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
« 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.
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
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 asyncio
20import typing as t
21from contextlib import suppress
22from dataclasses import dataclass, field
23from enum import Enum
24from uuid import UUID
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
33from .advanced_manager import AdvancedTemplateManager
34from .async_renderer import AsyncTemplateRenderer, RenderContext, RenderMode
37class BlockUpdateMode(Enum):
38 """Block update modes for HTMX."""
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
49class BlockTrigger(Enum):
50 """Block rendering triggers."""
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
59@dataclass
60class BlockDefinition:
61 """Definition of a renderable block."""
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
78@dataclass
79class BlockRenderRequest:
80 """Request to render a specific block."""
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
91@dataclass
92class BlockRenderResult:
93 """Result of block rendering."""
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)
105class BlockRegistry:
106 """Registry for managing template blocks."""
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]] = {}
113 def register_block(self, block_def: BlockDefinition) -> None:
114 """Register a block definition."""
115 self._blocks[block_def.name] = block_def
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)
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)
128 def get_block(self, block_id: str) -> BlockDefinition | None:
129 """Get block definition by ID."""
130 return self._blocks.get(block_id)
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 ]
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 ]
146 def list_blocks(self) -> list[BlockDefinition]:
147 """List all registered blocks."""
148 return list(self._blocks.values())
150 def clear(self) -> None:
151 """Clear all registered blocks."""
152 self._blocks.clear()
153 self._block_hierarchy.clear()
154 self._template_blocks.clear()
157class BlockRenderer:
158 """Specialized renderer for template blocks and fragments."""
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]] = {}
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()
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()
183 # Auto-discover blocks from templates
184 await self._discover_blocks()
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
191 env = self.async_renderer.base_templates.app.env
192 if not env.loader:
193 return
195 try:
196 template_names = await asyncio.get_event_loop().run_in_executor(
197 None, env.loader.list_templates
198 )
200 for template_name in template_names:
201 await self._analyze_template_blocks(template_name, env)
203 except Exception:
204 pass
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)
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 )
223 # Extract variables used in this block
224 block_def.variables = meta.find_undeclared_variables(node)
226 # Check for HTMX attributes in block content
227 block_def.htmx_attrs = self._extract_htmx_attrs(source, node.name)
229 self.registry.register_block(block_def)
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
241 except Exception:
242 pass
244 def _extract_htmx_attrs(self, source: str, block_name: str) -> dict[str, str]:
245 """Extract HTMX attributes from block content."""
246 attrs = {}
248 # Look for block start
249 block_start = f"[% block {block_name} %]"
250 block_end = "[% endblock %]"
252 start_idx = source.find(block_start)
253 if start_idx == -1:
254 return attrs
256 end_idx = source.find(block_end, start_idx)
257 if end_idx == -1:
258 return attrs
260 block_content = source[start_idx:end_idx]
262 # Extract common HTMX patterns
263 import re
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 }
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]
278 return attrs
280 async def render_block(self, request: BlockRenderRequest) -> BlockRenderResult:
281 """Render a specific block."""
282 import time
284 start_time = time.time()
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")
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 )
302 # Render the block
303 result = await self.async_renderer.render(render_context)
305 # Build HTMX headers
306 htmx_headers = self._build_htmx_headers(block_def, request)
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 )
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 = {}
325 # Set target if specified
326 target = request.target_selector or block_def.css_selector
327 if target:
328 headers["HX-Target"] = target
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 }
340 swap_mode = swap_modes.get(request.update_mode, "innerHTML")
341 headers["HX-Swap"] = swap_mode
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
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)
353 return headers
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 = {}
366 rendered_fragments = []
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 ]
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 )
383 result = await self.render_block(request_obj)
384 rendered_fragments.append(result.content)
386 except Exception:
387 # Skip failed fragments but continue with others
388 rendered_fragments.append(
389 f"<!-- Fragment {fragment_name} failed to render -->"
390 )
392 # Combine all fragments
393 combined_content = "\n".join(rendered_fragments)
395 return HTMLResponse(
396 content=combined_content, headers={"HX-Composition": composition_name}
397 )
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 []
405 dependencies = []
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)
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])
421 return dependencies
423 async def invalidate_dependent_blocks(self, block_id: str) -> list[str]:
424 """Invalidate blocks that depend on the given block."""
425 invalidated = []
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]
442 invalidated.append(block_def.name)
444 return invalidated
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
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 }
473 if trigger != BlockTrigger.MANUAL and trigger in trigger_mapping:
474 htmx_attrs["hx-trigger"] = trigger_mapping[trigger]
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 )
489 self.registry.register_block(block_def)
490 return block_def
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 )
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 )
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 ""
533 attrs = []
534 for attr_name, attr_value in block_def.htmx_attrs.items():
535 attrs.append(f'{attr_name}="{attr_value}"')
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}"')
542 return " ".join(attrs)
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 {}
550 dependencies = await self.get_block_dependencies(block_id)
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 }
567MODULE_ID = UUID("01937d89-1234-7890-abcd-1234567890ab")
568MODULE_STATUS = AdapterStatus.STABLE
570# Register the block renderer
571with suppress(Exception):
572 depends.set("block_renderer", BlockRenderer)