Coverage for fastblocks/adapters/templates/_htmy_components.py: 78%
289 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 04:05 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 04:05 -0700
1"""HTMY Component Management System for FastBlocks.
3Provides advanced component discovery, validation, scaffolding, and lifecycle management
4for HTMY components with deep HTMX integration and async rendering capabilities.
6Key Features:
7- Automatic component discovery with intelligent caching
8- Dataclass-based component scaffolding with validation
9- Component composition and nesting patterns
10- HTMX-aware state management
11- Async component rendering with lifecycle hooks
12- Advanced error handling and debugging
13- Hot-reloading support for development
15Author: lesleslie <les@wedgwoodwebworks.com>
16Created: 2025-01-13
17"""
19import asyncio
20import inspect
21import typing as t
22from abc import ABC, abstractmethod
23from dataclasses import dataclass, field, fields, is_dataclass
24from datetime import datetime
25from enum import Enum
26from typing import Any, Optional
27from uuid import uuid4
29from acb.debug import debug
30from anyio import Path as AsyncPath
31from starlette.requests import Request
33try:
34 from pydantic import BaseModel
35except ImportError:
36 BaseModel = None # type: ignore[no-redef]
39class ComponentStatus(str, Enum):
40 """Component lifecycle status."""
42 DISCOVERED = "discovered"
43 VALIDATED = "validated"
44 COMPILED = "compiled"
45 READY = "ready"
46 ERROR = "error"
47 DEPRECATED = "deprecated"
50class ComponentType(str, Enum):
51 """Component classification types."""
53 BASIC = "basic"
54 DATACLASS = "dataclass"
55 PYDANTIC = "pydantic"
56 ASYNC = "async"
57 HTMX = "htmx"
58 COMPOSITE = "composite"
61@dataclass
62class ComponentMetadata:
63 """Component metadata for discovery and management."""
65 name: str
66 path: AsyncPath
67 type: ComponentType
68 status: ComponentStatus = ComponentStatus.DISCOVERED
69 dependencies: list[str] = field(default_factory=list)
70 htmx_attributes: dict[str, Any] = field(default_factory=dict)
71 cache_key: str | None = None
72 last_modified: datetime | None = None
73 error_message: str | None = None
74 docstring: str | None = None
76 def __post_init__(self) -> None:
77 if self.cache_key is None:
78 self.cache_key = f"component_{self.name}_{self.path.stem}"
81class ComponentValidationError(Exception):
82 """Raised when component validation fails."""
84 pass
87class ComponentCompilationError(Exception):
88 """Raised when component compilation fails."""
90 pass
93class ComponentRenderError(Exception):
94 """Raised when component rendering fails."""
96 pass
99class HTMXComponentMixin:
100 """Mixin for HTMX-aware components."""
102 @property
103 def htmx_attrs(self) -> dict[str, str]:
104 """Default HTMX attributes for the component."""
105 return {}
107 def get_htmx_trigger(self, request: Request) -> str | None:
108 """Extract HTMX trigger from request."""
109 return request.headers.get("HX-Trigger")
111 def get_htmx_target(self, request: Request) -> str | None:
112 """Extract HTMX target from request."""
113 return request.headers.get("HX-Target")
115 def is_htmx_request(self, request: Request) -> bool:
116 """Check if request is from HTMX."""
117 return request.headers.get("HX-Request") == "true"
120class ComponentBase(ABC):
121 """Base class for all HTMY components."""
123 def __init__(self, **kwargs: Any) -> None:
124 self._context: dict[str, Any] = kwargs
125 self._request: Request | None = kwargs.get("request")
126 self._children: list[ComponentBase] = []
127 self._parent: ComponentBase | None = None
129 @abstractmethod
130 def htmy(self, context: dict[str, Any]) -> str:
131 """Render the component to HTML."""
132 pass
134 async def async_htmy(self, context: dict[str, Any]) -> str:
135 """Async version of htmy method."""
136 if asyncio.iscoroutinefunction(self.htmy):
137 return await self.htmy(context) # type: ignore[no-any-return]
138 return self.htmy(context) # type: ignore[no-any-return]
140 def add_child(self, child: "ComponentBase") -> None:
141 """Add a child component."""
142 child._parent = self
143 self._children.append(child)
145 def remove_child(self, child: "ComponentBase") -> None:
146 """Remove a child component."""
147 if child in self._children:
148 child._parent = None
149 self._children.remove(child)
151 @property
152 def children(self) -> list["ComponentBase"]:
153 """Get child components."""
154 return self._children.copy()
156 @property
157 def parent(self) -> Optional["ComponentBase"]:
158 """Get parent component."""
159 return self._parent
162class DataclassComponentBase(ComponentBase):
163 """Base class for dataclass-based components."""
165 def __init_subclass__(cls, **kwargs: Any) -> None:
166 super().__init_subclass__(**kwargs)
167 if not is_dataclass(cls):
168 raise ComponentValidationError(
169 f"Component {cls.__name__} must be a dataclass"
170 )
172 def validate_fields(self) -> None:
173 """Validate component fields."""
174 if not is_dataclass(self):
175 return
177 for field_info in fields(self):
178 value = getattr(self, field_info.name)
179 if field_info.type and value is not None:
180 # Basic type validation
181 origin = getattr(field_info.type, "__origin__", None)
182 # Only validate if field_info.type is a proper class
183 if origin is None and isinstance(field_info.type, type):
184 if not isinstance(value, field_info.type):
185 raise ComponentValidationError(
186 f"Field {field_info.name} must be of type {field_info.type}"
187 )
190class ComponentScaffolder:
191 """Scaffolding system for creating new components."""
193 @staticmethod
194 def create_basic_component(
195 name: str, props: dict[str, type] | None = None, htmx_enabled: bool = False
196 ) -> str:
197 """Create a basic component template."""
198 props = props or {}
200 # Generate prop fields
201 prop_lines = []
202 init_params = []
203 for prop_name, prop_type in props.items():
204 prop_lines.append(f" {prop_name}: {prop_type.__name__}")
205 init_params.append(f"{prop_name}: {prop_type.__name__}")
207 # Generate component class
208 mixins = ["HTMXComponentMixin"] if htmx_enabled else []
209 base_classes = ["DataclassComponentBase"] + mixins
211 template = f'''"""Component: {name}
213Auto-generated component using FastBlocks HTMY scaffolding.
214"""
216from dataclasses import dataclass
217from typing import Any
218from fastblocks.adapters.templates._htmy_components import (
219 DataclassComponentBase,
220 HTMXComponentMixin,
221)
224@dataclass
225class {name}({", ".join(base_classes)}):
226 """Auto-generated {name} component."""
227{chr(10).join(prop_lines) if prop_lines else " pass"}
229 def htmy(self, context: dict[str, Any]) -> str:
230 """Render the {name} component."""
231 return f"""
232 <div class="{name.lower()}-component">
233 <h3>{name} Component</h3>
234 {f'<p>{{self.{list(props.keys())[0]} if props else "content"}}</p>' if props else ""}
235 <!-- Add your HTML here -->
236 </div>
237 """
238'''
240 return template
242 @staticmethod
243 def create_htmx_component(
244 name: str, endpoint: str, trigger: str = "click", target: str = "#content"
245 ) -> str:
246 """Create an HTMX-enabled component template."""
247 template = f'''"""Component: {name}
249HTMX-enabled component for interactive behavior.
250"""
252from dataclasses import dataclass
253from typing import Any
254from fastblocks.adapters.templates._htmy_components import (
255 DataclassComponentBase,
256 HTMXComponentMixin,
257)
260@dataclass
261class {name}(DataclassComponentBase, HTMXComponentMixin):
262 """HTMX-enabled {name} component."""
263 label: str = "{name}"
264 css_class: str = "{name.lower()}-component"
266 @property
267 def htmx_attrs(self) -> dict[str, str]:
268 """HTMX attributes for the component."""
269 return {{
270 "hx-get": "{endpoint}",
271 "hx-trigger": "{trigger}",
272 "hx-target": "{target}",
273 "hx-swap": "innerHTML"
274 }}
276 def htmy(self, context: dict[str, Any]) -> str:
277 """Render the {name} component."""
278 attrs = " ".join([f'{{k}}="{{v}}"' for k, v in self.htmx_attrs.items()])
280 return f"""
281 <div class="{{self.css_class}}" {{attrs}}>
282 <button type="button">{{self.label}}</button>
283 </div>
284 """
285'''
287 return template
289 @staticmethod
290 def create_composite_component(name: str, children: list[str]) -> str:
291 """Create a composite component template."""
292 template = f'''"""Component: {name}
294Composite component containing multiple child components.
295"""
297from dataclasses import dataclass
298from typing import Any
299from fastblocks.adapters.templates._htmy_components import DataclassComponentBase
302@dataclass
303class {name}(DataclassComponentBase):
304 """Composite {name} component."""
305 title: str = "{name}"
307 def htmy(self, context: dict[str, Any]) -> str:
308 """Render the {name} composite component."""
309 # Access render_component from context if available
310 render_component = context.get("render_component")
312 children_html = ""
313 if render_component:
314{chr(10).join([f' children_html += render_component("{child}", context)' for child in children])}
316 return f"""
317 <div class="{name.lower()}-composite">
318 <h2>{{self.title}}</h2>
319 <div class="children">
320 {{children_html}}
321 </div>
322 </div>
323 """
324'''
326 return template
329class ComponentValidator:
330 """Component validation system."""
332 @staticmethod
333 async def validate_component_file(component_path: AsyncPath) -> ComponentMetadata:
334 """Validate a component file and extract metadata."""
335 try:
336 source = await component_path.read_text()
338 # Basic syntax validation
339 try:
340 compile(source, str(component_path), "exec")
341 except SyntaxError as e:
342 raise ComponentValidationError(f"Syntax error in {component_path}: {e}")
344 # Execute and analyze component
345 namespace: dict[str, Any] = {}
346 exec(source, namespace) # nosec B102 - trusted component files
348 component_class = None
349 for obj in namespace.values():
350 if (
351 inspect.isclass(obj)
352 and hasattr(obj, "htmy")
353 and callable(getattr(obj, "htmy"))
354 ):
355 component_class = obj
356 break
358 if component_class is None:
359 raise ComponentValidationError(
360 f"No valid component class found in {component_path}"
361 )
363 # Determine component type
364 component_type = ComponentValidator._determine_component_type(
365 component_class
366 )
368 # Extract metadata
369 metadata = ComponentMetadata(
370 name=component_path.stem,
371 path=component_path,
372 type=component_type,
373 status=ComponentStatus.VALIDATED,
374 docstring=inspect.getdoc(component_class),
375 last_modified=datetime.fromtimestamp(
376 (await component_path.stat()).st_mtime
377 ),
378 )
380 # Extract dependencies and HTMX attributes
381 if hasattr(component_class, "htmx_attrs"):
382 metadata.htmx_attributes = getattr(component_class, "htmx_attrs", {})
384 return metadata
386 except Exception as e:
387 return ComponentMetadata(
388 name=component_path.stem,
389 path=component_path,
390 type=ComponentType.BASIC,
391 status=ComponentStatus.ERROR,
392 error_message=str(e),
393 )
395 @staticmethod
396 def _determine_component_type(component_class: type) -> ComponentType:
397 """Determine the type of component."""
398 if is_dataclass(component_class):
399 if issubclass(component_class, HTMXComponentMixin):
400 return ComponentType.HTMX
401 return ComponentType.DATACLASS
403 if BaseModel and issubclass(component_class, BaseModel):
404 return ComponentType.PYDANTIC
406 if hasattr(component_class, "async_htmy"):
407 return ComponentType.ASYNC
409 return ComponentType.BASIC
412class ComponentLifecycleManager:
413 """Manages component lifecycle and state."""
415 def __init__(self) -> None:
416 self._component_states: dict[str, dict[str, Any]] = {}
417 self._lifecycle_hooks: dict[str, list[t.Callable[..., Any]]] = {
418 "before_render": [],
419 "after_render": [],
420 "on_error": [],
421 "on_state_change": [],
422 }
424 def register_hook(self, event: str, callback: t.Callable[..., Any]) -> None:
425 """Register a lifecycle hook."""
426 if event in self._lifecycle_hooks:
427 self._lifecycle_hooks[event].append(callback)
429 async def execute_hooks(self, event: str, **kwargs: Any) -> None:
430 """Execute lifecycle hooks for an event."""
431 for hook in self._lifecycle_hooks.get(event, []):
432 try:
433 if asyncio.iscoroutinefunction(hook):
434 await hook(**kwargs)
435 else:
436 hook(**kwargs)
437 except Exception as e:
438 debug(f"Lifecycle hook error for {event}: {e}")
440 def set_component_state(self, component_id: str, state: dict[str, Any]) -> None:
441 """Set component state."""
442 old_state = self._component_states.get(component_id, {})
443 self._component_states[component_id] = state
445 # Trigger state change hooks
446 asyncio.create_task(
447 self.execute_hooks(
448 "on_state_change",
449 component_id=component_id,
450 old_state=old_state,
451 new_state=state,
452 )
453 )
455 def get_component_state(self, component_id: str) -> dict[str, Any]:
456 """Get component state."""
457 return self._component_states.get(component_id, {})
459 def clear_component_state(self, component_id: str) -> None:
460 """Clear component state."""
461 self._component_states.pop(component_id, None)
464class AdvancedHTMYComponentRegistry:
465 """Enhanced component registry with advanced features."""
467 def __init__(
468 self,
469 searchpaths: list[AsyncPath] | None = None,
470 cache: t.Any = None,
471 storage: t.Any = None,
472 ) -> None:
473 self.searchpaths = searchpaths or []
474 self.cache = cache
475 self.storage = storage
476 self._component_cache: dict[str, t.Any] = {}
477 self._metadata_cache: dict[str, ComponentMetadata] = {}
478 self._scaffolder = ComponentScaffolder()
479 self._validator = ComponentValidator()
480 self._lifecycle_manager = ComponentLifecycleManager()
481 self._hot_reload_enabled = False
483 async def discover_components(self) -> dict[str, ComponentMetadata]:
484 """Discover all components with metadata."""
485 components = {}
487 for search_path in self.searchpaths:
488 if not await search_path.exists():
489 continue
491 async for component_file in search_path.rglob("*.py"):
492 if component_file.name == "__init__.py":
493 continue
495 component_name = component_file.stem
497 # Check cache first
498 cached_metadata = self._metadata_cache.get(component_name)
499 if cached_metadata and await self._is_cache_valid(cached_metadata):
500 components[component_name] = cached_metadata
501 continue
503 # Validate and cache metadata
504 metadata = await self._validator.validate_component_file(component_file)
505 self._metadata_cache[component_name] = metadata
506 components[component_name] = metadata
508 return components
510 async def _is_cache_valid(self, metadata: ComponentMetadata) -> bool:
511 """Check if cached metadata is still valid."""
512 try:
513 current_stat = await metadata.path.stat()
514 current_mtime = datetime.fromtimestamp(current_stat.st_mtime)
515 return metadata.last_modified == current_mtime
516 except Exception:
517 return False
519 async def get_component_class(self, component_name: str) -> t.Any:
520 """Get compiled component class with enhanced error handling."""
521 if component_name in self._component_cache:
522 return self._component_cache[component_name]
524 components = await self.discover_components()
525 if component_name not in components:
526 raise ComponentValidationError(f"Component '{component_name}' not found")
528 metadata = components[component_name]
530 if metadata.status == ComponentStatus.ERROR:
531 raise ComponentCompilationError(
532 f"Component '{component_name}' has errors: {metadata.error_message}"
533 )
535 try:
536 source = await metadata.path.read_text()
537 namespace: dict[str, Any] = {}
538 compiled_code = compile(source, str(metadata.path), "exec")
539 exec(compiled_code, namespace) # nosec B102 - trusted component files
541 component_class = None
542 for obj in namespace.values():
543 if (
544 inspect.isclass(obj)
545 and hasattr(obj, "htmy")
546 and callable(getattr(obj, "htmy"))
547 ):
548 component_class = obj
549 break
551 if component_class is None:
552 raise ComponentCompilationError(
553 f"No valid component class found in '{component_name}'"
554 )
556 self._component_cache[component_name] = component_class
557 metadata.status = ComponentStatus.READY
559 return component_class
561 except Exception as e:
562 metadata.status = ComponentStatus.ERROR
563 metadata.error_message = str(e)
564 raise ComponentCompilationError(
565 f"Failed to compile component '{component_name}': {e}"
566 ) from e
568 async def render_component_with_lifecycle(
569 self, component_name: str, context: dict[str, Any], request: Request
570 ) -> str:
571 """Render component with full lifecycle management."""
572 component_id = f"{component_name}_{uuid4().hex[:8]}"
574 try:
575 # Execute before_render hooks
576 await self._lifecycle_manager.execute_hooks(
577 "before_render",
578 component_name=component_name,
579 component_id=component_id,
580 context=context,
581 request=request,
582 )
584 component_class = await self.get_component_class(component_name)
585 component_instance = component_class(**context)
587 # Enhance context with lifecycle and state management
588 enhanced_context = context | {
589 "request": request,
590 "component_id": component_id,
591 "component_state": self._lifecycle_manager.get_component_state(
592 component_id
593 ),
594 "set_state": lambda state: self._lifecycle_manager.set_component_state(
595 component_id, state
596 ),
597 "render_component": self._create_nested_renderer(request),
598 }
600 # Render component
601 if hasattr(component_instance, "async_htmy"):
602 rendered_content = await component_instance.async_htmy(enhanced_context)
603 elif asyncio.iscoroutinefunction(component_instance.htmy):
604 rendered_content = await component_instance.htmy(enhanced_context)
605 else:
606 rendered_content = component_instance.htmy(enhanced_context)
608 # Execute after_render hooks
609 await self._lifecycle_manager.execute_hooks(
610 "after_render",
611 component_name=component_name,
612 component_id=component_id,
613 rendered_content=rendered_content,
614 request=request,
615 )
617 return t.cast(str, rendered_content)
619 except Exception as e:
620 # Execute error hooks
621 await self._lifecycle_manager.execute_hooks(
622 "on_error",
623 component_name=component_name,
624 component_id=component_id,
625 error=e,
626 request=request,
627 )
628 raise ComponentRenderError(
629 f"Failed to render component '{component_name}': {e}"
630 ) from e
632 def _create_nested_renderer(
633 self, request: Request
634 ) -> t.Callable[..., t.Awaitable[str]]:
635 """Create a nested component renderer for composition."""
637 async def render_nested(
638 component_name: str, context: dict[str, Any] | None = None
639 ) -> str:
640 if context is None:
641 context = {}
642 return await self.render_component_with_lifecycle(
643 component_name, context, request
644 )
646 return render_nested
648 async def scaffold_component(
649 self,
650 name: str,
651 component_type: ComponentType = ComponentType.DATACLASS,
652 target_path: AsyncPath | None = None,
653 **kwargs: Any,
654 ) -> AsyncPath:
655 """Scaffold a new component with the specified type."""
656 if target_path is None and self.searchpaths:
657 target_path = self.searchpaths[0] / f"{name.lower()}.py"
658 elif target_path is None:
659 raise ValueError("No target path specified and no searchpaths configured")
661 # Generate component code based on type
662 if component_type == ComponentType.HTMX:
663 content = self._scaffolder.create_htmx_component(name, **kwargs)
664 elif component_type == ComponentType.COMPOSITE:
665 content = self._scaffolder.create_composite_component(name, **kwargs)
666 else:
667 content = self._scaffolder.create_basic_component(name, **kwargs)
669 # Ensure directory exists
670 await target_path.parent.mkdir(parents=True, exist_ok=True)
672 # Write component file
673 await target_path.write_text(content)
675 # Clear cache to force re-discovery
676 self.clear_cache()
678 debug(f"Scaffolded {component_type.value} component '{name}' at {target_path}")
679 return target_path
681 def clear_cache(self, component_name: str | None = None) -> None:
682 """Clear component cache."""
683 if component_name:
684 self._component_cache.pop(component_name, None)
685 self._metadata_cache.pop(component_name, None)
686 else:
687 self._component_cache.clear()
688 self._metadata_cache.clear()
690 def enable_hot_reload(self) -> None:
691 """Enable hot reloading for development."""
692 self._hot_reload_enabled = True
693 debug("HTMY component hot reload enabled")
695 def disable_hot_reload(self) -> None:
696 """Disable hot reloading."""
697 self._hot_reload_enabled = False
698 debug("HTMY component hot reload disabled")
700 @property
701 def lifecycle_manager(self) -> ComponentLifecycleManager:
702 """Access to lifecycle manager."""
703 return self._lifecycle_manager