Coverage for fastblocks/adapters/templates/block_renderer.py: 36%

248 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-29 00:51 -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 

192 if not env.loader: 

193 return 

194 

195 try: 

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 except Exception: 

204 pass 

205 

206 async def _analyze_template_blocks( 

207 self, template_name: str, env: Environment 

208 ) -> None: 

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

210 try: 

211 source, _ = env.loader.get_source(env, template_name) 

212 parsed = env.parse(source, template_name) 

213 

214 # Find all block nodes 

215 for node in parsed.find_all(Block): 

216 block_def = BlockDefinition( 

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

218 template_name=template_name, 

219 block_name=node.name, 

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

221 ) 

222 

223 # Extract variables used in this block 

224 block_def.variables = meta.find_undeclared_variables(node) 

225 

226 # Check for HTMX attributes in block content 

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

228 

229 self.registry.register_block(block_def) 

230 

231 # Find extends and includes for hierarchy 

232 for node in parsed.find_all((Extends, Include)): 

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

234 parent_template = node.template.value 

235 # Register dependency relationship 

236 for block_def in self.registry.get_blocks_for_template( 

237 template_name 

238 ): 

239 block_def.parent_template = parent_template 

240 

241 except Exception: 

242 pass 

243 

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

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

246 attrs = {} 

247 

248 # Look for block start 

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

250 block_end = "[% endblock %]" 

251 

252 start_idx = source.find(block_start) 

253 if start_idx == -1: 

254 return attrs 

255 

256 end_idx = source.find(block_end, start_idx) 

257 if end_idx == -1: 

258 return attrs 

259 

260 block_content = source[start_idx:end_idx] 

261 

262 # Extract common HTMX patterns 

263 import re 

264 

265 htmx_patterns = { 

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

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

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

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

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

271 } 

272 

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

274 matches = re.findall(pattern, block_content) 

275 if matches: 

276 attrs[attr_name] = matches[0] 

277 

278 return attrs 

279 

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

281 """Render a specific block.""" 

282 import time 

283 

284 start_time = time.time() 

285 

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

287 if not block_def: 

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

289 

290 # Build render context 

291 render_context = RenderContext( 

292 template_name=block_def.template_name, 

293 context=request.context, 

294 request=request.request, 

295 mode=RenderMode.BLOCK, 

296 block_name=block_def.block_name, 

297 validate_template=request.validate, 

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

299 cache_ttl=block_def.cache_ttl, 

300 ) 

301 

302 # Render the block 

303 result = await self.async_renderer.render(render_context) 

304 

305 # Build HTMX headers 

306 htmx_headers = self._build_htmx_headers(block_def, request) 

307 

308 return BlockRenderResult( 

309 content=result.content, 

310 block_id=request.block_id, 

311 update_mode=request.update_mode, 

312 target_selector=request.target_selector or block_def.css_selector, 

313 htmx_headers=htmx_headers, 

314 cache_hit=result.cache_hit, 

315 render_time=time.time() - start_time, 

316 dependencies=list(block_def.dependencies), 

317 ) 

318 

319 def _build_htmx_headers( 

320 self, block_def: BlockDefinition, request: BlockRenderRequest 

321 ) -> dict[str, str]: 

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

323 headers = {} 

324 

325 # Set target if specified 

326 target = request.target_selector or block_def.css_selector 

327 if target: 

328 headers["HX-Target"] = target 

329 

330 # Set swap mode 

331 swap_modes = { 

332 BlockUpdateMode.REPLACE: "innerHTML", 

333 BlockUpdateMode.APPEND: "beforeend", 

334 BlockUpdateMode.PREPEND: "afterbegin", 

335 BlockUpdateMode.INNER: "innerHTML", 

336 BlockUpdateMode.OUTER: "outerHTML", 

337 BlockUpdateMode.DELETE: "delete", 

338 } 

339 

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

341 headers["HX-Swap"] = swap_mode 

342 

343 # Add any custom HTMX attributes from block definition 

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

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

346 headers[header_name] = attr_value 

347 

348 # Add refresh headers for auto-updating blocks 

349 if block_def.auto_refresh: 

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

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

352 

353 return headers 

354 

355 async def render_fragment_composition( 

356 self, 

357 composition_name: str, 

358 fragments: list[str], 

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

360 request: Request | None = None, 

361 ) -> HTMLResponse: 

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

363 if not context: 

364 context = {} 

365 

366 rendered_fragments = [] 

367 

368 for fragment_name in fragments: 

369 try: 

370 # Find block definition for this fragment 

371 matching_blocks = [ 

372 block 

373 for block in self.registry.list_blocks() 

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

375 ] 

376 

377 if matching_blocks: 

378 block_def = matching_blocks[0] 

379 request_obj = BlockRenderRequest( 

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

381 ) 

382 

383 result = await self.render_block(request_obj) 

384 rendered_fragments.append(result.content) 

385 

386 except Exception: 

387 # Skip failed fragments but continue with others 

388 rendered_fragments.append( 

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

390 ) 

391 

392 # Combine all fragments 

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

394 

395 return HTMLResponse( 

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

397 ) 

398 

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

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

401 block_def = self.registry.get_block(block_id) 

402 if not block_def: 

403 return [] 

404 

405 dependencies = [] 

406 

407 # Add template dependencies 

408 if self.advanced_manager: 

409 template_deps = await self.advanced_manager.get_template_dependencies( 

410 block_def.template_name 

411 ) 

412 dependencies.extend(template_deps) 

413 

414 # Add parent template dependencies 

415 if block_def.parent_template: 

416 parent_blocks = self.registry.get_blocks_for_template( 

417 block_def.parent_template 

418 ) 

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

420 

421 return dependencies 

422 

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

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

425 invalidated = [] 

426 

427 # Find blocks that depend on this one 

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

429 if ( 

430 block_id in block_def.dependencies 

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

432 ): 

433 # Clear cache for dependent block 

434 cache_keys_to_remove = [ 

435 key 

436 for key in self._render_cache.keys() 

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

438 ] 

439 for key in cache_keys_to_remove: 

440 del self._render_cache[key] 

441 

442 invalidated.append(block_def.name) 

443 

444 return invalidated 

445 

446 def register_htmx_block( 

447 self, 

448 name: str, 

449 template_name: str, 

450 block_name: str | None = None, 

451 htmx_endpoint: str | None = None, 

452 update_mode: BlockUpdateMode = BlockUpdateMode.REPLACE, 

453 trigger: BlockTrigger = BlockTrigger.MANUAL, 

454 auto_refresh: int | None = None, 

455 **kwargs: t.Any, 

456 ) -> BlockDefinition: 

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

458 # Build HTMX attributes 

459 htmx_attrs = {} 

460 if htmx_endpoint: 

461 htmx_attrs["hx-get"] = htmx_endpoint 

462 

463 # Set appropriate triggers 

464 trigger_mapping = { 

465 BlockTrigger.AUTO: "load", 

466 BlockTrigger.LAZY: "revealed", 

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

468 if auto_refresh 

469 else "every 30s", 

470 BlockTrigger.WEBSOCKET: "sse", 

471 } 

472 

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

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

475 

476 # Create block definition 

477 block_def = BlockDefinition( 

478 name=name, 

479 template_name=template_name, 

480 block_name=block_name or name, 

481 update_mode=update_mode, 

482 trigger=trigger, 

483 htmx_attrs=htmx_attrs, 

484 auto_refresh=auto_refresh, 

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

486 **kwargs, 

487 ) 

488 

489 self.registry.register_block(block_def) 

490 return block_def 

491 

492 async def create_htmx_polling_block( 

493 self, 

494 name: str, 

495 template_name: str, 

496 endpoint: str, 

497 interval: int = 30, 

498 **kwargs: t.Any, 

499 ) -> BlockDefinition: 

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

501 return self.register_htmx_block( 

502 name=name, 

503 template_name=template_name, 

504 htmx_endpoint=endpoint, 

505 trigger=BlockTrigger.POLLING, 

506 auto_refresh=interval, 

507 **kwargs, 

508 ) 

509 

510 async def create_lazy_loading_block( 

511 self, 

512 name: str, 

513 template_name: str, 

514 endpoint: str, 

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

516 **kwargs: t.Any, 

517 ) -> BlockDefinition: 

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

519 return self.register_htmx_block( 

520 name=name, 

521 template_name=template_name, 

522 htmx_endpoint=endpoint, 

523 trigger=BlockTrigger.LAZY, 

524 **kwargs, 

525 ) 

526 

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

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

529 block_def = self.registry.get_block(block_id) 

530 if not block_def: 

531 return "" 

532 

533 attrs = [] 

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

535 attrs.append(f'{attr_name}="{attr_value}"') 

536 

537 # Add ID for targeting 

538 if block_def.css_selector: 

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

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

541 

542 return " ".join(attrs) 

543 

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

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

546 block_def = self.registry.get_block(block_id) 

547 if not block_def: 

548 return {} 

549 

550 dependencies = await self.get_block_dependencies(block_id) 

551 

552 return { 

553 "name": block_def.name, 

554 "template_name": block_def.template_name, 

555 "block_name": block_def.block_name, 

556 "update_mode": block_def.update_mode.value, 

557 "trigger": block_def.trigger.value, 

558 "css_selector": block_def.css_selector, 

559 "htmx_attrs": block_def.htmx_attrs, 

560 "auto_refresh": block_def.auto_refresh, 

561 "cache_ttl": block_def.cache_ttl, 

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

563 "dependencies": dependencies, 

564 } 

565 

566 

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

568MODULE_STATUS = AdapterStatus.STABLE 

569 

570# Register the block renderer 

571with suppress(Exception): 

572 depends.set("block_renderer", BlockRenderer)