Coverage for fastblocks/adapters/templates/htmy.py: 0%

289 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-21 04:50 -0700

1"""HTMY Templates Adapter for FastBlocks. 

2 

3Provides native HTMY component rendering with advanced features including: 

4- Component discovery and caching system 

5- Multi-layer caching (Redis, cloud storage, filesystem) 

6- Bidirectional integration with Jinja2 templates 

7- Async component rendering with context sharing 

8- Template synchronization across cache/storage/filesystem layers 

9- Enhanced debugging and error handling 

10 

11Requirements: 

12- htmy>=0.1.0 

13- redis>=3.5.3 (for caching) 

14 

15Usage: 

16```python 

17from acb.depends import depends 

18from acb.adapters import import_adapter 

19 

20htmy = depends.get("htmy") 

21 

22HTMYTemplates = import_adapter("htmy") 

23 

24response = await htmy.render_component(request, "my_component", {"data": data}) 

25 

26component_class = await htmy.get_component_class("my_component") 

27``` 

28 

29Author: lesleslie <les@wedgwoodwebworks.com> 

30Created: 2025-01-13 

31""" 

32 

33import asyncio 

34import typing as t 

35from contextlib import suppress 

36from uuid import UUID 

37 

38from acb.adapters import AdapterStatus, get_adapter, import_adapter, root_path 

39from acb.debug import debug 

40from acb.depends import depends 

41from anyio import Path as AsyncPath 

42from starlette.responses import HTMLResponse 

43 

44from ._base import TemplatesBase, TemplatesBaseSettings 

45 

46try: 

47 from fastblocks.actions.sync.strategies import SyncDirection, SyncStrategy 

48 from fastblocks.actions.sync.templates import sync_templates 

49except ImportError: 

50 sync_templates = None 

51 SyncDirection = None 

52 SyncStrategy = None 

53 

54try: 

55 Cache, Storage, Models = import_adapter() 

56except Exception: 

57 Cache = Storage = Models = None 

58 

59 

60class ComponentNotFound(Exception): 

61 pass 

62 

63 

64class ComponentCompilationError(Exception): 

65 pass 

66 

67 

68class HTMYComponentRegistry: 

69 def __init__( 

70 self, 

71 searchpaths: list[AsyncPath] | None = None, 

72 cache: t.Any = None, 

73 storage: t.Any = None, 

74 ) -> None: 

75 self.searchpaths = searchpaths or [] 

76 self.cache = cache 

77 self.storage = storage 

78 self._component_cache: dict[str, t.Any] = {} 

79 self._source_cache: dict[str, str] = {} 

80 

81 @staticmethod 

82 def get_cache_key(component_path: AsyncPath, cache_type: str = "source") -> str: 

83 return f"htmy_component_{cache_type}:{component_path}" 

84 

85 @staticmethod 

86 def get_storage_path(component_path: AsyncPath) -> AsyncPath: 

87 return component_path 

88 

89 async def discover_components(self) -> dict[str, AsyncPath]: 

90 components = {} 

91 for search_path in self.searchpaths: 

92 if not await search_path.exists(): 

93 continue 

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

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

96 continue 

97 component_name = component_file.stem 

98 components[component_name] = component_file 

99 

100 return components 

101 

102 async def _cache_component_source( 

103 self, component_path: AsyncPath, source: str 

104 ) -> None: 

105 if self.cache is not None: 

106 cache_key = self.get_cache_key(component_path) 

107 await self.cache.set(cache_key, source.encode()) 

108 

109 async def _cache_component_bytecode( 

110 self, component_path: AsyncPath, bytecode: bytes 

111 ) -> None: 

112 if self.cache is not None: 

113 cache_key = self.get_cache_key(component_path, "bytecode") 

114 await self.cache.set(cache_key, bytecode) 

115 

116 async def _get_cached_source(self, component_path: AsyncPath) -> str | None: 

117 if self.cache is not None: 

118 cache_key = self.get_cache_key(component_path) 

119 cached = await self.cache.get(cache_key) 

120 if cached: 

121 return cached.decode() 

122 return None 

123 

124 async def _get_cached_bytecode(self, component_path: AsyncPath) -> bytes | None: 

125 if self.cache is not None: 

126 cache_key = self.get_cache_key(component_path, "bytecode") 

127 return await self.cache.get(cache_key) 

128 return None 

129 

130 async def _sync_component_file( 

131 self, 

132 path: AsyncPath, 

133 storage_path: AsyncPath, 

134 ) -> tuple[str, int]: 

135 if sync_templates is None or SyncDirection is None or SyncStrategy is None: 

136 return await self._sync_from_storage_fallback(path, storage_path) 

137 

138 try: 

139 strategy = SyncStrategy(backup_on_conflict=False) 

140 component_paths = [path] 

141 result = await sync_templates( 

142 template_paths=component_paths, 

143 strategy=strategy, 

144 ) 

145 

146 source = await path.read_text() 

147 local_stat = await path.stat() 

148 local_mtime = int(local_stat.st_mtime) 

149 

150 debug(f"Component sync result: {result.sync_status} for {path}") 

151 return source, local_mtime 

152 

153 except Exception as e: 

154 debug(f"Sync action failed for {path}: {e}, falling back to primitive sync") 

155 return await self._sync_from_storage_fallback(path, storage_path) 

156 

157 async def _sync_from_storage_fallback( 

158 self, 

159 path: AsyncPath, 

160 storage_path: AsyncPath, 

161 ) -> tuple[str, int]: 

162 local_stat = await path.stat() 

163 local_mtime = int(local_stat.st_mtime) 

164 

165 if self.storage is not None: 

166 try: 

167 local_size = local_stat.st_size 

168 storage_stat = await self.storage.templates.stat(storage_path) 

169 storage_mtime = round(storage_stat.get("mtime", 0)) 

170 storage_size = storage_stat.get("size", 0) 

171 

172 if local_mtime < storage_mtime and local_size != storage_size: 

173 resp = await self.storage.templates.open(storage_path) 

174 await path.write_bytes(resp) 

175 source = resp.decode() 

176 return source, storage_mtime 

177 except Exception as e: 

178 debug(f"Storage fallback failed for {path}: {e}") 

179 

180 source = await path.read_text() 

181 return source, local_mtime 

182 

183 async def get_component_source(self, component_name: str) -> tuple[str, AsyncPath]: 

184 components = await self.discover_components() 

185 if component_name not in components: 

186 raise ComponentNotFound(f"Component '{component_name}' not found") 

187 component_path = components[component_name] 

188 cache_key = str(component_path) 

189 if cache_key in self._source_cache: 

190 return self._source_cache[cache_key], component_path 

191 cached_source = await self._get_cached_source(component_path) 

192 if cached_source: 

193 self._source_cache[cache_key] = cached_source 

194 return cached_source, component_path 

195 storage_path = self.get_storage_path(component_path) 

196 source, _ = await self._sync_component_file(component_path, storage_path) 

197 self._source_cache[cache_key] = source 

198 await self._cache_component_source(component_path, source) 

199 

200 return source, component_path 

201 

202 async def get_component_class(self, component_name: str) -> t.Any: 

203 if component_name in self._component_cache: 

204 return self._component_cache[component_name] 

205 source, component_path = await self.get_component_source(component_name) 

206 cached_bytecode = await self._get_cached_bytecode(component_path) 

207 try: 

208 if cached_bytecode: 

209 try: 

210 import pickle 

211 

212 component_class = pickle.loads(cached_bytecode) 

213 self._component_cache[component_name] = component_class 

214 return component_class 

215 except Exception as e: 

216 debug(f"Failed to load cached bytecode for {component_name}: {e}") 

217 namespace = {} 

218 compiled_code = compile(source, str(component_path), "exec") 

219 exec(compiled_code, namespace) 

220 component_class = None 

221 for obj in namespace.values(): 

222 if hasattr(obj, "htmy") and callable(getattr(obj, "htmy")): 

223 component_class = obj 

224 break 

225 if component_class is None: 

226 raise ComponentCompilationError( 

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

228 ) 

229 self._component_cache[component_name] = component_class 

230 try: 

231 import pickle 

232 

233 bytecode = pickle.dumps(component_class) 

234 await self._cache_component_bytecode(component_path, bytecode) 

235 except Exception as e: 

236 debug(f"Failed to cache bytecode for {component_name}: {e}") 

237 

238 return component_class 

239 except Exception as e: 

240 raise ComponentCompilationError( 

241 f"Failed to compile component '{component_name}': {e}" 

242 ) from e 

243 

244 

245class HTMYTemplatesSettings(TemplatesBaseSettings): 

246 searchpaths: list[str] = [] 

247 cache_timeout: int = 300 

248 enable_bidirectional: bool = True 

249 debug_components: bool = False 

250 

251 

252class HTMYTemplates(TemplatesBase): 

253 def __init__(self, **kwargs: t.Any) -> None: 

254 super().__init__(**kwargs) 

255 self.htmy_registry: HTMYComponentRegistry | None = None 

256 self.component_searchpaths: list[AsyncPath] = [] 

257 self.jinja_templates: t.Any = None 

258 

259 async def get_component_searchpaths(self, app_adapter: t.Any) -> list[AsyncPath]: 

260 searchpaths = [] 

261 if callable(root_path): 

262 base_root = AsyncPath(root_path()) 

263 else: 

264 base_root = AsyncPath(root_path) 

265 debug(f"get_component_searchpaths: app_adapter={app_adapter}") 

266 if app_adapter: 

267 category = getattr(app_adapter, "category", "app") 

268 debug(f"get_component_searchpaths: using category={category}") 

269 template_paths = self.get_searchpath( 

270 app_adapter, base_root / "templates" / category 

271 ) 

272 debug(f"get_component_searchpaths: template_paths={template_paths}") 

273 for template_path in template_paths: 

274 component_path = template_path / "components" 

275 searchpaths.append(component_path) 

276 debug( 

277 f"get_component_searchpaths: added component_path={component_path}" 

278 ) 

279 debug(f"get_component_searchpaths: final searchpaths={searchpaths}") 

280 return searchpaths 

281 

282 async def _init_htmy_registry(self) -> None: 

283 if self.htmy_registry is not None: 

284 return 

285 app_adapter = get_adapter("app") 

286 if app_adapter is None: 

287 try: 

288 app_adapter = depends.get("app") 

289 except Exception: 

290 from types import SimpleNamespace 

291 

292 app_adapter = SimpleNamespace(name="app", category="app") 

293 self.component_searchpaths = await self.get_component_searchpaths(app_adapter) 

294 self.htmy_registry = HTMYComponentRegistry( 

295 searchpaths=self.component_searchpaths, 

296 cache=self.cache, 

297 storage=self.storage, 

298 ) 

299 

300 async def clear_component_cache(self, component_name: str | None = None) -> None: 

301 if self.htmy_registry is None: 

302 return 

303 if component_name: 

304 self.htmy_registry._component_cache.pop(component_name, None) 

305 if self.cache: 

306 components = await self.htmy_registry.discover_components() 

307 if component_name in components: 

308 component_path = components[component_name] 

309 source_key = HTMYComponentRegistry.get_cache_key(component_path) 

310 bytecode_key = HTMYComponentRegistry.get_cache_key( 

311 component_path, "bytecode" 

312 ) 

313 await self.cache.delete(source_key) 

314 await self.cache.delete(bytecode_key) 

315 debug(f"HTMY component cache cleared for: {component_name}") 

316 else: 

317 self.htmy_registry._component_cache.clear() 

318 self.htmy_registry._source_cache.clear() 

319 if self.cache: 

320 with suppress(NotImplementedError, AttributeError): 

321 await self.cache.clear("htmy_component_source") 

322 await self.cache.clear("htmy_component_bytecode") 

323 debug("All HTMY component caches cleared") 

324 

325 async def get_component_class(self, component_name: str) -> t.Any: 

326 if self.htmy_registry is None: 

327 await self._init_htmy_registry() 

328 

329 return await self.htmy_registry.get_component_class(component_name) 

330 

331 async def render_component( 

332 self, 

333 request: t.Any, 

334 component: str, 

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

336 status_code: int = 200, 

337 headers: dict[str, str] | None = None, 

338 **kwargs: t.Any, 

339 ) -> HTMLResponse: 

340 if context is None: 

341 context = {} 

342 if headers is None: 

343 headers = {} 

344 

345 if self.htmy_registry is None: 

346 await self._init_htmy_registry() 

347 

348 try: 

349 component_class = await self.htmy_registry.get_component_class(component) 

350 

351 component_instance = component_class(**context, **kwargs) 

352 

353 htmy_context = { 

354 "request": request, 

355 **context, 

356 "render_template": self._create_template_renderer(request), 

357 "render_block": self._create_block_renderer(request), 

358 "_template_system": "htmy", 

359 "_request": request, 

360 } 

361 

362 if asyncio.iscoroutinefunction(component_instance.htmy): 

363 rendered_content = await component_instance.htmy(htmy_context) 

364 else: 

365 rendered_content = component_instance.htmy(htmy_context) 

366 

367 html_content = str(rendered_content) 

368 

369 return HTMLResponse( 

370 content=html_content, 

371 status_code=status_code, 

372 headers=headers, 

373 ) 

374 

375 except (ComponentNotFound, ComponentCompilationError) as e: 

376 return HTMLResponse( 

377 content=f"<html><body>Component {component} error: {e}</body></html>", 

378 status_code=404, 

379 headers=headers, 

380 ) 

381 

382 def _create_template_renderer( 

383 self, request: t.Any = None 

384 ) -> t.Callable[..., t.Any]: 

385 async def render_template( 

386 template_name: str, 

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

388 inherit_context: bool = True, # noqa: ARG001 

389 **kwargs: t.Any, 

390 ) -> str: 

391 if context is None: 

392 context = {} 

393 

394 template_context = {**context, **kwargs} 

395 

396 if self.jinja_templates and hasattr(self.jinja_templates, "app"): 

397 try: 

398 template = self.jinja_templates.app.get_template(template_name) 

399 if asyncio.iscoroutinefunction(template.render): 

400 rendered = await template.render(template_context) 

401 else: 

402 rendered = template.render(template_context) 

403 return rendered 

404 except Exception as e: 

405 debug( 

406 f"Failed to render template '{template_name}' in HTMY component: {e}" 

407 ) 

408 return f"<!-- Error rendering template '{template_name}': {e} -->" 

409 else: 

410 debug( 

411 f"No Jinja2 adapter available to render template '{template_name}' in HTMY component" 

412 ) 

413 return f"<!-- No template renderer available for '{template_name}' -->" 

414 

415 return render_template 

416 

417 def _create_block_renderer(self, request: t.Any = None) -> t.Callable[..., t.Any]: 

418 async def render_block( 

419 block_name: str, context: dict[str, t.Any] | None = None, **kwargs: t.Any 

420 ) -> str: 

421 if context is None: 

422 context = {} 

423 

424 block_context = {**context, **kwargs} 

425 

426 if ( 

427 self.jinja_templates 

428 and hasattr(self.jinja_templates, "app") 

429 and hasattr(self.jinja_templates.app, "render_block") 

430 ): 

431 try: 

432 if asyncio.iscoroutinefunction( 

433 self.jinja_templates.app.render_block 

434 ): 

435 rendered = await self.jinja_templates.app.render_block( 

436 block_name, block_context 

437 ) 

438 else: 

439 rendered = self.jinja_templates.app.render_block( 

440 block_name, block_context 

441 ) 

442 return rendered 

443 except Exception as e: 

444 debug( 

445 f"Failed to render block '{block_name}' in HTMY component: {e}" 

446 ) 

447 return f"<!-- Error rendering block '{block_name}': {e} -->" 

448 else: 

449 debug( 

450 f"No block renderer available for '{block_name}' in HTMY component" 

451 ) 

452 return f"<!-- No block renderer available for '{block_name}' -->" 

453 

454 return render_block 

455 

456 async def init(self, cache: t.Any | None = None) -> None: 

457 if cache is None: 

458 try: 

459 cache = depends.get("cache") 

460 except Exception: 

461 cache = None 

462 self.cache = cache 

463 try: 

464 self.storage = depends.get("storage") 

465 except Exception: 

466 self.storage = None 

467 await self._init_htmy_registry() 

468 try: 

469 self.jinja_templates = depends.get("templates") 

470 except Exception: 

471 self.jinja_templates = None 

472 depends.set("htmy", self) 

473 debug("HTMY Templates adapter initialized") 

474 

475 async def render_template( 

476 self, 

477 request: t.Any, 

478 template: str, 

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

480 status_code: int = 200, 

481 headers: dict[str, str] | None = None, 

482 ) -> HTMLResponse: 

483 return await self.render_component( 

484 request=request, 

485 component=template, 

486 context=context, 

487 status_code=status_code, 

488 headers=headers, 

489 ) 

490 

491 

492MODULE_ID = UUID("01937d86-e1f2-7890-abcd-ef1234567890") 

493MODULE_STATUS = AdapterStatus.STABLE 

494 

495with suppress(Exception): 

496 depends.set(HTMYTemplates)