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

1"""Enhanced Async Template Renderer for FastBlocks. 

2 

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 

9 

10Requirements: 

11- jinja2>=3.1.6 

12- jinja2-async-environment>=0.14.3 

13- starlette-async-jinja>=1.12.4 

14 

15Author: lesleslie <les@wedgwoodwebworks.com> 

16Created: 2025-01-12 

17""" 

18 

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 

26 

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 

32 

33from ._advanced_manager import AdvancedTemplateManager, TemplateValidationResult 

34from ._performance_optimizer import ( 

35 PerformanceMetrics, 

36 PerformanceOptimizer, 

37 get_performance_optimizer, 

38) 

39from .jinja2 import Templates 

40 

41 

42class RenderMode(Enum): 

43 """Template rendering modes.""" 

44 

45 STANDARD = "standard" 

46 FRAGMENT = "fragment" 

47 BLOCK = "block" 

48 STREAMING = "streaming" 

49 HTMX = "htmx" 

50 

51 

52class CacheStrategy(Enum): 

53 """Template caching strategies.""" 

54 

55 NONE = "none" 

56 MEMORY = "memory" 

57 REDIS = "redis" 

58 HYBRID = "hybrid" 

59 

60 

61@dataclass 

62class RenderContext: 

63 """Template rendering context with metadata.""" 

64 

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 

77 

78 

79@dataclass 

80class RenderResult: 

81 """Result of template rendering operation.""" 

82 

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) 

92 

93 

94class AsyncTemplateRenderer: 

95 """Enhanced async template renderer with advanced features.""" 

96 

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]] = {} 

113 

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() 

122 

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() 

129 

130 async def render(self, render_context: RenderContext) -> RenderResult: 

131 """Render template with enhanced error handling and optimization.""" 

132 start_time = time.time() 

133 

134 try: 

135 await self._optimize_render_context(render_context) 

136 context_size = len(str(render_context.context)) 

137 

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 ) 

147 

148 cached_result = await self._try_get_cached(render_context, start_time) 

149 if cached_result: 

150 return cached_result 

151 

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 ) 

156 

157 return result 

158 

159 except Exception as e: 

160 return self._create_error_result( 

161 str(e), render_time=time.time() - start_time, status_code=500 

162 ) 

163 

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 

170 

171 context_size = len(str(render_context.context)) 

172 

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 

181 

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 ) 

186 

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 

194 

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 

206 

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) 

221 

222 return await self._render_standard(render_context) 

223 

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 ) 

239 

240 if render_context.cache_key and isinstance(content, str): 

241 await self._cache_result(render_context, result) 

242 

243 self._track_performance(render_context.template_name, result.render_time) 

244 

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 ) 

254 

255 self.performance_optimizer.record_render( 

256 render_context.template_name, performance_metrics 

257 ) 

258 

259 return result 

260 

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) 

267 

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) 

272 

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=[]) 

278 

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 

283 

284 if self.cache_strategy == CacheStrategy.MEMORY: 

285 return self._check_memory_cache(render_context) 

286 

287 elif self.cache_strategy == CacheStrategy.REDIS: 

288 return await self._check_redis_cache(render_context) 

289 

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) 

296 

297 return None 

298 

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] 

308 

309 return None 

310 

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) 

321 

322 return None 

323 

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") 

328 

329 template = self.base_templates.app.env.get_template( 

330 render_context.template_name 

331 ) 

332 

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) 

337 

338 rendered = await template.render_async(render_context.context) 

339 return t.cast(str, rendered) 

340 

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") 

345 

346 if not render_context.fragment_name: 

347 raise ValueError("Fragment name required for fragment rendering") 

348 

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 ) 

355 

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") 

360 

361 if not render_context.block_name: 

362 raise ValueError("Block name required for block rendering") 

363 

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) 

372 

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 } 

382 

383 # Update context and render 

384 render_context.context = htmx_context 

385 

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) 

390 

391 return await self._render_standard(render_context) 

392 

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") 

397 

398 template = self.base_templates.app.env.get_template( 

399 render_context.template_name 

400 ) 

401 

402 # Return async generator directly 

403 return self._stream_template_chunks(template, render_context) 

404 

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 

417 

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 

424 

425 if self.cache_strategy in (CacheStrategy.MEMORY, CacheStrategy.HYBRID): 

426 self._render_cache[render_context.cache_key] = (result.content, time.time()) 

427 

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 ) 

437 

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] = [] 

442 

443 metrics = self._performance_metrics[template_name] 

444 metrics.append(render_time) 

445 

446 # Keep only last 100 measurements 

447 if len(metrics) > 100: 

448 metrics.pop(0) 

449 

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) 

459 

460 return RenderResult( 

461 content=error_html, 

462 status_code=status_code, 

463 render_time=render_time, 

464 validation_result=validation_result, 

465 ) 

466 

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 ] 

478 

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 ) 

493 

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>")) 

498 

499 error_html.append("</div>") 

500 return "".join(error_html) 

501 

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 ) 

516 

517 result = await self.render(render_context) 

518 

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 ) 

527 

528 # Standard HTML response 

529 return HTMLResponse( 

530 content=result.content, 

531 status_code=result.status_code, 

532 headers=result.headers, 

533 ) 

534 

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 ) 

552 

553 result = await self.render(render_context) 

554 

555 # Add HTMX-specific headers 

556 headers = {"HX-Content-Type": "text/html"} | result.headers 

557 

558 return HTMLResponse( 

559 content=result.content, status_code=result.status_code, headers=headers 

560 ) 

561 

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 } 

576 

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 } 

586 

587 return all_metrics 

588 

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 } 

601 

602 async def get_optimization_recommendations(self) -> list[str]: 

603 """Get performance optimization recommendations.""" 

604 return self.performance_optimizer.get_optimization_recommendations() 

605 

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() 

609 

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() 

620 

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) 

626 

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 

633 

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 

641 

642 return False 

643 

644 

645MODULE_ID = UUID("01937d88-1234-7890-abcd-1234567890ab") 

646MODULE_STATUS = AdapterStatus.STABLE 

647 

648# Register the async renderer 

649with suppress(Exception): 

650 depends.set("async_template_renderer", AsyncTemplateRenderer)