Coverage for fastblocks/adapters/templates/_async_renderer.py: 31%
297 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:37 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:37 -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 ._performance_optimizer import (
35 PerformanceMetrics,
36 PerformanceOptimizer,
37 get_performance_optimizer,
38)
39from .jinja2 import Templates
42class RenderMode(Enum):
43 """Template rendering modes."""
45 STANDARD = "standard"
46 FRAGMENT = "fragment"
47 BLOCK = "block"
48 STREAMING = "streaming"
49 HTMX = "htmx"
52class CacheStrategy(Enum):
53 """Template caching strategies."""
55 NONE = "none"
56 MEMORY = "memory"
57 REDIS = "redis"
58 HYBRID = "hybrid"
61@dataclass
62class RenderContext:
63 """Template rendering context with metadata."""
65 template_name: str
66 context: dict[str, t.Any]
67 request: Request | None = None
68 mode: RenderMode = RenderMode.STANDARD
69 fragment_name: str | None = None
70 block_name: str | None = None
71 cache_key: str | None = None
72 cache_ttl: int = 300
73 enable_streaming: bool = False
74 chunk_size: int = 8192
75 validate_template: bool = False
76 secure_render: bool = False
79@dataclass
80class RenderResult:
81 """Result of template rendering operation."""
83 content: str | AsyncIterator[str]
84 content_type: str = "text/html"
85 status_code: int = 200
86 headers: dict[str, str] = field(default_factory=dict)
87 render_time: float = 0.0
88 cache_hit: bool = False
89 validation_result: TemplateValidationResult | None = None
90 template_path: str | None = None
91 fragment_info: dict[str, t.Any] = field(default_factory=dict)
94class AsyncTemplateRenderer:
95 """Enhanced async template renderer with advanced features."""
97 def __init__(
98 self,
99 base_templates: Templates | None = None,
100 advanced_manager: AdvancedTemplateManager | None = None,
101 cache_strategy: CacheStrategy = CacheStrategy.MEMORY,
102 performance_optimizer: PerformanceOptimizer | None = None,
103 ) -> None:
104 self.base_templates = base_templates
105 self.advanced_manager = advanced_manager
106 self.cache_strategy = cache_strategy
107 self.performance_optimizer = (
108 performance_optimizer or get_performance_optimizer()
109 )
110 self._render_cache: dict[str, tuple[str, float]] = {}
111 self._template_watchers: dict[str, float] = {}
112 self._performance_metrics: dict[str, list[float]] = {}
114 async def initialize(self) -> None:
115 """Initialize the async renderer."""
116 if not self.base_templates:
117 try:
118 self.base_templates = depends.get("templates")
119 except Exception:
120 self.base_templates = Templates()
121 await self.base_templates.init()
123 if not self.advanced_manager:
124 try:
125 self.advanced_manager = depends.get("advanced_template_manager")
126 except Exception:
127 self.advanced_manager = AdvancedTemplateManager()
128 await self.advanced_manager.initialize()
130 async def render(self, render_context: RenderContext) -> RenderResult:
131 """Render template with enhanced error handling and optimization."""
132 start_time = time.time()
134 try:
135 await self._optimize_render_context(render_context)
136 context_size = len(str(render_context.context))
138 validation_result = await self._validate_if_requested(
139 render_context, start_time
140 )
141 if validation_result and not validation_result.is_valid:
142 return self._create_error_result(
143 "Template validation failed",
144 validation_result=validation_result,
145 render_time=time.time() - start_time,
146 )
148 cached_result = await self._try_get_cached(render_context, start_time)
149 if cached_result:
150 return cached_result
152 content = await self._execute_render_strategy(render_context)
153 result = await self._finalize_render_result(
154 render_context, content, validation_result, context_size, start_time
155 )
157 return result
159 except Exception as e:
160 return self._create_error_result(
161 str(e), render_time=time.time() - start_time, status_code=500
162 )
164 async def _optimize_render_context(self, render_context: RenderContext) -> None:
165 """Apply performance optimizations to render context."""
166 optimized_context = await self.performance_optimizer.optimize_render_context(
167 render_context.template_name, render_context.context
168 )
169 render_context.context = optimized_context
171 context_size = len(str(render_context.context))
173 if not render_context.enable_streaming:
174 render_context.enable_streaming = (
175 self.performance_optimizer.should_enable_streaming(
176 render_context.template_name, context_size
177 )
178 )
179 if render_context.enable_streaming:
180 render_context.mode = RenderMode.STREAMING
182 if render_context.cache_key and render_context.cache_ttl == 300:
183 render_context.cache_ttl = self.performance_optimizer.get_optimal_cache_ttl(
184 render_context.template_name
185 )
187 async def _validate_if_requested(
188 self, render_context: RenderContext, start_time: float
189 ) -> TemplateValidationResult | None:
190 """Validate template if validation is requested."""
191 if render_context.validate_template:
192 return await self._validate_before_render(render_context)
193 return None
195 async def _try_get_cached(
196 self, render_context: RenderContext, start_time: float
197 ) -> RenderResult | None:
198 """Try to get cached result if caching is enabled."""
199 if render_context.cache_key:
200 cached_result = await self._check_cache(render_context)
201 if cached_result:
202 cached_result.render_time = time.time() - start_time
203 cached_result.cache_hit = True
204 return cached_result
205 return None
207 async def _execute_render_strategy(
208 self, render_context: RenderContext
209 ) -> str | AsyncIterator[str]:
210 """Execute the appropriate rendering strategy based on mode."""
211 if render_context.mode == RenderMode.STREAMING:
212 # _render_streaming returns an AsyncIterator, not awaitable
213 result: str | AsyncIterator[str] = self._render_streaming(render_context)
214 return result
215 elif render_context.mode == RenderMode.FRAGMENT:
216 return await self._render_fragment(render_context)
217 elif render_context.mode == RenderMode.BLOCK:
218 return await self._render_block(render_context)
219 elif render_context.mode == RenderMode.HTMX:
220 return await self._render_htmx(render_context)
222 return await self._render_standard(render_context)
224 async def _finalize_render_result(
225 self,
226 render_context: RenderContext,
227 content: str | AsyncIterator[str],
228 validation_result: TemplateValidationResult | None,
229 context_size: int,
230 start_time: float,
231 ) -> RenderResult:
232 """Finalize render result with caching and metrics."""
233 result = RenderResult(
234 content=content,
235 render_time=time.time() - start_time,
236 validation_result=validation_result,
237 template_path=render_context.template_name,
238 )
240 if render_context.cache_key and isinstance(content, str):
241 await self._cache_result(render_context, result)
243 self._track_performance(render_context.template_name, result.render_time)
245 performance_metrics = PerformanceMetrics(
246 render_time=result.render_time,
247 cache_hit=result.cache_hit,
248 template_size=len(render_context.template_name),
249 context_size=context_size,
250 fragment_count=1 if render_context.fragment_name else 0,
251 memory_usage=0,
252 concurrent_renders=1,
253 )
255 self.performance_optimizer.record_render(
256 render_context.template_name, performance_metrics
257 )
259 return result
261 async def _validate_before_render(
262 self, render_context: RenderContext
263 ) -> TemplateValidationResult:
264 """Validate template before rendering."""
265 if not self.advanced_manager:
266 return TemplateValidationResult(is_valid=True)
268 try:
269 # Get template source
270 env = self.base_templates.app.env # type: ignore[union-attr]
271 source, _ = env.loader.get_source(env, render_context.template_name)
273 return await self.advanced_manager.validate_template(
274 source, render_context.template_name, render_context.context
275 )
276 except Exception:
277 return TemplateValidationResult(is_valid=False, errors=[], warnings=[])
279 async def _check_cache(self, render_context: RenderContext) -> RenderResult | None:
280 """Check if rendered result is cached."""
281 if self.cache_strategy == CacheStrategy.NONE or not render_context.cache_key:
282 return None
284 if self.cache_strategy == CacheStrategy.MEMORY:
285 return self._check_memory_cache(render_context)
287 elif self.cache_strategy == CacheStrategy.REDIS:
288 return await self._check_redis_cache(render_context)
290 elif self.cache_strategy == CacheStrategy.HYBRID:
291 # Try memory first, then Redis
292 result = self._check_memory_cache(render_context)
293 if result:
294 return result
295 return await self._check_redis_cache(render_context)
297 return None
299 def _check_memory_cache(self, render_context: RenderContext) -> RenderResult | None:
300 """Check memory cache for cached result."""
301 cache_key = render_context.cache_key
302 if cache_key in self._render_cache:
303 content, timestamp = self._render_cache[cache_key]
304 if time.time() - timestamp < render_context.cache_ttl:
305 return RenderResult(content=content, cache_hit=True)
306 else:
307 del self._render_cache[cache_key]
309 return None
311 async def _check_redis_cache(
312 self, render_context: RenderContext
313 ) -> RenderResult | None:
314 """Check Redis cache for cached result."""
315 with suppress(Exception):
316 cache = depends.get("cache")
317 if cache:
318 cached_content = await cache.get(render_context.cache_key)
319 if cached_content:
320 return RenderResult(content=cached_content, cache_hit=True)
322 return None
324 async def _render_standard(self, render_context: RenderContext) -> str:
325 """Render template using standard mode."""
326 if not self.base_templates or not self.base_templates.app:
327 raise RuntimeError("Templates not initialized")
329 template = self.base_templates.app.env.get_template(
330 render_context.template_name
331 )
333 # Use secure environment if requested
334 if render_context.secure_render and self.advanced_manager:
335 env = self.advanced_manager._get_template_environment(secure=True)
336 template = env.get_template(render_context.template_name)
338 rendered = await template.render_async(render_context.context)
339 return t.cast(str, rendered)
341 async def _render_fragment(self, render_context: RenderContext) -> str:
342 """Render template fragment for HTMX."""
343 if not self.advanced_manager:
344 raise RuntimeError("Advanced manager required for fragment rendering")
346 if not render_context.fragment_name:
347 raise ValueError("Fragment name required for fragment rendering")
349 return await self.advanced_manager.render_fragment(
350 render_context.fragment_name,
351 render_context.context,
352 render_context.template_name,
353 render_context.secure_render,
354 )
356 async def _render_block(self, render_context: RenderContext) -> str:
357 """Render specific template block."""
358 if not self.base_templates or not self.base_templates.app:
359 raise RuntimeError("Templates not initialized")
361 if not render_context.block_name:
362 raise ValueError("Block name required for block rendering")
364 template = self.base_templates.app.env.get_template(
365 render_context.template_name
366 )
367 # render_block exists in Jinja2 runtime but not in type stubs
368 rendered = template.render_block( # type: ignore[attr-defined]
369 render_context.block_name, render_context.context
370 )
371 return t.cast(str, rendered)
373 async def _render_htmx(self, render_context: RenderContext) -> str:
374 """Render template optimized for HTMX responses."""
375 # Add HTMX-specific context
376 htmx_context = render_context.context | {
377 "is_htmx": True,
378 "htmx_request": getattr(render_context.request, "htmx", None)
379 if render_context.request
380 else None,
381 }
383 # Update context and render
384 render_context.context = htmx_context
386 if render_context.fragment_name:
387 return await self._render_fragment(render_context)
388 elif render_context.block_name:
389 return await self._render_block(render_context)
391 return await self._render_standard(render_context)
393 def _render_streaming(self, render_context: RenderContext) -> AsyncIterator[str]:
394 """Render template with streaming for large responses."""
395 if not self.base_templates or not self.base_templates.app:
396 raise RuntimeError("Templates not initialized")
398 template = self.base_templates.app.env.get_template(
399 render_context.template_name
400 )
402 # Return async generator directly
403 return self._stream_template_chunks(template, render_context)
405 async def _stream_template_chunks(
406 self, template: t.Any, render_context: RenderContext
407 ) -> AsyncIterator[str]:
408 """Internal async generator for streaming template chunks."""
409 # Generate template content in chunks
410 async for chunk in template.generate_async(render_context.context):
411 # Yield chunks of specified size
412 if len(chunk) > render_context.chunk_size:
413 for i in range(0, len(chunk), render_context.chunk_size):
414 yield chunk[i : i + render_context.chunk_size]
415 else:
416 yield chunk
418 async def _cache_result(
419 self, render_context: RenderContext, result: RenderResult
420 ) -> None:
421 """Cache the rendered result."""
422 if not render_context.cache_key or not isinstance(result.content, str):
423 return
425 if self.cache_strategy in (CacheStrategy.MEMORY, CacheStrategy.HYBRID):
426 self._render_cache[render_context.cache_key] = (result.content, time.time())
428 if self.cache_strategy in (CacheStrategy.REDIS, CacheStrategy.HYBRID):
429 with suppress(Exception):
430 cache = depends.get("cache")
431 if cache:
432 await cache.set(
433 render_context.cache_key,
434 result.content,
435 ttl=render_context.cache_ttl,
436 )
438 def _track_performance(self, template_name: str, render_time: float) -> None:
439 """Track rendering performance metrics."""
440 if template_name not in self._performance_metrics:
441 self._performance_metrics[template_name] = []
443 metrics = self._performance_metrics[template_name]
444 metrics.append(render_time)
446 # Keep only last 100 measurements
447 if len(metrics) > 100:
448 metrics.pop(0)
450 def _create_error_result(
451 self,
452 error_message: str,
453 status_code: int = 500,
454 render_time: float = 0.0,
455 validation_result: TemplateValidationResult | None = None,
456 ) -> RenderResult:
457 """Create error result with helpful debugging information."""
458 error_html = self._generate_error_html(error_message, validation_result)
460 return RenderResult(
461 content=error_html,
462 status_code=status_code,
463 render_time=render_time,
464 validation_result=validation_result,
465 )
467 def _generate_error_html(
468 self,
469 error_message: str,
470 validation_result: TemplateValidationResult | None = None,
471 ) -> str:
472 """Generate helpful error HTML for template issues."""
473 error_html = [
474 '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 20px; margin: 20px; font-family: monospace;">',
475 '<h3 style="color: #dc3545; margin-top: 0;">Template Error</h3>',
476 f"<p><strong>Error:</strong> {error_message}</p>",
477 ]
479 if validation_result and validation_result.errors:
480 error_html.extend(
481 ('<h4 style="color: #dc3545;">Validation Errors:</h4>', "<ul>")
482 )
483 for error in validation_result.errors:
484 line_info = f" (line {error.line_number})" if error.line_number else ""
485 error_html.append(f"<li>{error.message}{line_info}</li>")
486 if error.context:
487 error_html.extend(
488 (
489 f'<pre style="background: #f1f3f4; padding: 10px; margin: 5px 0;">{error.context}</pre>',
490 "</ul>",
491 )
492 )
494 if validation_result and validation_result.suggestions:
495 error_html.extend(('<h4 style="color: #007bff;">Suggestions:</h4>', "<ul>"))
496 for suggestion in validation_result.suggestions:
497 error_html.extend((f"<li>{suggestion}</li>", "</ul>"))
499 error_html.append("</div>")
500 return "".join(error_html)
502 async def render_response(
503 self,
504 request: Request,
505 template_name: str,
506 context: dict[str, t.Any] | None = None,
507 **kwargs: t.Any,
508 ) -> Response:
509 """Render template and return appropriate Response object."""
510 render_context = RenderContext(
511 template_name=template_name,
512 context=context or {},
513 request=request,
514 **kwargs,
515 )
517 result = await self.render(render_context)
519 # Handle streaming responses
520 if isinstance(result.content, AsyncIterator):
521 return StreamingResponse(
522 result.content,
523 status_code=result.status_code,
524 headers=result.headers,
525 media_type=result.content_type,
526 )
528 # Standard HTML response
529 return HTMLResponse(
530 content=result.content,
531 status_code=result.status_code,
532 headers=result.headers,
533 )
535 async def render_htmx_fragment(
536 self,
537 request: Request,
538 fragment_name: str,
539 context: dict[str, t.Any] | None = None,
540 template_name: str | None = None,
541 **kwargs: t.Any,
542 ) -> Response:
543 """Render HTMX fragment with appropriate headers."""
544 render_context = RenderContext(
545 template_name=template_name or f"_{fragment_name}.html",
546 context=context or {},
547 request=request,
548 mode=RenderMode.HTMX,
549 fragment_name=fragment_name,
550 **kwargs,
551 )
553 result = await self.render(render_context)
555 # Add HTMX-specific headers
556 headers = {"HX-Content-Type": "text/html"} | result.headers
558 return HTMLResponse(
559 content=result.content, status_code=result.status_code, headers=headers
560 )
562 async def get_performance_metrics(
563 self, template_name: str | None = None
564 ) -> dict[str, t.Any]:
565 """Get performance metrics for templates."""
566 if template_name and template_name in self._performance_metrics:
567 metrics = self._performance_metrics[template_name]
568 return {
569 "template": template_name,
570 "avg_render_time": sum(metrics) / len(metrics),
571 "min_render_time": min(metrics),
572 "max_render_time": max(metrics),
573 "render_count": len(metrics),
574 "recent_times": metrics[-10:], # Last 10 renders
575 }
577 # Return aggregate metrics
578 all_metrics = {}
579 for tmpl_name, times in self._performance_metrics.items():
580 all_metrics[tmpl_name] = {
581 "avg_render_time": sum(times) / len(times),
582 "min_render_time": min(times),
583 "max_render_time": max(times),
584 "render_count": len(times),
585 }
587 return all_metrics
589 async def get_performance_stats(self) -> dict[str, t.Any]:
590 """Get comprehensive performance statistics from the optimizer."""
591 stats = self.performance_optimizer.get_performance_stats()
592 return {
593 "total_renders": stats.total_renders,
594 "avg_render_time": stats.avg_render_time,
595 "cache_hit_ratio": stats.cache_hit_ratio,
596 "slowest_templates": stats.slowest_templates,
597 "fastest_templates": stats.fastest_templates,
598 "memory_peak": stats.memory_peak,
599 "concurrent_peak": stats.concurrent_peak,
600 }
602 async def get_optimization_recommendations(self) -> list[str]:
603 """Get performance optimization recommendations."""
604 return self.performance_optimizer.get_optimization_recommendations()
606 async def export_performance_metrics(self) -> dict[str, t.Any]:
607 """Export comprehensive performance metrics for monitoring."""
608 return self.performance_optimizer.export_metrics()
610 def clear_cache(self, template_pattern: str | None = None) -> None:
611 """Clear render cache, optionally for specific template pattern."""
612 if template_pattern:
613 keys_to_remove = [
614 key for key in self._render_cache.keys() if template_pattern in key
615 ]
616 for key in keys_to_remove:
617 del self._render_cache[key]
618 else:
619 self._render_cache.clear()
621 async def watch_template_changes(self, template_name: str) -> bool:
622 """Check if template has changed since last render."""
623 with suppress(Exception):
624 env = self.base_templates.app.env # type: ignore[union-attr]
625 _, filename = env.loader.get_source(env, template_name)
627 if filename:
628 # Check file modification time
629 path = AsyncPath(filename)
630 if await path.exists():
631 stat = await path.stat()
632 current_mtime = stat.st_mtime
634 if template_name in self._template_watchers:
635 last_mtime = self._template_watchers[template_name]
636 if current_mtime > last_mtime:
637 self._template_watchers[template_name] = current_mtime
638 return True
639 else:
640 self._template_watchers[template_name] = current_mtime
642 return False
645MODULE_ID = UUID("01937d88-1234-7890-abcd-1234567890ab")
646MODULE_STATUS = AdapterStatus.STABLE
648# Register the async renderer
649with suppress(Exception):
650 depends.set("async_template_renderer", AsyncTemplateRenderer)