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

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 .jinja2 import Templates 

35 

36 

37class RenderMode(Enum): 

38 """Template rendering modes.""" 

39 

40 STANDARD = "standard" 

41 FRAGMENT = "fragment" 

42 BLOCK = "block" 

43 STREAMING = "streaming" 

44 HTMX = "htmx" 

45 

46 

47class CacheStrategy(Enum): 

48 """Template caching strategies.""" 

49 

50 NONE = "none" 

51 MEMORY = "memory" 

52 REDIS = "redis" 

53 HYBRID = "hybrid" 

54 

55 

56@dataclass 

57class RenderContext: 

58 """Template rendering context with metadata.""" 

59 

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 

72 

73 

74@dataclass 

75class RenderResult: 

76 """Result of template rendering operation.""" 

77 

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) 

87 

88 

89class AsyncTemplateRenderer: 

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

91 

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

104 

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

113 

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

120 

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

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

123 start_time = time.time() 

124 

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 ) 

136 

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 

144 

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) 

156 

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 ) 

164 

165 # Cache result if appropriate 

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

167 await self._cache_result(render_context, result) 

168 

169 # Track performance metrics 

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

171 

172 return result 

173 

174 except Exception as e: 

175 return self._create_error_result( 

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

177 ) 

178 

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) 

185 

186 try: 

187 # Get template source 

188 env = self.base_templates.app.env 

189 source, _ = env.loader.get_source(env, render_context.template_name) 

190 

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

196 

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 

201 

202 if self.cache_strategy == CacheStrategy.MEMORY: 

203 return self._check_memory_cache(render_context) 

204 

205 elif self.cache_strategy == CacheStrategy.REDIS: 

206 return await self._check_redis_cache(render_context) 

207 

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) 

214 

215 return None 

216 

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] 

226 

227 return None 

228 

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 

241 

242 return None 

243 

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

248 

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

250 render_context.template_name 

251 ) 

252 

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) 

257 

258 return await template.render_async(render_context.context) 

259 

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

264 

265 if not render_context.fragment_name: 

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

267 

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 ) 

274 

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

279 

280 if not render_context.block_name: 

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

282 

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) 

287 

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 } 

298 

299 # Update context and render 

300 render_context.context = htmx_context 

301 

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) 

308 

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

315 

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

317 render_context.template_name 

318 ) 

319 

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 

328 

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 

335 

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

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

338 

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 

350 

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

355 

356 metrics = self._performance_metrics[template_name] 

357 metrics.append(render_time) 

358 

359 # Keep only last 100 measurements 

360 if len(metrics) > 100: 

361 metrics.pop(0) 

362 

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) 

372 

373 return RenderResult( 

374 content=error_html, 

375 status_code=status_code, 

376 render_time=render_time, 

377 validation_result=validation_result, 

378 ) 

379 

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 ] 

391 

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

403 

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

410 

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

412 return "".join(error_html) 

413 

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 ) 

428 

429 result = await self.render(render_context) 

430 

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 ) 

439 

440 # Standard HTML response 

441 return HTMLResponse( 

442 content=result.content, 

443 status_code=result.status_code, 

444 headers=result.headers, 

445 ) 

446 

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 ) 

464 

465 result = await self.render(render_context) 

466 

467 # Add HTMX-specific headers 

468 headers = {"HX-Content-Type": "text/html", **result.headers} 

469 

470 return HTMLResponse( 

471 content=result.content, status_code=result.status_code, headers=headers 

472 ) 

473 

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 } 

488 

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 } 

498 

499 return all_metrics 

500 

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

511 

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) 

517 

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 

524 

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 

532 

533 except Exception: 

534 pass 

535 

536 return False 

537 

538 

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

540MODULE_STATUS = AdapterStatus.STABLE 

541 

542# Register the async renderer 

543with suppress(Exception): 

544 depends.set("async_template_renderer", AsyncTemplateRenderer)