Coverage for fastblocks/adapters/templates/async_renderer.py: 29%
264 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-29 00:51 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-29 00:51 -0700
1"""Enhanced Async Template Renderer for FastBlocks.
3This module provides advanced async template rendering with:
4- Enhanced error handling with detailed context
5- Performance optimization with caching layers
6- HTMX-aware fragment rendering
7- Streaming template rendering for large responses
8- Template dependency tracking and hot reloading
10Requirements:
11- jinja2>=3.1.6
12- jinja2-async-environment>=0.14.3
13- starlette-async-jinja>=1.12.4
15Author: lesleslie <les@wedgwoodwebworks.com>
16Created: 2025-01-12
17"""
19import time
20import typing as t
21from collections.abc import AsyncIterator
22from contextlib import suppress
23from dataclasses import dataclass, field
24from enum import Enum
25from uuid import UUID
27from acb.adapters import AdapterStatus
28from acb.depends import depends
29from anyio import Path as AsyncPath
30from starlette.requests import Request
31from starlette.responses import HTMLResponse, Response, StreamingResponse
33from .advanced_manager import AdvancedTemplateManager, TemplateValidationResult
34from .jinja2 import Templates
37class RenderMode(Enum):
38 """Template rendering modes."""
40 STANDARD = "standard"
41 FRAGMENT = "fragment"
42 BLOCK = "block"
43 STREAMING = "streaming"
44 HTMX = "htmx"
47class CacheStrategy(Enum):
48 """Template caching strategies."""
50 NONE = "none"
51 MEMORY = "memory"
52 REDIS = "redis"
53 HYBRID = "hybrid"
56@dataclass
57class RenderContext:
58 """Template rendering context with metadata."""
60 template_name: str
61 context: dict[str, t.Any]
62 request: Request | None = None
63 mode: RenderMode = RenderMode.STANDARD
64 fragment_name: str | None = None
65 block_name: str | None = None
66 cache_key: str | None = None
67 cache_ttl: int = 300
68 enable_streaming: bool = False
69 chunk_size: int = 8192
70 validate_template: bool = False
71 secure_render: bool = False
74@dataclass
75class RenderResult:
76 """Result of template rendering operation."""
78 content: str | AsyncIterator[str]
79 content_type: str = "text/html"
80 status_code: int = 200
81 headers: dict[str, str] = field(default_factory=dict)
82 render_time: float = 0.0
83 cache_hit: bool = False
84 validation_result: TemplateValidationResult | None = None
85 template_path: str | None = None
86 fragment_info: dict[str, t.Any] = field(default_factory=dict)
89class AsyncTemplateRenderer:
90 """Enhanced async template renderer with advanced features."""
92 def __init__(
93 self,
94 base_templates: Templates | None = None,
95 advanced_manager: AdvancedTemplateManager | None = None,
96 cache_strategy: CacheStrategy = CacheStrategy.MEMORY,
97 ) -> None:
98 self.base_templates = base_templates
99 self.advanced_manager = advanced_manager
100 self.cache_strategy = cache_strategy
101 self._render_cache: dict[str, tuple[str, float]] = {}
102 self._template_watchers: dict[str, float] = {}
103 self._performance_metrics: dict[str, list[float]] = {}
105 async def initialize(self) -> None:
106 """Initialize the async renderer."""
107 if not self.base_templates:
108 try:
109 self.base_templates = depends.get("templates")
110 except Exception:
111 self.base_templates = Templates()
112 await self.base_templates.init()
114 if not self.advanced_manager:
115 try:
116 self.advanced_manager = depends.get("advanced_template_manager")
117 except Exception:
118 self.advanced_manager = AdvancedTemplateManager()
119 await self.advanced_manager.initialize()
121 async def render(self, render_context: RenderContext) -> RenderResult:
122 """Render template with enhanced error handling and optimization."""
123 start_time = time.time()
125 try:
126 # Validate template if requested
127 validation_result = None
128 if render_context.validate_template:
129 validation_result = await self._validate_before_render(render_context)
130 if not validation_result.is_valid:
131 return self._create_error_result(
132 "Template validation failed",
133 validation_result=validation_result,
134 render_time=time.time() - start_time,
135 )
137 # Check cache first
138 if render_context.cache_key:
139 cached_result = await self._check_cache(render_context)
140 if cached_result:
141 cached_result.render_time = time.time() - start_time
142 cached_result.cache_hit = True
143 return cached_result
145 # Choose rendering strategy
146 if render_context.mode == RenderMode.STREAMING:
147 content = await self._render_streaming(render_context)
148 elif render_context.mode == RenderMode.FRAGMENT:
149 content = await self._render_fragment(render_context)
150 elif render_context.mode == RenderMode.BLOCK:
151 content = await self._render_block(render_context)
152 elif render_context.mode == RenderMode.HTMX:
153 content = await self._render_htmx(render_context)
154 else:
155 content = await self._render_standard(render_context)
157 # Create result
158 result = RenderResult(
159 content=content,
160 render_time=time.time() - start_time,
161 validation_result=validation_result,
162 template_path=render_context.template_name,
163 )
165 # Cache result if appropriate
166 if render_context.cache_key and isinstance(content, str):
167 await self._cache_result(render_context, result)
169 # Track performance metrics
170 self._track_performance(render_context.template_name, result.render_time)
172 return result
174 except Exception as e:
175 return self._create_error_result(
176 str(e), render_time=time.time() - start_time, status_code=500
177 )
179 async def _validate_before_render(
180 self, render_context: RenderContext
181 ) -> TemplateValidationResult:
182 """Validate template before rendering."""
183 if not self.advanced_manager:
184 return TemplateValidationResult(is_valid=True)
186 try:
187 # Get template source
188 env = self.base_templates.app.env
189 source, _ = env.loader.get_source(env, render_context.template_name)
191 return await self.advanced_manager.validate_template(
192 source, render_context.template_name, render_context.context
193 )
194 except Exception:
195 return TemplateValidationResult(is_valid=False, errors=[], warnings=[])
197 async def _check_cache(self, render_context: RenderContext) -> RenderResult | None:
198 """Check if rendered result is cached."""
199 if self.cache_strategy == CacheStrategy.NONE or not render_context.cache_key:
200 return None
202 if self.cache_strategy == CacheStrategy.MEMORY:
203 return self._check_memory_cache(render_context)
205 elif self.cache_strategy == CacheStrategy.REDIS:
206 return await self._check_redis_cache(render_context)
208 elif self.cache_strategy == CacheStrategy.HYBRID:
209 # Try memory first, then Redis
210 result = self._check_memory_cache(render_context)
211 if result:
212 return result
213 return await self._check_redis_cache(render_context)
215 return None
217 def _check_memory_cache(self, render_context: RenderContext) -> RenderResult | None:
218 """Check memory cache for cached result."""
219 cache_key = render_context.cache_key
220 if cache_key in self._render_cache:
221 content, timestamp = self._render_cache[cache_key]
222 if time.time() - timestamp < render_context.cache_ttl:
223 return RenderResult(content=content, cache_hit=True)
224 else:
225 del self._render_cache[cache_key]
227 return None
229 async def _check_redis_cache(
230 self, render_context: RenderContext
231 ) -> RenderResult | None:
232 """Check Redis cache for cached result."""
233 try:
234 cache = depends.get("cache")
235 if cache:
236 cached_content = await cache.get(render_context.cache_key)
237 if cached_content:
238 return RenderResult(content=cached_content, cache_hit=True)
239 except Exception:
240 pass
242 return None
244 async def _render_standard(self, render_context: RenderContext) -> str:
245 """Render template using standard mode."""
246 if not self.base_templates or not self.base_templates.app:
247 raise RuntimeError("Templates not initialized")
249 template = self.base_templates.app.env.get_template(
250 render_context.template_name
251 )
253 # Use secure environment if requested
254 if render_context.secure_render and self.advanced_manager:
255 env = self.advanced_manager._get_template_environment(secure=True)
256 template = env.get_template(render_context.template_name)
258 return await template.render_async(render_context.context)
260 async def _render_fragment(self, render_context: RenderContext) -> str:
261 """Render template fragment for HTMX."""
262 if not self.advanced_manager:
263 raise RuntimeError("Advanced manager required for fragment rendering")
265 if not render_context.fragment_name:
266 raise ValueError("Fragment name required for fragment rendering")
268 return await self.advanced_manager.render_fragment(
269 render_context.fragment_name,
270 render_context.context,
271 render_context.template_name,
272 render_context.secure_render,
273 )
275 async def _render_block(self, render_context: RenderContext) -> str:
276 """Render specific template block."""
277 if not self.base_templates or not self.base_templates.app:
278 raise RuntimeError("Templates not initialized")
280 if not render_context.block_name:
281 raise ValueError("Block name required for block rendering")
283 template = self.base_templates.app.env.get_template(
284 render_context.template_name
285 )
286 return template.render_block(render_context.block_name, render_context.context)
288 async def _render_htmx(self, render_context: RenderContext) -> str:
289 """Render template optimized for HTMX responses."""
290 # Add HTMX-specific context
291 htmx_context = {
292 **render_context.context,
293 "is_htmx": True,
294 "htmx_request": getattr(render_context.request, "htmx", None)
295 if render_context.request
296 else None,
297 }
299 # Update context and render
300 render_context.context = htmx_context
302 if render_context.fragment_name:
303 return await self._render_fragment(render_context)
304 elif render_context.block_name:
305 return await self._render_block(render_context)
306 else:
307 return await self._render_standard(render_context)
309 async def _render_streaming(
310 self, render_context: RenderContext
311 ) -> AsyncIterator[str]:
312 """Render template with streaming for large responses."""
313 if not self.base_templates or not self.base_templates.app:
314 raise RuntimeError("Templates not initialized")
316 template = self.base_templates.app.env.get_template(
317 render_context.template_name
318 )
320 # Generate template content in chunks
321 async for chunk in template.generate_async(render_context.context):
322 # Yield chunks of specified size
323 if len(chunk) > render_context.chunk_size:
324 for i in range(0, len(chunk), render_context.chunk_size):
325 yield chunk[i : i + render_context.chunk_size]
326 else:
327 yield chunk
329 async def _cache_result(
330 self, render_context: RenderContext, result: RenderResult
331 ) -> None:
332 """Cache the rendered result."""
333 if not render_context.cache_key or not isinstance(result.content, str):
334 return
336 if self.cache_strategy in (CacheStrategy.MEMORY, CacheStrategy.HYBRID):
337 self._render_cache[render_context.cache_key] = (result.content, time.time())
339 if self.cache_strategy in (CacheStrategy.REDIS, CacheStrategy.HYBRID):
340 try:
341 cache = depends.get("cache")
342 if cache:
343 await cache.set(
344 render_context.cache_key,
345 result.content,
346 ttl=render_context.cache_ttl,
347 )
348 except Exception:
349 pass
351 def _track_performance(self, template_name: str, render_time: float) -> None:
352 """Track rendering performance metrics."""
353 if template_name not in self._performance_metrics:
354 self._performance_metrics[template_name] = []
356 metrics = self._performance_metrics[template_name]
357 metrics.append(render_time)
359 # Keep only last 100 measurements
360 if len(metrics) > 100:
361 metrics.pop(0)
363 def _create_error_result(
364 self,
365 error_message: str,
366 status_code: int = 500,
367 render_time: float = 0.0,
368 validation_result: TemplateValidationResult | None = None,
369 ) -> RenderResult:
370 """Create error result with helpful debugging information."""
371 error_html = self._generate_error_html(error_message, validation_result)
373 return RenderResult(
374 content=error_html,
375 status_code=status_code,
376 render_time=render_time,
377 validation_result=validation_result,
378 )
380 def _generate_error_html(
381 self,
382 error_message: str,
383 validation_result: TemplateValidationResult | None = None,
384 ) -> str:
385 """Generate helpful error HTML for template issues."""
386 error_html = [
387 '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 20px; margin: 20px; font-family: monospace;">',
388 '<h3 style="color: #dc3545; margin-top: 0;">Template Error</h3>',
389 f"<p><strong>Error:</strong> {error_message}</p>",
390 ]
392 if validation_result and validation_result.errors:
393 error_html.append('<h4 style="color: #dc3545;">Validation Errors:</h4>')
394 error_html.append("<ul>")
395 for error in validation_result.errors:
396 line_info = f" (line {error.line_number})" if error.line_number else ""
397 error_html.append(f"<li>{error.message}{line_info}</li>")
398 if error.context:
399 error_html.append(
400 f'<pre style="background: #f1f3f4; padding: 10px; margin: 5px 0;">{error.context}</pre>'
401 )
402 error_html.append("</ul>")
404 if validation_result and validation_result.suggestions:
405 error_html.append('<h4 style="color: #007bff;">Suggestions:</h4>')
406 error_html.append("<ul>")
407 for suggestion in validation_result.suggestions:
408 error_html.append(f"<li>{suggestion}</li>")
409 error_html.append("</ul>")
411 error_html.append("</div>")
412 return "".join(error_html)
414 async def render_response(
415 self,
416 request: Request,
417 template_name: str,
418 context: dict[str, t.Any] | None = None,
419 **kwargs: t.Any,
420 ) -> Response:
421 """Render template and return appropriate Response object."""
422 render_context = RenderContext(
423 template_name=template_name,
424 context=context or {},
425 request=request,
426 **kwargs,
427 )
429 result = await self.render(render_context)
431 # Handle streaming responses
432 if isinstance(result.content, AsyncIterator):
433 return StreamingResponse(
434 result.content,
435 status_code=result.status_code,
436 headers=result.headers,
437 media_type=result.content_type,
438 )
440 # Standard HTML response
441 return HTMLResponse(
442 content=result.content,
443 status_code=result.status_code,
444 headers=result.headers,
445 )
447 async def render_htmx_fragment(
448 self,
449 request: Request,
450 fragment_name: str,
451 context: dict[str, t.Any] | None = None,
452 template_name: str | None = None,
453 **kwargs: t.Any,
454 ) -> Response:
455 """Render HTMX fragment with appropriate headers."""
456 render_context = RenderContext(
457 template_name=template_name or f"_{fragment_name}.html",
458 context=context or {},
459 request=request,
460 mode=RenderMode.HTMX,
461 fragment_name=fragment_name,
462 **kwargs,
463 )
465 result = await self.render(render_context)
467 # Add HTMX-specific headers
468 headers = {"HX-Content-Type": "text/html", **result.headers}
470 return HTMLResponse(
471 content=result.content, status_code=result.status_code, headers=headers
472 )
474 async def get_performance_metrics(
475 self, template_name: str | None = None
476 ) -> dict[str, t.Any]:
477 """Get performance metrics for templates."""
478 if template_name and template_name in self._performance_metrics:
479 metrics = self._performance_metrics[template_name]
480 return {
481 "template": template_name,
482 "avg_render_time": sum(metrics) / len(metrics),
483 "min_render_time": min(metrics),
484 "max_render_time": max(metrics),
485 "render_count": len(metrics),
486 "recent_times": metrics[-10:], # Last 10 renders
487 }
489 # Return aggregate metrics
490 all_metrics = {}
491 for tmpl_name, times in self._performance_metrics.items():
492 all_metrics[tmpl_name] = {
493 "avg_render_time": sum(times) / len(times),
494 "min_render_time": min(times),
495 "max_render_time": max(times),
496 "render_count": len(times),
497 }
499 return all_metrics
501 def clear_cache(self, template_pattern: str | None = None) -> None:
502 """Clear render cache, optionally for specific template pattern."""
503 if template_pattern:
504 keys_to_remove = [
505 key for key in self._render_cache.keys() if template_pattern in key
506 ]
507 for key in keys_to_remove:
508 del self._render_cache[key]
509 else:
510 self._render_cache.clear()
512 async def watch_template_changes(self, template_name: str) -> bool:
513 """Check if template has changed since last render."""
514 try:
515 env = self.base_templates.app.env
516 source, filename = env.loader.get_source(env, template_name)
518 if filename:
519 # Check file modification time
520 path = AsyncPath(filename)
521 if await path.exists():
522 stat = await path.stat()
523 current_mtime = stat.st_mtime
525 if template_name in self._template_watchers:
526 last_mtime = self._template_watchers[template_name]
527 if current_mtime > last_mtime:
528 self._template_watchers[template_name] = current_mtime
529 return True
530 else:
531 self._template_watchers[template_name] = current_mtime
533 except Exception:
534 pass
536 return False
539MODULE_ID = UUID("01937d88-1234-7890-abcd-1234567890ab")
540MODULE_STATUS = AdapterStatus.STABLE
542# Register the async renderer
543with suppress(Exception):
544 depends.set("async_template_renderer", AsyncTemplateRenderer)