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

1"""HTMY Component Management System for FastBlocks. 

2 

3Provides advanced component discovery, validation, scaffolding, and lifecycle management 

4for HTMY components with deep HTMX integration and async rendering capabilities. 

5 

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 

14 

15Author: lesleslie <les@wedgwoodwebworks.com> 

16Created: 2025-01-13 

17""" 

18 

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 

28 

29from acb.debug import debug 

30from anyio import Path as AsyncPath 

31from starlette.requests import Request 

32 

33try: 

34 from pydantic import BaseModel 

35except ImportError: 

36 BaseModel = None # type: ignore[no-redef] 

37 

38 

39class ComponentStatus(str, Enum): 

40 """Component lifecycle status.""" 

41 

42 DISCOVERED = "discovered" 

43 VALIDATED = "validated" 

44 COMPILED = "compiled" 

45 READY = "ready" 

46 ERROR = "error" 

47 DEPRECATED = "deprecated" 

48 

49 

50class ComponentType(str, Enum): 

51 """Component classification types.""" 

52 

53 BASIC = "basic" 

54 DATACLASS = "dataclass" 

55 PYDANTIC = "pydantic" 

56 ASYNC = "async" 

57 HTMX = "htmx" 

58 COMPOSITE = "composite" 

59 

60 

61@dataclass 

62class ComponentMetadata: 

63 """Component metadata for discovery and management.""" 

64 

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 

75 

76 def __post_init__(self) -> None: 

77 if self.cache_key is None: 

78 self.cache_key = f"component_{self.name}_{self.path.stem}" 

79 

80 

81class ComponentValidationError(Exception): 

82 """Raised when component validation fails.""" 

83 

84 pass 

85 

86 

87class ComponentCompilationError(Exception): 

88 """Raised when component compilation fails.""" 

89 

90 pass 

91 

92 

93class ComponentRenderError(Exception): 

94 """Raised when component rendering fails.""" 

95 

96 pass 

97 

98 

99class HTMXComponentMixin: 

100 """Mixin for HTMX-aware components.""" 

101 

102 @property 

103 def htmx_attrs(self) -> dict[str, str]: 

104 """Default HTMX attributes for the component.""" 

105 return {} 

106 

107 def get_htmx_trigger(self, request: Request) -> str | None: 

108 """Extract HTMX trigger from request.""" 

109 return request.headers.get("HX-Trigger") 

110 

111 def get_htmx_target(self, request: Request) -> str | None: 

112 """Extract HTMX target from request.""" 

113 return request.headers.get("HX-Target") 

114 

115 def is_htmx_request(self, request: Request) -> bool: 

116 """Check if request is from HTMX.""" 

117 return request.headers.get("HX-Request") == "true" 

118 

119 

120class ComponentBase(ABC): 

121 """Base class for all HTMY components.""" 

122 

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 

128 

129 @abstractmethod 

130 def htmy(self, context: dict[str, Any]) -> str: 

131 """Render the component to HTML.""" 

132 pass 

133 

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] 

139 

140 def add_child(self, child: "ComponentBase") -> None: 

141 """Add a child component.""" 

142 child._parent = self 

143 self._children.append(child) 

144 

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) 

150 

151 @property 

152 def children(self) -> list["ComponentBase"]: 

153 """Get child components.""" 

154 return self._children.copy() 

155 

156 @property 

157 def parent(self) -> Optional["ComponentBase"]: 

158 """Get parent component.""" 

159 return self._parent 

160 

161 

162class DataclassComponentBase(ComponentBase): 

163 """Base class for dataclass-based components.""" 

164 

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 ) 

171 

172 def validate_fields(self) -> None: 

173 """Validate component fields.""" 

174 if not is_dataclass(self): 

175 return 

176 

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 ) 

188 

189 

190class ComponentScaffolder: 

191 """Scaffolding system for creating new components.""" 

192 

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 {} 

199 

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__}") 

206 

207 # Generate component class 

208 mixins = ["HTMXComponentMixin"] if htmx_enabled else [] 

209 base_classes = ["DataclassComponentBase"] + mixins 

210 

211 template = f'''"""Component: {name} 

212 

213Auto-generated component using FastBlocks HTMY scaffolding. 

214""" 

215 

216from dataclasses import dataclass 

217from typing import Any 

218from fastblocks.adapters.templates._htmy_components import ( 

219 DataclassComponentBase, 

220 HTMXComponentMixin, 

221) 

222 

223 

224@dataclass 

225class {name}({", ".join(base_classes)}): 

226 """Auto-generated {name} component.""" 

227{chr(10).join(prop_lines) if prop_lines else " pass"} 

228 

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''' 

239 

240 return template 

241 

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} 

248 

249HTMX-enabled component for interactive behavior. 

250""" 

251 

252from dataclasses import dataclass 

253from typing import Any 

254from fastblocks.adapters.templates._htmy_components import ( 

255 DataclassComponentBase, 

256 HTMXComponentMixin, 

257) 

258 

259 

260@dataclass 

261class {name}(DataclassComponentBase, HTMXComponentMixin): 

262 """HTMX-enabled {name} component.""" 

263 label: str = "{name}" 

264 css_class: str = "{name.lower()}-component" 

265 

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 }} 

275 

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()]) 

279 

280 return f""" 

281 <div class="{{self.css_class}}" {{attrs}}> 

282 <button type="button">{{self.label}}</button> 

283 </div> 

284 """ 

285''' 

286 

287 return template 

288 

289 @staticmethod 

290 def create_composite_component(name: str, children: list[str]) -> str: 

291 """Create a composite component template.""" 

292 template = f'''"""Component: {name} 

293 

294Composite component containing multiple child components. 

295""" 

296 

297from dataclasses import dataclass 

298from typing import Any 

299from fastblocks.adapters.templates._htmy_components import DataclassComponentBase 

300 

301 

302@dataclass 

303class {name}(DataclassComponentBase): 

304 """Composite {name} component.""" 

305 title: str = "{name}" 

306 

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") 

311 

312 children_html = "" 

313 if render_component: 

314{chr(10).join([f' children_html += render_component("{child}", context)' for child in children])} 

315 

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''' 

325 

326 return template 

327 

328 

329class ComponentValidator: 

330 """Component validation system.""" 

331 

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() 

337 

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}") 

343 

344 # Execute and analyze component 

345 namespace: dict[str, Any] = {} 

346 exec(source, namespace) # nosec B102 - trusted component files 

347 

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 

357 

358 if component_class is None: 

359 raise ComponentValidationError( 

360 f"No valid component class found in {component_path}" 

361 ) 

362 

363 # Determine component type 

364 component_type = ComponentValidator._determine_component_type( 

365 component_class 

366 ) 

367 

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 ) 

379 

380 # Extract dependencies and HTMX attributes 

381 if hasattr(component_class, "htmx_attrs"): 

382 metadata.htmx_attributes = getattr(component_class, "htmx_attrs", {}) 

383 

384 return metadata 

385 

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 ) 

394 

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 

402 

403 if BaseModel and issubclass(component_class, BaseModel): 

404 return ComponentType.PYDANTIC 

405 

406 if hasattr(component_class, "async_htmy"): 

407 return ComponentType.ASYNC 

408 

409 return ComponentType.BASIC 

410 

411 

412class ComponentLifecycleManager: 

413 """Manages component lifecycle and state.""" 

414 

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 } 

423 

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) 

428 

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}") 

439 

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 

444 

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 ) 

454 

455 def get_component_state(self, component_id: str) -> dict[str, Any]: 

456 """Get component state.""" 

457 return self._component_states.get(component_id, {}) 

458 

459 def clear_component_state(self, component_id: str) -> None: 

460 """Clear component state.""" 

461 self._component_states.pop(component_id, None) 

462 

463 

464class AdvancedHTMYComponentRegistry: 

465 """Enhanced component registry with advanced features.""" 

466 

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 

482 

483 async def discover_components(self) -> dict[str, ComponentMetadata]: 

484 """Discover all components with metadata.""" 

485 components = {} 

486 

487 for search_path in self.searchpaths: 

488 if not await search_path.exists(): 

489 continue 

490 

491 async for component_file in search_path.rglob("*.py"): 

492 if component_file.name == "__init__.py": 

493 continue 

494 

495 component_name = component_file.stem 

496 

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 

502 

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 

507 

508 return components 

509 

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 

518 

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] 

523 

524 components = await self.discover_components() 

525 if component_name not in components: 

526 raise ComponentValidationError(f"Component '{component_name}' not found") 

527 

528 metadata = components[component_name] 

529 

530 if metadata.status == ComponentStatus.ERROR: 

531 raise ComponentCompilationError( 

532 f"Component '{component_name}' has errors: {metadata.error_message}" 

533 ) 

534 

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 

540 

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 

550 

551 if component_class is None: 

552 raise ComponentCompilationError( 

553 f"No valid component class found in '{component_name}'" 

554 ) 

555 

556 self._component_cache[component_name] = component_class 

557 metadata.status = ComponentStatus.READY 

558 

559 return component_class 

560 

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 

567 

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]}" 

573 

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 ) 

583 

584 component_class = await self.get_component_class(component_name) 

585 component_instance = component_class(**context) 

586 

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 } 

599 

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) 

607 

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 ) 

616 

617 return t.cast(str, rendered_content) 

618 

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 

631 

632 def _create_nested_renderer( 

633 self, request: Request 

634 ) -> t.Callable[..., t.Awaitable[str]]: 

635 """Create a nested component renderer for composition.""" 

636 

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 ) 

645 

646 return render_nested 

647 

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") 

660 

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) 

668 

669 # Ensure directory exists 

670 await target_path.parent.mkdir(parents=True, exist_ok=True) 

671 

672 # Write component file 

673 await target_path.write_text(content) 

674 

675 # Clear cache to force re-discovery 

676 self.clear_cache() 

677 

678 debug(f"Scaffolded {component_type.value} component '{name}' at {target_path}") 

679 return target_path 

680 

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() 

689 

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") 

694 

695 def disable_hot_reload(self) -> None: 

696 """Disable hot reloading.""" 

697 self._hot_reload_enabled = False 

698 debug("HTMY component hot reload disabled") 

699 

700 @property 

701 def lifecycle_manager(self) -> ComponentLifecycleManager: 

702 """Access to lifecycle manager.""" 

703 return self._lifecycle_manager