Coverage for fastblocks/adapters/templates/_block_renderer.py: 37%

244 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 04:01 -0700

1"""Block Rendering System for HTMX Partials and Fragments. 

2 

3This module provides specialized block rendering for HTMX interactions: 

4- Named template blocks for partial updates 

5- Fragment composition and inheritance 

6- Dynamic block swapping and updates 

7- Progressive enhancement support 

8- Block-level caching and optimization 

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 asyncio 

20import typing as t 

21from contextlib import suppress 

22from dataclasses import dataclass, field 

23from enum import Enum 

24from uuid import UUID 

25 

26from acb.adapters import AdapterStatus 

27from acb.depends import depends 

28from jinja2 import Environment, meta 

29from jinja2.nodes import Block, Extends, Include 

30from starlette.requests import Request 

31from starlette.responses import HTMLResponse 

32 

33from ._advanced_manager import AdvancedTemplateManager 

34from ._async_renderer import AsyncTemplateRenderer, RenderContext, RenderMode 

35 

36 

37class BlockUpdateMode(Enum): 

38 """Block update modes for HTMX.""" 

39 

40 REPLACE = "replace" # Replace entire block 

41 APPEND = "append" # Append to block content 

42 PREPEND = "prepend" # Prepend to block content 

43 INNER = "inner" # Replace inner content only 

44 OUTER = "outer" # Replace including block element 

45 DELETE = "delete" # Remove the block 

46 NONE = "none" # No update 

47 

48 

49class BlockTrigger(Enum): 

50 """Block rendering triggers.""" 

51 

52 MANUAL = "manual" # Manually triggered 

53 AUTO = "auto" # Auto-triggered on events 

54 LAZY = "lazy" # Lazy-loaded when visible 

55 POLLING = "polling" # Periodically updated 

56 WEBSOCKET = "websocket" # Updated via WebSocket 

57 

58 

59@dataclass 

60class BlockDefinition: 

61 """Definition of a renderable block.""" 

62 

63 name: str 

64 template_name: str 

65 block_name: str | None = None 

66 parent_template: str | None = None 

67 dependencies: set[str] = field(default_factory=set) 

68 variables: set[str] = field(default_factory=set) 

69 update_mode: BlockUpdateMode = BlockUpdateMode.REPLACE 

70 trigger: BlockTrigger = BlockTrigger.MANUAL 

71 cache_key: str | None = None 

72 cache_ttl: int = 300 

73 htmx_attrs: dict[str, str] = field(default_factory=dict) 

74 css_selector: str | None = None 

75 auto_refresh: int | None = None # Refresh interval in seconds 

76 

77 

78@dataclass 

79class BlockRenderRequest: 

80 """Request to render a specific block.""" 

81 

82 block_id: str 

83 context: dict[str, t.Any] = field(default_factory=dict) 

84 request: Request | None = None 

85 target_selector: str | None = None 

86 update_mode: BlockUpdateMode = BlockUpdateMode.REPLACE 

87 headers: dict[str, str] = field(default_factory=dict) 

88 validate: bool = False 

89 

90 

91@dataclass 

92class BlockRenderResult: 

93 """Result of block rendering.""" 

94 

95 content: str 

96 block_id: str 

97 update_mode: BlockUpdateMode 

98 target_selector: str | None = None 

99 htmx_headers: dict[str, str] = field(default_factory=dict) 

100 cache_hit: bool = False 

101 render_time: float = 0.0 

102 dependencies: list[str] = field(default_factory=list) 

103 

104 

105class BlockRegistry: 

106 """Registry for managing template blocks.""" 

107 

108 def __init__(self) -> None: 

109 self._blocks: dict[str, BlockDefinition] = {} 

110 self._block_hierarchy: dict[str, list[str]] = {} 

111 self._template_blocks: dict[str, list[str]] = {} 

112 

113 def register_block(self, block_def: BlockDefinition) -> None: 

114 """Register a block definition.""" 

115 self._blocks[block_def.name] = block_def 

116 

117 # Track blocks by template 

118 if block_def.template_name not in self._template_blocks: 

119 self._template_blocks[block_def.template_name] = [] 

120 self._template_blocks[block_def.template_name].append(block_def.name) 

121 

122 # Track hierarchy if parent exists 

123 if block_def.parent_template: 

124 if block_def.parent_template not in self._block_hierarchy: 

125 self._block_hierarchy[block_def.parent_template] = [] 

126 self._block_hierarchy[block_def.parent_template].append(block_def.name) 

127 

128 def get_block(self, block_id: str) -> BlockDefinition | None: 

129 """Get block definition by ID.""" 

130 return self._blocks.get(block_id) 

131 

132 def get_blocks_for_template(self, template_name: str) -> list[BlockDefinition]: 

133 """Get all blocks for a template.""" 

134 block_ids = self._template_blocks.get(template_name, []) 

135 return [ 

136 self._blocks[block_id] for block_id in block_ids if block_id in self._blocks 

137 ] 

138 

139 def get_child_blocks(self, template_name: str) -> list[BlockDefinition]: 

140 """Get child blocks that depend on a template.""" 

141 child_ids = self._block_hierarchy.get(template_name, []) 

142 return [ 

143 self._blocks[block_id] for block_id in child_ids if block_id in self._blocks 

144 ] 

145 

146 def list_blocks(self) -> list[BlockDefinition]: 

147 """List all registered blocks.""" 

148 return list(self._blocks.values()) 

149 

150 def clear(self) -> None: 

151 """Clear all registered blocks.""" 

152 self._blocks.clear() 

153 self._block_hierarchy.clear() 

154 self._template_blocks.clear() 

155 

156 

157class BlockRenderer: 

158 """Specialized renderer for template blocks and fragments.""" 

159 

160 def __init__( 

161 self, 

162 async_renderer: AsyncTemplateRenderer | None = None, 

163 advanced_manager: AdvancedTemplateManager | None = None, 

164 ) -> None: 

165 self.async_renderer = async_renderer 

166 self.advanced_manager = advanced_manager 

167 self.registry = BlockRegistry() 

168 self._render_cache: dict[str, tuple[str, float]] = {} 

169 

170 async def initialize(self) -> None: 

171 """Initialize the block renderer.""" 

172 if not self.async_renderer: 

173 self.async_renderer = AsyncTemplateRenderer() 

174 await self.async_renderer.initialize() 

175 

176 if not self.advanced_manager: 

177 try: 

178 self.advanced_manager = depends.get("advanced_template_manager") 

179 except Exception: 

180 self.advanced_manager = AdvancedTemplateManager() 

181 await self.advanced_manager.initialize() 

182 

183 # Auto-discover blocks from templates 

184 await self._discover_blocks() 

185 

186 async def _discover_blocks(self) -> None: 

187 """Auto-discover blocks from template files.""" 

188 if not self.async_renderer or not self.async_renderer.base_templates: 

189 return 

190 

191 env = self.async_renderer.base_templates.app.env # type: ignore[union-attr] 

192 if not env.loader: 

193 return 

194 

195 with suppress(Exception): 

196 template_names = await asyncio.get_event_loop().run_in_executor( 

197 None, env.loader.list_templates 

198 ) 

199 

200 for template_name in template_names: 

201 await self._analyze_template_blocks(template_name, env) 

202 

203 async def _analyze_template_blocks( 

204 self, template_name: str, env: Environment 

205 ) -> None: 

206 """Analyze template and register its blocks.""" 

207 with suppress(Exception): 

208 source, _, _ = env.loader.get_source(env, template_name) # type: ignore[union-attr,misc] 

209 parsed = env.parse(source, template_name) 

210 

211 # Find all block nodes 

212 for node in parsed.find_all(Block): 

213 block_def = BlockDefinition( 

214 name=f"{template_name}:{node.name}", 

215 template_name=template_name, 

216 block_name=node.name, 

217 css_selector=f"#{node.name.replace('_', '-')}", 

218 ) 

219 

220 # Extract variables used in this block 

221 # find_undeclared_variables accepts Block nodes at runtime 

222 block_def.variables = meta.find_undeclared_variables(node) # type: ignore[arg-type] 

223 

224 # Check for HTMX attributes in block content 

225 block_def.htmx_attrs = self._extract_htmx_attrs(source, node.name) 

226 

227 self.registry.register_block(block_def) 

228 

229 # Find extends and includes for hierarchy 

230 for node in parsed.find_all((Extends, Include)): # type: ignore[assignment] 

231 # Template attribute value extraction at runtime 

232 if hasattr(node, "template") and hasattr(node.template, "value"): 

233 parent_template = node.template.value # type: ignore[attr-defined] 

234 # Register dependency relationship 

235 for block_def in self.registry.get_blocks_for_template( 

236 template_name 

237 ): 

238 block_def.parent_template = parent_template 

239 

240 def _extract_htmx_attrs(self, source: str, block_name: str) -> dict[str, str]: 

241 """Extract HTMX attributes from block content.""" 

242 attrs: dict[str, str] = {} 

243 

244 # Look for block start 

245 block_start = f"[% block {block_name} %]" 

246 block_end = "[% endblock %]" 

247 

248 start_idx = source.find(block_start) 

249 if start_idx == -1: 

250 return attrs 

251 

252 end_idx = source.find(block_end, start_idx) 

253 if end_idx == -1: 

254 return attrs 

255 

256 block_content = source[start_idx:end_idx] 

257 

258 # Extract common HTMX patterns 

259 import re 

260 

261 htmx_patterns = { 

262 "hx-get": r'hx-get="([^"]*)"', 

263 "hx-post": r'hx-post="([^"]*)"', 

264 "hx-target": r'hx-target="([^"]*)"', 

265 "hx-swap": r'hx-swap="([^"]*)"', 

266 "hx-trigger": r'hx-trigger="([^"]*)"', 

267 } 

268 

269 for attr_name, pattern in htmx_patterns.items(): 

270 matches = re.findall( 

271 pattern, block_content 

272 ) # REGEX OK: extract HTMX attributes from template content 

273 if matches: 

274 attrs[attr_name] = matches[0] 

275 

276 return attrs 

277 

278 async def render_block(self, request: BlockRenderRequest) -> BlockRenderResult: 

279 """Render a specific block.""" 

280 import time 

281 

282 start_time = time.time() 

283 

284 block_def = self.registry.get_block(request.block_id) 

285 if not block_def: 

286 raise ValueError(f"Block '{request.block_id}' not found") 

287 

288 # Build render context 

289 render_context = RenderContext( 

290 template_name=block_def.template_name, 

291 context=request.context, 

292 request=request.request, 

293 mode=RenderMode.BLOCK, 

294 block_name=block_def.block_name, 

295 validate_template=request.validate, 

296 cache_key=f"block:{request.block_id}:{hash(str(request.context))}", 

297 cache_ttl=block_def.cache_ttl, 

298 ) 

299 

300 # Render the block 

301 result = await self.async_renderer.render(render_context) # type: ignore[union-attr] 

302 

303 # Build HTMX headers 

304 htmx_headers = self._build_htmx_headers(block_def, request) 

305 

306 return BlockRenderResult( 

307 content=t.cast(str, result.content), 

308 block_id=request.block_id, 

309 update_mode=request.update_mode, 

310 target_selector=request.target_selector or block_def.css_selector, 

311 htmx_headers=htmx_headers, 

312 cache_hit=result.cache_hit, 

313 render_time=time.time() - start_time, 

314 dependencies=list(block_def.dependencies), 

315 ) 

316 

317 def _build_htmx_headers( 

318 self, block_def: BlockDefinition, request: BlockRenderRequest 

319 ) -> dict[str, str]: 

320 """Build HTMX response headers for block updates.""" 

321 headers = {} 

322 

323 # Set target if specified 

324 target = request.target_selector or block_def.css_selector 

325 if target: 

326 headers["HX-Target"] = target 

327 

328 # Set swap mode 

329 swap_modes = { 

330 BlockUpdateMode.REPLACE: "innerHTML", 

331 BlockUpdateMode.APPEND: "beforeend", 

332 BlockUpdateMode.PREPEND: "afterbegin", 

333 BlockUpdateMode.INNER: "innerHTML", 

334 BlockUpdateMode.OUTER: "outerHTML", 

335 BlockUpdateMode.DELETE: "delete", 

336 } 

337 

338 swap_mode = swap_modes.get(request.update_mode, "innerHTML") 

339 headers["HX-Swap"] = swap_mode 

340 

341 # Add any custom HTMX attributes from block definition 

342 for attr_name, attr_value in block_def.htmx_attrs.items(): 

343 header_name = f"HX-{attr_name.replace('hx-', '').title()}" 

344 headers[header_name] = attr_value 

345 

346 # Add refresh headers for auto-updating blocks 

347 if block_def.auto_refresh: 

348 headers["HX-Trigger"] = f"refresh-block-{block_def.name}" 

349 headers["HX-Refresh"] = str(block_def.auto_refresh) 

350 

351 return headers 

352 

353 async def render_fragment_composition( 

354 self, 

355 composition_name: str, 

356 fragments: list[str], 

357 context: dict[str, t.Any] | None = None, 

358 request: Request | None = None, 

359 ) -> HTMLResponse: 

360 """Render a composition of multiple fragments.""" 

361 if not context: 

362 context = {} 

363 

364 rendered_fragments = [] 

365 

366 for fragment_name in fragments: 

367 try: 

368 # Find block definition for this fragment 

369 matching_blocks = [ 

370 block 

371 for block in self.registry.list_blocks() 

372 if fragment_name in block.name or block.block_name == fragment_name 

373 ] 

374 

375 if matching_blocks: 

376 block_def = matching_blocks[0] 

377 request_obj = BlockRenderRequest( 

378 block_id=block_def.name, context=context, request=request 

379 ) 

380 

381 result = await self.render_block(request_obj) 

382 rendered_fragments.append(result.content) 

383 

384 except Exception: 

385 # Skip failed fragments but continue with others 

386 rendered_fragments.append( 

387 f"<!-- Fragment {fragment_name} failed to render -->" 

388 ) 

389 

390 # Combine all fragments 

391 combined_content = "\n".join(rendered_fragments) 

392 

393 return HTMLResponse( 

394 content=combined_content, headers={"HX-Composition": composition_name} 

395 ) 

396 

397 async def get_block_dependencies(self, block_id: str) -> list[str]: 

398 """Get dependencies for a block (other blocks it depends on).""" 

399 block_def = self.registry.get_block(block_id) 

400 if not block_def: 

401 return [] 

402 

403 dependencies = [] 

404 

405 # Add template dependencies 

406 if self.advanced_manager: 

407 template_deps = await self.advanced_manager.get_template_dependencies( 

408 block_def.template_name 

409 ) 

410 dependencies.extend(template_deps) 

411 

412 # Add parent template dependencies 

413 if block_def.parent_template: 

414 parent_blocks = self.registry.get_blocks_for_template( 

415 block_def.parent_template 

416 ) 

417 dependencies.extend([block.name for block in parent_blocks]) 

418 

419 return dependencies 

420 

421 async def invalidate_dependent_blocks(self, block_id: str) -> list[str]: 

422 """Invalidate blocks that depend on the given block.""" 

423 invalidated = [] 

424 

425 # Find blocks that depend on this one 

426 for block_def in self.registry.list_blocks(): 

427 if ( 

428 block_id in block_def.dependencies 

429 or block_id in await self.get_block_dependencies(block_def.name) 

430 ): 

431 # Clear cache for dependent block 

432 cache_keys_to_remove = [ 

433 key 

434 for key in self._render_cache.keys() 

435 if f"block:{block_def.name}" in key 

436 ] 

437 for key in cache_keys_to_remove: 

438 del self._render_cache[key] 

439 

440 invalidated.append(block_def.name) 

441 

442 return invalidated 

443 

444 def register_htmx_block( 

445 self, 

446 name: str, 

447 template_name: str, 

448 block_name: str | None = None, 

449 htmx_endpoint: str | None = None, 

450 update_mode: BlockUpdateMode = BlockUpdateMode.REPLACE, 

451 trigger: BlockTrigger = BlockTrigger.MANUAL, 

452 auto_refresh: int | None = None, 

453 **kwargs: t.Any, 

454 ) -> BlockDefinition: 

455 """Register a block optimized for HTMX interactions.""" 

456 # Build HTMX attributes 

457 htmx_attrs = {} 

458 if htmx_endpoint: 

459 htmx_attrs["hx-get"] = htmx_endpoint 

460 

461 # Set appropriate triggers 

462 trigger_mapping = { 

463 BlockTrigger.AUTO: "load", 

464 BlockTrigger.LAZY: "revealed", 

465 BlockTrigger.POLLING: f"every {auto_refresh}s" 

466 if auto_refresh 

467 else "every 30s", 

468 BlockTrigger.WEBSOCKET: "sse", 

469 } 

470 

471 if trigger != BlockTrigger.MANUAL and trigger in trigger_mapping: 

472 htmx_attrs["hx-trigger"] = trigger_mapping[trigger] 

473 

474 # Create block definition 

475 block_def = BlockDefinition( 

476 name=name, 

477 template_name=template_name, 

478 block_name=block_name or name, 

479 update_mode=update_mode, 

480 trigger=trigger, 

481 htmx_attrs=htmx_attrs, 

482 auto_refresh=auto_refresh, 

483 css_selector=f"#{name.replace('_', '-')}", 

484 **kwargs, 

485 ) 

486 

487 self.registry.register_block(block_def) 

488 return block_def 

489 

490 async def create_htmx_polling_block( 

491 self, 

492 name: str, 

493 template_name: str, 

494 endpoint: str, 

495 interval: int = 30, 

496 **kwargs: t.Any, 

497 ) -> BlockDefinition: 

498 """Create a block that polls for updates via HTMX.""" 

499 return self.register_htmx_block( 

500 name=name, 

501 template_name=template_name, 

502 htmx_endpoint=endpoint, 

503 trigger=BlockTrigger.POLLING, 

504 auto_refresh=interval, 

505 **kwargs, 

506 ) 

507 

508 async def create_lazy_loading_block( 

509 self, 

510 name: str, 

511 template_name: str, 

512 endpoint: str, 

513 placeholder_content: str = "Loading...", 

514 **kwargs: t.Any, 

515 ) -> BlockDefinition: 

516 """Create a lazy-loading block that loads when visible.""" 

517 return self.register_htmx_block( 

518 name=name, 

519 template_name=template_name, 

520 htmx_endpoint=endpoint, 

521 trigger=BlockTrigger.LAZY, 

522 **kwargs, 

523 ) 

524 

525 def get_htmx_attributes_for_block(self, block_id: str) -> str: 

526 """Get HTMX attributes string for a block.""" 

527 block_def = self.registry.get_block(block_id) 

528 if not block_def: 

529 return "" 

530 

531 attrs = [ 

532 f'{attr_name}="{attr_value}"' 

533 for attr_name, attr_value in block_def.htmx_attrs.items() 

534 ] 

535 

536 # Add ID for targeting 

537 if block_def.css_selector: 

538 selector_id = block_def.css_selector.lstrip("#") 

539 attrs.append(f'id="{selector_id}"') 

540 

541 return " ".join(attrs) 

542 

543 async def get_block_info(self, block_id: str) -> dict[str, t.Any]: 

544 """Get detailed information about a block.""" 

545 block_def = self.registry.get_block(block_id) 

546 if not block_def: 

547 return {} 

548 

549 dependencies = await self.get_block_dependencies(block_id) 

550 

551 return { 

552 "name": block_def.name, 

553 "template_name": block_def.template_name, 

554 "block_name": block_def.block_name, 

555 "update_mode": block_def.update_mode.value, 

556 "trigger": block_def.trigger.value, 

557 "css_selector": block_def.css_selector, 

558 "htmx_attrs": block_def.htmx_attrs, 

559 "auto_refresh": block_def.auto_refresh, 

560 "cache_ttl": block_def.cache_ttl, 

561 "variables": list(block_def.variables), 

562 "dependencies": dependencies, 

563 } 

564 

565 

566MODULE_ID = UUID("01937d89-1234-7890-abcd-1234567890ab") 

567MODULE_STATUS = AdapterStatus.STABLE 

568 

569# Register the block renderer 

570with suppress(Exception): 

571 depends.set("block_renderer", BlockRenderer)