Coverage for integrations / channels / response / templates.py: 46.7%
165 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2TemplateEngine - Response formatting with variable substitution.
4Provides template rendering with support for common variables like
5{model}, {provider}, {identity.name}, {user.name}, {channel}, {timestamp}.
6"""
8import re
9import time
10from datetime import datetime
11from typing import Optional, Dict, Any, Callable, List
12from dataclasses import dataclass, field
13import logging
15logger = logging.getLogger(__name__)
18@dataclass
19class Identity:
20 """Bot identity information."""
21 name: str = "Assistant"
22 description: str = ""
23 avatar_url: str = ""
24 metadata: Dict[str, Any] = field(default_factory=dict)
27@dataclass
28class User:
29 """User information."""
30 name: str = "User"
31 id: str = ""
32 display_name: str = ""
33 metadata: Dict[str, Any] = field(default_factory=dict)
36@dataclass
37class TemplateContext:
38 """Context for template rendering."""
39 model: str = ""
40 provider: str = ""
41 identity: Identity = field(default_factory=Identity)
42 user: User = field(default_factory=User)
43 channel: str = ""
44 timestamp: Optional[datetime] = None
45 custom_vars: Dict[str, Any] = field(default_factory=dict)
47 def to_dict(self) -> Dict[str, Any]:
48 """Convert context to flat dictionary for variable substitution."""
49 ts = self.timestamp or datetime.now()
51 return {
52 "model": self.model,
53 "provider": self.provider,
54 "identity.name": self.identity.name,
55 "identity.description": self.identity.description,
56 "identity.avatar_url": self.identity.avatar_url,
57 "user.name": self.user.name or self.user.display_name,
58 "user.id": self.user.id,
59 "user.display_name": self.user.display_name or self.user.name,
60 "channel": self.channel,
61 "timestamp": ts.strftime("%Y-%m-%d %H:%M:%S"),
62 "timestamp.date": ts.strftime("%Y-%m-%d"),
63 "timestamp.time": ts.strftime("%H:%M:%S"),
64 "timestamp.iso": ts.isoformat(),
65 "timestamp.unix": str(int(ts.timestamp())),
66 **self.custom_vars
67 }
70@dataclass
71class TemplateConfig:
72 """Configuration for the template engine."""
73 prefix: str = ""
74 suffix: str = ""
75 default_format: str = "{content}"
76 escape_html: bool = False
77 strict_mode: bool = False # Raise error on missing variables
78 missing_var_placeholder: str = "" # What to show for missing vars
81class TemplateEngine:
82 """
83 Template engine for response formatting.
85 Supports variable substitution using {variable} syntax,
86 with support for nested variables like {identity.name}.
87 """
89 # Pattern to match {variable} or {variable.subvar}
90 VAR_PATTERN = re.compile(r'\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}')
92 def __init__(self, config: Optional[TemplateConfig] = None):
93 """
94 Initialize the TemplateEngine.
96 Args:
97 config: Optional configuration for template behavior.
98 """
99 self._config = config or TemplateConfig()
100 self._context = TemplateContext()
101 self._custom_filters: Dict[str, Callable[[str], str]] = {}
102 self._templates: Dict[str, str] = {}
104 @property
105 def config(self) -> TemplateConfig:
106 """Get the template configuration."""
107 return self._config
109 @property
110 def context(self) -> TemplateContext:
111 """Get the current template context."""
112 return self._context
114 def set_context(self, context: TemplateContext) -> None:
115 """Set the template context."""
116 self._context = context
118 def update_context(self, **kwargs) -> None:
119 """Update specific context fields."""
120 for key, value in kwargs.items():
121 if hasattr(self._context, key):
122 setattr(self._context, key, value)
123 else:
124 self._context.custom_vars[key] = value
126 def set_model(self, model: str) -> None:
127 """Set the model name."""
128 self._context.model = model
130 def set_provider(self, provider: str) -> None:
131 """Set the provider name."""
132 self._context.provider = provider
134 def set_identity(self, identity: Identity) -> None:
135 """Set the bot identity."""
136 self._context.identity = identity
138 def set_user(self, user: User) -> None:
139 """Set the user information."""
140 self._context.user = user
142 def set_channel(self, channel: str) -> None:
143 """Set the channel name."""
144 self._context.channel = channel
146 def set_prefix(self, prefix: str) -> None:
147 """Set the response prefix."""
148 self._config.prefix = prefix
150 def set_suffix(self, suffix: str) -> None:
151 """Set the response suffix."""
152 self._config.suffix = suffix
154 def set_variable(self, name: str, value: Any) -> None:
155 """Set a custom variable."""
156 self._context.custom_vars[name] = value
158 def get_variable(self, name: str) -> Optional[Any]:
159 """Get a variable value."""
160 context_dict = self._context.to_dict()
161 return context_dict.get(name, self._context.custom_vars.get(name))
163 def register_template(self, name: str, template: str) -> None:
164 """Register a named template for later use."""
165 self._templates[name] = template
167 def get_template(self, name: str) -> Optional[str]:
168 """Get a registered template by name."""
169 return self._templates.get(name)
171 def list_templates(self) -> List[str]:
172 """List all registered template names."""
173 return list(self._templates.keys())
175 def register_filter(self, name: str, func: Callable[[str], str]) -> None:
176 """
177 Register a custom filter function.
179 Filters can be applied using {variable|filter} syntax.
180 """
181 self._custom_filters[name] = func
183 def render(self, template: str, context: Optional[TemplateContext] = None,
184 extra_vars: Optional[Dict[str, Any]] = None) -> str:
185 """
186 Render a template with variable substitution.
188 Args:
189 template: The template string with {variable} placeholders.
190 context: Optional context to use (defaults to stored context).
191 extra_vars: Additional variables to include.
193 Returns:
194 Rendered template string.
196 Raises:
197 ValueError: If strict_mode is True and a variable is missing.
198 """
199 ctx = context or self._context
200 var_dict = ctx.to_dict()
202 if extra_vars:
203 var_dict.update(extra_vars)
205 def replace_var(match):
206 var_name = match.group(1)
208 # Check for filter syntax: {var|filter}
209 if '|' in var_name:
210 var_name, filter_name = var_name.split('|', 1)
211 value = var_dict.get(var_name)
212 if value is not None and filter_name in self._custom_filters:
213 return self._custom_filters[filter_name](str(value))
215 value = var_dict.get(var_name)
217 if value is None:
218 if self._config.strict_mode:
219 raise ValueError(f"Missing template variable: {var_name}")
220 return self._config.missing_var_placeholder or match.group(0)
222 result = str(value)
223 if self._config.escape_html:
224 result = self._escape_html(result)
225 return result
227 return self.VAR_PATTERN.sub(replace_var, template)
229 def render_named(self, template_name: str, context: Optional[TemplateContext] = None,
230 extra_vars: Optional[Dict[str, Any]] = None) -> str:
231 """
232 Render a registered template by name.
234 Args:
235 template_name: Name of the registered template.
236 context: Optional context to use.
237 extra_vars: Additional variables to include.
239 Returns:
240 Rendered template string.
242 Raises:
243 KeyError: If template name is not found.
244 """
245 template = self._templates.get(template_name)
246 if template is None:
247 raise KeyError(f"Template not found: {template_name}")
248 return self.render(template, context, extra_vars)
250 def format_response(self, content: str, context: Optional[TemplateContext] = None,
251 extra_vars: Optional[Dict[str, Any]] = None) -> str:
252 """
253 Format a response with prefix, suffix, and variable substitution.
255 Args:
256 content: The main response content.
257 context: Optional context to use.
258 extra_vars: Additional variables to include.
260 Returns:
261 Formatted response string.
262 """
263 ctx = context or self._context
264 vars_dict = {"content": content}
265 if extra_vars:
266 vars_dict.update(extra_vars)
268 # Render prefix
269 prefix = ""
270 if self._config.prefix:
271 prefix = self.render(self._config.prefix, ctx, vars_dict)
273 # Render suffix
274 suffix = ""
275 if self._config.suffix:
276 suffix = self.render(self._config.suffix, ctx, vars_dict)
278 # Render content with format template
279 formatted = self.render(self._config.default_format, ctx, vars_dict)
281 return f"{prefix}{formatted}{suffix}"
283 def _escape_html(self, text: str) -> str:
284 """Escape HTML special characters."""
285 replacements = {
286 '&': '&',
287 '<': '<',
288 '>': '>',
289 '"': '"',
290 "'": '''
291 }
292 for char, escaped in replacements.items():
293 text = text.replace(char, escaped)
294 return text
296 def get_available_variables(self) -> List[str]:
297 """Get list of available variable names."""
298 return list(self._context.to_dict().keys())
300 def validate_template(self, template: str) -> Dict[str, Any]:
301 """
302 Validate a template string.
304 Returns:
305 Dictionary with validation results.
306 """
307 variables = self.VAR_PATTERN.findall(template)
308 available = set(self._context.to_dict().keys())
310 missing = [v for v in variables if v not in available and v not in self._context.custom_vars]
312 return {
313 "valid": len(missing) == 0,
314 "variables_used": variables,
315 "missing_variables": missing,
316 "available_variables": list(available)
317 }
319 def create_context(
320 self,
321 model: str = "",
322 provider: str = "",
323 identity_name: str = "Assistant",
324 user_name: str = "User",
325 channel: str = "",
326 **custom_vars
327 ) -> TemplateContext:
328 """
329 Create a new template context with common values.
331 Args:
332 model: Model name.
333 provider: Provider name.
334 identity_name: Bot identity name.
335 user_name: User name.
336 channel: Channel name.
337 **custom_vars: Additional custom variables.
339 Returns:
340 New TemplateContext instance.
341 """
342 return TemplateContext(
343 model=model,
344 provider=provider,
345 identity=Identity(name=identity_name),
346 user=User(name=user_name),
347 channel=channel,
348 timestamp=datetime.now(),
349 custom_vars=custom_vars
350 )
352 def clone(self) -> 'TemplateEngine':
353 """Create a copy of this template engine."""
354 new_engine = TemplateEngine(TemplateConfig(
355 prefix=self._config.prefix,
356 suffix=self._config.suffix,
357 default_format=self._config.default_format,
358 escape_html=self._config.escape_html,
359 strict_mode=self._config.strict_mode,
360 missing_var_placeholder=self._config.missing_var_placeholder
361 ))
362 new_engine._context = TemplateContext(
363 model=self._context.model,
364 provider=self._context.provider,
365 identity=Identity(
366 name=self._context.identity.name,
367 description=self._context.identity.description,
368 avatar_url=self._context.identity.avatar_url
369 ),
370 user=User(
371 name=self._context.user.name,
372 id=self._context.user.id,
373 display_name=self._context.user.display_name
374 ),
375 channel=self._context.channel,
376 timestamp=self._context.timestamp,
377 custom_vars=dict(self._context.custom_vars)
378 )
379 new_engine._custom_filters = dict(self._custom_filters)
380 new_engine._templates = dict(self._templates)
381 return new_engine
383 # Built-in filters
384 @staticmethod
385 def filter_upper(value: str) -> str:
386 """Convert to uppercase."""
387 return value.upper()
389 @staticmethod
390 def filter_lower(value: str) -> str:
391 """Convert to lowercase."""
392 return value.lower()
394 @staticmethod
395 def filter_title(value: str) -> str:
396 """Convert to title case."""
397 return value.title()
399 @staticmethod
400 def filter_strip(value: str) -> str:
401 """Strip whitespace."""
402 return value.strip()
404 def register_default_filters(self) -> None:
405 """Register common built-in filters."""
406 self.register_filter("upper", self.filter_upper)
407 self.register_filter("lower", self.filter_lower)
408 self.register_filter("title", self.filter_title)
409 self.register_filter("strip", self.filter_strip)
411 def get_stats(self) -> dict:
412 """Get statistics about the template engine."""
413 return {
414 "registered_templates": len(self._templates),
415 "custom_filters": len(self._custom_filters),
416 "custom_variables": len(self._context.custom_vars),
417 "prefix_set": bool(self._config.prefix),
418 "suffix_set": bool(self._config.suffix)
419 }