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
« 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.
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 # type: ignore[union-attr]
192 if not env.loader:
193 return
195 with suppress(Exception):
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 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)
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 )
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]
224 # Check for HTMX attributes in block content
225 block_def.htmx_attrs = self._extract_htmx_attrs(source, node.name)
227 self.registry.register_block(block_def)
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
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] = {}
244 # Look for block start
245 block_start = f"[% block {block_name} %]"
246 block_end = "[% endblock %]"
248 start_idx = source.find(block_start)
249 if start_idx == -1:
250 return attrs
252 end_idx = source.find(block_end, start_idx)
253 if end_idx == -1:
254 return attrs
256 block_content = source[start_idx:end_idx]
258 # Extract common HTMX patterns
259 import re
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 }
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]
276 return attrs
278 async def render_block(self, request: BlockRenderRequest) -> BlockRenderResult:
279 """Render a specific block."""
280 import time
282 start_time = time.time()
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")
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 )
300 # Render the block
301 result = await self.async_renderer.render(render_context) # type: ignore[union-attr]
303 # Build HTMX headers
304 htmx_headers = self._build_htmx_headers(block_def, request)
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 )
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 = {}
323 # Set target if specified
324 target = request.target_selector or block_def.css_selector
325 if target:
326 headers["HX-Target"] = target
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 }
338 swap_mode = swap_modes.get(request.update_mode, "innerHTML")
339 headers["HX-Swap"] = swap_mode
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
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)
351 return headers
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 = {}
364 rendered_fragments = []
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 ]
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 )
381 result = await self.render_block(request_obj)
382 rendered_fragments.append(result.content)
384 except Exception:
385 # Skip failed fragments but continue with others
386 rendered_fragments.append(
387 f"<!-- Fragment {fragment_name} failed to render -->"
388 )
390 # Combine all fragments
391 combined_content = "\n".join(rendered_fragments)
393 return HTMLResponse(
394 content=combined_content, headers={"HX-Composition": composition_name}
395 )
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 []
403 dependencies = []
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)
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])
419 return dependencies
421 async def invalidate_dependent_blocks(self, block_id: str) -> list[str]:
422 """Invalidate blocks that depend on the given block."""
423 invalidated = []
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]
440 invalidated.append(block_def.name)
442 return invalidated
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
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 }
471 if trigger != BlockTrigger.MANUAL and trigger in trigger_mapping:
472 htmx_attrs["hx-trigger"] = trigger_mapping[trigger]
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 )
487 self.registry.register_block(block_def)
488 return block_def
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 )
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 )
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 ""
531 attrs = [
532 f'{attr_name}="{attr_value}"'
533 for attr_name, attr_value in block_def.htmx_attrs.items()
534 ]
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}"')
541 return " ".join(attrs)
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 {}
549 dependencies = await self.get_block_dependencies(block_id)
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 }
566MODULE_ID = UUID("01937d89-1234-7890-abcd-1234567890ab")
567MODULE_STATUS = AdapterStatus.STABLE
569# Register the block renderer
570with suppress(Exception):
571 depends.set("block_renderer", BlockRenderer)