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

596 statements  

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

1"""Jinja2 Templates Adapter for FastBlocks. 

2 

3Provides asynchronous Jinja2 template rendering with advanced features including: 

4- Multi-layer loader system (Redis cache, cloud storage, filesystem) 

5- Bidirectional integration with HTMY components via dedicated HTMY adapter 

6- Fragment and partial template support 

7- Bytecode caching with Redis backend 

8- Template synchronization across cache/storage/filesystem layers 

9- Custom delimiters ([[/]] instead of {{/}}) 

10- Extensive template debugging and error handling 

11 

12Requirements: 

13- jinja2-async-environment>=0.14.3 

14- starlette-async-jinja>=1.12.4 

15- jinja2>=3.1.6 

16- redis>=3.5.3 (for caching) 

17 

18Usage: 

19```python 

20from acb.depends import depends 

21from acb.adapters import import_adapter 

22 

23templates = depends.get("templates") 

24 

25Templates = import_adapter("templates") 

26 

27response = await templates.render_template( 

28 request, "index.html", {"title": "FastBlocks"} 

29) 

30``` 

31 

32Author: lesleslie <les@wedgwoodwebworks.com> 

33Created: 2025-01-12 

34""" 

35 

36import asyncio 

37import re 

38import typing as t 

39from ast import literal_eval 

40from contextlib import suppress 

41from html.parser import HTMLParser 

42from importlib import import_module 

43from importlib.util import find_spec 

44from inspect import isclass 

45from pathlib import Path 

46from uuid import UUID 

47 

48from acb.adapters import AdapterStatus, get_adapter, import_adapter 

49from acb.config import Config 

50from acb.debug import debug 

51from acb.depends import depends 

52from anyio import Path as AsyncPath 

53from jinja2 import TemplateNotFound 

54from jinja2.ext import Extension, i18n, loopcontrols 

55from jinja2.ext import debug as jinja_debug 

56from jinja2_async_environment.bccache import AsyncRedisBytecodeCache 

57from jinja2_async_environment.loaders import AsyncBaseLoader, SourceType 

58from starlette_async_jinja import AsyncJinja2Templates 

59 

60from ._base import TemplatesBase, TemplatesBaseSettings 

61 

62try: 

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

64 from fastblocks.actions.sync.templates import sync_templates 

65except ImportError: 

66 sync_templates = None 

67 SyncDirection = None 

68 SyncStrategy = None 

69 

70try: 

71 Cache, Storage, Models = import_adapter() 

72except Exception: 

73 Cache = Storage = Models = None 

74 

75_TEMPLATE_REPLACEMENTS = [ 

76 (b"{{", b"[["), 

77 (b"}}", b"]]"), 

78 (b"{%", b"[%"), 

79 (b"%}", b"%]"), 

80] 

81_HTTP_TO_HTTPS = (b"http://", b"https://") 

82 

83_ATTR_PATTERN_CACHE: dict[str, re.Pattern[str]] = {} 

84 

85 

86def _get_attr_pattern(attr: str) -> re.Pattern[str]: 

87 if attr not in _ATTR_PATTERN_CACHE: 

88 escaped_attr = re.escape(f"{attr}=") 

89 _ATTR_PATTERN_CACHE[attr] = re.compile( 

90 escaped_attr 

91 ) # REGEX OK: Template attribute pattern compilation for Jinja2 

92 return _ATTR_PATTERN_CACHE[attr] 

93 

94 

95def _apply_template_replacements(source: bytes, deployed: bool = False) -> bytes: 

96 for old_pattern, new_pattern in _TEMPLATE_REPLACEMENTS: 

97 source = source.replace(old_pattern, new_pattern) 

98 if deployed: 

99 source = source.replace(*_HTTP_TO_HTTPS) 

100 

101 return source 

102 

103 

104class BaseTemplateLoader(AsyncBaseLoader): 

105 config: Config = depends() 

106 cache: Cache = depends() 

107 storage: Storage = depends() 

108 

109 def __init__( 

110 self, 

111 searchpath: AsyncPath | t.Sequence[AsyncPath] | None = None, 

112 ) -> None: 

113 super().__init__(searchpath or []) 

114 if self.storage is None: 

115 try: 

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

117 except Exception: 

118 self.storage = get_adapter("storage") 

119 if self.cache is None: 

120 try: 

121 self.cache = depends.get("cache") 

122 except Exception: 

123 self.cache = get_adapter("cache") 

124 if not hasattr(self, "config"): 

125 try: 

126 self.config = depends.get("config") 

127 except Exception: 

128 config_adapter = get_adapter("config") 

129 self.config = ( 

130 config_adapter if isinstance(config_adapter, Config) else Config() 

131 ) 

132 

133 def get_supported_extensions(self) -> tuple[str, ...]: 

134 return ("html", "css", "js") 

135 

136 async def _list_templates_for_extensions( 

137 self, 

138 extensions: tuple[str, ...], 

139 ) -> list[str]: 

140 found: set[str] = set() 

141 for searchpath in self.searchpath: 

142 for ext in extensions: 

143 async for p in searchpath.rglob(f"*.{ext}"): 

144 found.add(str(p)) 

145 return sorted(found) 

146 

147 def _normalize_template( 

148 self, 

149 environment_or_template: t.Any, 

150 template: str | AsyncPath | None = None, 

151 ) -> str | AsyncPath: 

152 if template is None: 

153 template = environment_or_template 

154 assert template is not None 

155 return template 

156 

157 async def _find_template_path_parallel( 

158 self, 

159 template: str | AsyncPath, 

160 ) -> AsyncPath | None: 

161 async def check_path(searchpath: AsyncPath) -> AsyncPath | None: 

162 path = searchpath / template 

163 if await path.is_file(): 

164 return path 

165 return None 

166 

167 tasks = [check_path(searchpath) for searchpath in self.searchpath] 

168 results = await asyncio.gather(*tasks, return_exceptions=True) 

169 

170 for result in results: 

171 if isinstance(result, AsyncPath): 

172 return result 

173 return None 

174 

175 async def _find_storage_path_parallel( 

176 self, 

177 template: str | AsyncPath, 

178 ) -> tuple[AsyncPath, AsyncPath] | None: 

179 async def check_storage_path( 

180 searchpath: AsyncPath, 

181 ) -> tuple[AsyncPath, AsyncPath] | None: 

182 path = searchpath / template 

183 storage_path = Templates.get_storage_path(path) 

184 if storage_path and await self.storage.templates.exists(storage_path): 

185 return path, storage_path 

186 return None 

187 

188 tasks = [check_storage_path(searchpath) for searchpath in self.searchpath] 

189 results = await asyncio.gather(*tasks, return_exceptions=True) 

190 

191 for result in results: 

192 if isinstance(result, tuple): 

193 return result 

194 return None 

195 

196 async def _find_cache_path_parallel( 

197 self, 

198 template: str | AsyncPath, 

199 ) -> tuple[AsyncPath, AsyncPath, str] | None: 

200 async def check_cache_path( 

201 searchpath: AsyncPath, 

202 ) -> tuple[AsyncPath, AsyncPath, str] | None: 

203 path = searchpath / template if searchpath else AsyncPath(template) 

204 storage_path = Templates.get_storage_path(path) 

205 cache_key = Templates.get_cache_key(storage_path) 

206 if ( 

207 storage_path 

208 and self.cache is not None 

209 and await self.cache.exists(cache_key) 

210 ): 

211 return path, storage_path, cache_key 

212 return None 

213 

214 tasks = [check_cache_path(searchpath) for searchpath in self.searchpath] 

215 results = await asyncio.gather(*tasks, return_exceptions=True) 

216 

217 for result in results: 

218 if isinstance(result, tuple): 

219 return result 

220 return None 

221 

222 

223class LoaderProtocol(t.Protocol): 

224 cache: t.Any 

225 config: t.Any 

226 storage: t.Any 

227 

228 async def get_source_async( 

229 self, 

230 environment_or_template: t.Any, 

231 template: str | AsyncPath | None = None, 

232 ) -> tuple[ 

233 str, 

234 str | None, 

235 t.Callable[[], bool] | t.Callable[[], t.Awaitable[bool]], 

236 ]: ... 

237 

238 async def list_templates_async(self) -> list[str]: ... 

239 

240 

241class FileSystemLoader(BaseTemplateLoader): 

242 async def _check_storage_exists(self, storage_path: AsyncPath) -> bool: 

243 if self.storage is not None: 

244 return await self.storage.templates.exists(storage_path) 

245 return False 

246 

247 async def _sync_template_file( 

248 self, 

249 path: AsyncPath, 

250 storage_path: AsyncPath, 

251 ) -> tuple[bytes, int]: 

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

253 return await self._sync_from_storage_fallback(path, storage_path) 

254 

255 try: 

256 strategy = SyncStrategy( 

257 backup_on_conflict=False, 

258 ) 

259 

260 template_paths = [path] 

261 result = await sync_templates( 

262 template_paths=template_paths, 

263 strategy=strategy, 

264 ) 

265 

266 resp = await path.read_bytes() 

267 local_stat = await path.stat() 

268 local_mtime = int(local_stat.st_mtime) 

269 

270 debug(f"Template sync result: {result.sync_status} for {path}") 

271 return resp, local_mtime 

272 

273 except Exception as e: 

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

275 return await self._sync_from_storage_fallback(path, storage_path) 

276 

277 async def _sync_from_storage_fallback( 

278 self, 

279 path: AsyncPath, 

280 storage_path: AsyncPath, 

281 ) -> tuple[bytes, int]: 

282 local_stat = await path.stat() 

283 local_mtime = int(local_stat.st_mtime) 

284 local_size = local_stat.st_size 

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

286 storage_mtime = round(storage_stat.get("mtime")) 

287 storage_size = storage_stat.get("size") 

288 

289 if local_mtime < storage_mtime and local_size != storage_size: 

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

291 await path.write_bytes(resp) 

292 else: 

293 resp = await path.read_bytes() 

294 if local_size != storage_size: 

295 await self.storage.templates.write(storage_path, resp) 

296 return resp, local_mtime 

297 

298 async def _read_and_store_template( 

299 self, 

300 path: AsyncPath, 

301 storage_path: AsyncPath, 

302 ) -> bytes: 

303 try: 

304 resp = await path.read_bytes() 

305 if self.storage is not None: 

306 try: 

307 import asyncio 

308 

309 await asyncio.wait_for( 

310 self.storage.templates.write(storage_path, resp), timeout=5.0 

311 ) 

312 except (TimeoutError, Exception) as e: 

313 debug( 

314 f"Storage write failed for {storage_path}: {e}, continuing with local file" 

315 ) 

316 return resp 

317 except FileNotFoundError: 

318 raise TemplateNotFound(path.name) 

319 

320 async def _cache_template(self, storage_path: AsyncPath, resp: bytes) -> None: 

321 if self.cache is not None: 

322 await self.cache.set(Templates.get_cache_key(storage_path), resp) 

323 

324 async def get_source_async( 

325 self, 

326 environment_or_template: t.Any, 

327 template: str | AsyncPath | None = None, 

328 ) -> SourceType: 

329 template = self._normalize_template(environment_or_template, template) 

330 path = await self._find_template_path_parallel(template) 

331 if path is None: 

332 raise TemplateNotFound(str(template)) 

333 storage_path = Templates.get_storage_path(path) 

334 debug(path) 

335 

336 fs_exists = await path.exists() 

337 storage_exists = await self._check_storage_exists(storage_path) 

338 local_mtime = 0 

339 

340 if storage_exists and fs_exists and (not self.config.deployed): 

341 resp, local_mtime = await self._sync_template_file(path, storage_path) 

342 else: 

343 resp = await self._read_and_store_template(path, storage_path) 

344 

345 await self._cache_template(storage_path, resp) 

346 

347 async def uptodate() -> bool: 

348 return int((await path.stat()).st_mtime) == local_mtime 

349 

350 return (resp.decode(), str(storage_path), uptodate) 

351 

352 async def list_templates_async(self) -> list[str]: 

353 return await self._list_templates_for_extensions( 

354 self.get_supported_extensions(), 

355 ) 

356 

357 

358class StorageLoader(BaseTemplateLoader): 

359 async def _check_filesystem_sync_opportunity( 

360 self, template: str | AsyncPath, storage_path: AsyncPath 

361 ) -> AsyncPath | None: 

362 if not self.config or self.config.deployed: 

363 return None 

364 

365 fs_result = await self._find_template_path_parallel(template) 

366 if fs_result and await fs_result.exists(): 

367 return fs_result 

368 return None 

369 

370 async def _sync_storage_with_filesystem( 

371 self, 

372 fs_path: AsyncPath, 

373 storage_path: AsyncPath, 

374 ) -> tuple[bytes, int]: 

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

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

377 stat = await self.storage.templates.stat(storage_path) 

378 return resp, round(stat.get("mtime").timestamp()) 

379 

380 try: 

381 strategy = SyncStrategy( 

382 direction=SyncDirection.PULL, 

383 backup_on_conflict=False, 

384 ) 

385 

386 result = await sync_templates( 

387 template_paths=[fs_path], 

388 strategy=strategy, 

389 ) 

390 

391 resp = await fs_path.read_bytes() 

392 local_stat = await fs_path.stat() 

393 local_mtime = int(local_stat.st_mtime) 

394 

395 debug( 

396 f"Storage-filesystem sync result: {result.sync_status} for {storage_path}" 

397 ) 

398 return resp, local_mtime 

399 

400 except Exception as e: 

401 debug( 

402 f"Storage sync failed for {storage_path}: {e}, reading from storage only" 

403 ) 

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

405 stat = await self.storage.templates.stat(storage_path) 

406 return resp, round(stat.get("mtime").timestamp()) 

407 

408 async def get_source_async( 

409 self, 

410 environment_or_template: t.Any, 

411 template: str | AsyncPath | None = None, 

412 ) -> tuple[str, str, t.Callable[[], t.Awaitable[bool]]]: 

413 template = self._normalize_template(environment_or_template, template) 

414 result = await self._find_storage_path_parallel(template) 

415 if result is None: 

416 raise TemplateNotFound(str(template)) 

417 _, storage_path = result 

418 debug(storage_path) 

419 

420 try: 

421 fs_path = await self._check_filesystem_sync_opportunity( 

422 template, storage_path 

423 ) 

424 

425 if fs_path: 

426 resp, local_mtime = await self._sync_storage_with_filesystem( 

427 fs_path, storage_path 

428 ) 

429 else: 

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

431 local_stat = await self.storage.templates.stat(storage_path) 

432 local_mtime = round(local_stat.get("mtime").timestamp()) 

433 

434 if self.cache is not None: 

435 await self.cache.set(Templates.get_cache_key(storage_path), resp) 

436 

437 async def uptodate() -> bool: 

438 if fs_path and await fs_path.exists(): 

439 fs_stat = await fs_path.stat() 

440 return int(fs_stat.st_mtime) == local_mtime 

441 else: 

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

443 return round(storage_stat.get("mtime").timestamp()) == local_mtime 

444 

445 return (resp.decode(), str(storage_path), uptodate) 

446 except (FileNotFoundError, AttributeError): 

447 raise TemplateNotFound(str(template)) 

448 

449 async def list_templates_async(self) -> list[str]: 

450 found: list[str] = [] 

451 for searchpath in self.searchpath: 

452 with suppress(FileNotFoundError): 

453 paths = await self.storage.templates.list( 

454 Templates.get_storage_path(searchpath), 

455 ) 

456 found.extend(p for p in paths if p.endswith((".html", ".css", ".js"))) 

457 found.sort() 

458 return found 

459 

460 

461class RedisLoader(BaseTemplateLoader): 

462 async def get_source_async( 

463 self, 

464 environment_or_template: t.Any, 

465 template: str | AsyncPath | None = None, 

466 ) -> tuple[str, str | None, t.Callable[[], t.Awaitable[bool]]]: 

467 template = self._normalize_template(environment_or_template, template) 

468 result = await self._find_cache_path_parallel(template) 

469 if result is None: 

470 raise TemplateNotFound(str(template)) 

471 path, _, cache_key = result 

472 debug(cache_key) 

473 resp = await self.cache.get(cache_key) if self.cache is not None else None 

474 if not resp: 

475 raise TemplateNotFound(path.name) 

476 

477 async def uptodate() -> bool: 

478 return True 

479 

480 return (resp.decode(), None, uptodate) 

481 

482 async def list_templates_async(self) -> list[str]: 

483 found: list[str] = [] 

484 for ext in ("html", "css", "js"): 

485 scan_result = ( 

486 await self.cache.scan(f"*.{ext}") if self.cache is not None else [] 

487 ) 

488 if hasattr(scan_result, "__aiter__"): 

489 async for k in scan_result: # type: ignore 

490 found.append(k) 

491 else: 

492 found.extend(scan_result) 

493 found.sort() 

494 return found 

495 

496 

497class PackageLoader(BaseTemplateLoader): 

498 _template_root: AsyncPath 

499 _adapter: str 

500 package_name: str 

501 _loader: t.Any 

502 

503 def __init__( 

504 self, 

505 package_name: str, 

506 path: str = "templates", 

507 adapter: str = "admin", 

508 ) -> None: 

509 self.package_path = Path(package_name) 

510 self.path = self.package_path / path 

511 super().__init__(AsyncPath(self.path)) 

512 self.package_name = package_name 

513 self._adapter = adapter 

514 self._template_root = AsyncPath(".") 

515 try: 

516 if package_name.startswith("/"): 

517 spec = None 

518 self._loader = None 

519 return 

520 import_module(package_name) 

521 spec = find_spec(package_name) 

522 if spec is None: 

523 msg = f"Could not find package {package_name}" 

524 raise ImportError(msg) 

525 except ModuleNotFoundError: 

526 spec = None 

527 self._loader = None 

528 return 

529 roots: list[Path] = [] 

530 template_root = None 

531 loader = spec.loader 

532 self._loader = loader 

533 if spec.submodule_search_locations: 

534 roots.extend(Path(s) for s in spec.submodule_search_locations) 

535 elif spec.origin is not None: 

536 roots.append(Path(spec.origin)) 

537 for root in roots: 

538 root = root / path 

539 if root.is_dir(): 

540 template_root = root 

541 break 

542 if template_root is None: 

543 msg = f"The {package_name!r} package was not installed in a way that PackageLoader understands." 

544 raise ValueError( 

545 msg, 

546 ) 

547 self._template_root = AsyncPath(template_root) 

548 

549 async def get_source_async( 

550 self, 

551 environment_or_template: t.Any, 

552 template: str | AsyncPath | None = None, 

553 ) -> tuple[str, str, t.Callable[[], t.Awaitable[bool]]]: 

554 if template is None: 

555 template = environment_or_template 

556 assert template is not None 

557 template_path: AsyncPath = AsyncPath(template) 

558 path = self._template_root / template_path 

559 debug(path) 

560 if not await path.is_file(): 

561 raise TemplateNotFound(template_path.name) 

562 source = await path.read_bytes() 

563 mtime = (await path.stat()).st_mtime 

564 

565 async def uptodate() -> bool: 

566 return await path.is_file() and (await path.stat()).st_mtime == mtime 

567 

568 source = _apply_template_replacements(source, self.config.deployed) 

569 storage_path = Templates.get_storage_path(path) 

570 _storage_path: list[str] = list(storage_path.parts) 

571 _storage_path[0] = "_templates" 

572 _storage_path.insert(1, self._adapter) 

573 _storage_path.insert(2, getattr(self.config, self._adapter).style) 

574 storage_path = AsyncPath("/".join(_storage_path)) 

575 cache_key = Templates.get_cache_key(storage_path) 

576 if self.cache is not None: 

577 await self.cache.set(cache_key, source) 

578 return (source.decode(), path.name, uptodate) 

579 

580 async def list_templates_async(self) -> list[str]: 

581 found: set[str] = set() 

582 for ext in ("html", "css", "js"): 

583 found.update([str(p) async for p in self._template_root.rglob(f"*.{ext}")]) 

584 return sorted(found) 

585 

586 

587class ChoiceLoader(AsyncBaseLoader): 

588 loaders: list[AsyncBaseLoader | LoaderProtocol] 

589 

590 def __init__( 

591 self, 

592 loaders: list[AsyncBaseLoader | LoaderProtocol], 

593 searchpath: AsyncPath | t.Sequence[AsyncPath] | None = None, 

594 ) -> None: 

595 super().__init__(searchpath or AsyncPath("templates")) 

596 self.loaders = loaders 

597 

598 async def get_source_async( 

599 self, 

600 environment_or_template: t.Any, 

601 template: str | AsyncPath | None = None, 

602 ) -> SourceType: 

603 if template is None: 

604 template = environment_or_template 

605 assert template is not None 

606 for loader in self.loaders: 

607 try: 

608 result = await loader.get_source_async( 

609 environment_or_template, template 

610 ) 

611 return result 

612 except TemplateNotFound: 

613 continue 

614 except Exception: # nosec B112 

615 continue 

616 raise TemplateNotFound(str(template)) 

617 

618 async def list_templates_async(self) -> list[str]: 

619 found: set[str] = set() 

620 for loader in self.loaders: 

621 templates = await loader.list_templates_async() 

622 found.update(templates) 

623 return sorted(found) 

624 

625 

626class TemplatesSettings(TemplatesBaseSettings): 

627 loader: str | None = None 

628 extensions: list[str] = [] 

629 delimiters: dict[str, str] = { 

630 "block_start_string": "[%", 

631 "block_end_string": "%]", 

632 "variable_start_string": "[[", 

633 "variable_end_string": "]]", 

634 "comment_start_string": "[#", 

635 "comment_end_string": "#]", 

636 } 

637 globals: dict[str, t.Any] = {} 

638 context_processors: list[str] = [] 

639 

640 def __init__(self, **data: t.Any) -> None: 

641 from pydantic import BaseModel 

642 

643 BaseModel.__init__(self, **data) 

644 if not hasattr(self, "cache_timeout"): 

645 self.cache_timeout = 300 

646 try: 

647 models = depends.get("models") 

648 self.globals["models"] = models 

649 except Exception: 

650 self.globals["models"] = None 

651 

652 

653class Templates(TemplatesBase): 

654 app: AsyncJinja2Templates | None = None 

655 

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

657 super().__init__(**kwargs) 

658 self.filters: dict[str, t.Callable[..., t.Any]] = {} 

659 self.enabled_admin = get_adapter("admin") 

660 self.enabled_app = self._get_app_adapter() 

661 self._admin = None 

662 self._admin_initialized = False 

663 

664 def _get_app_adapter(self) -> t.Any: 

665 app_adapter = get_adapter("app") 

666 if app_adapter is not None: 

667 return app_adapter 

668 with suppress(Exception): 

669 app_adapter = depends.get("app") 

670 if app_adapter is not None: 

671 return app_adapter 

672 

673 return None 

674 

675 @property 

676 def admin(self) -> AsyncJinja2Templates | None: 

677 if not self._admin_initialized and self.enabled_admin: 

678 import asyncio 

679 

680 loop = asyncio.get_event_loop() 

681 if hasattr(self, "_admin_cache") and hasattr(self, "admin_searchpaths"): 

682 debug("Initializing admin templates environment") 

683 self._admin = loop.run_until_complete( 

684 self.init_envs( 

685 self.admin_searchpaths, 

686 admin=True, 

687 cache=self._admin_cache, 

688 ), 

689 ) 

690 else: 

691 debug( 

692 "Skipping admin templates initialization - missing cache or searchpaths" 

693 ) 

694 self._admin_initialized = True 

695 return self._admin 

696 

697 @admin.setter 

698 def admin(self, value: AsyncJinja2Templates | None) -> None: 

699 self._admin = value 

700 self._admin_initialized = True 

701 

702 def get_loader(self, template_paths: list[AsyncPath]) -> ChoiceLoader: 

703 searchpaths: list[AsyncPath] = [] 

704 for path in template_paths: 

705 searchpaths.extend([path, path / "blocks"]) 

706 loaders: list[AsyncBaseLoader] = [ 

707 RedisLoader(searchpaths), 

708 StorageLoader(searchpaths), 

709 ] 

710 file_loaders: list[AsyncBaseLoader | LoaderProtocol] = [ 

711 FileSystemLoader(searchpaths), 

712 ] 

713 jinja_loaders: list[AsyncBaseLoader | LoaderProtocol] = loaders + file_loaders 

714 if not self.config.deployed and (not self.config.debug.production): 

715 jinja_loaders = file_loaders + loaders 

716 if self.enabled_admin and template_paths == self.admin_searchpaths: 

717 jinja_loaders.append( 

718 PackageLoader(self.enabled_admin.name, "templates", "admin"), 

719 ) 

720 debug(jinja_loaders) 

721 return ChoiceLoader(jinja_loaders) 

722 

723 @depends.inject 

724 async def init_envs( 

725 self, 

726 template_paths: list[AsyncPath], 

727 admin: bool = False, 

728 cache: t.Any | None = None, 

729 ) -> AsyncJinja2Templates: 

730 _extensions: list[t.Any] = [loopcontrols, i18n, jinja_debug] 

731 _imported_extensions = [ 

732 import_module(e) for e in self.config.templates.extensions 

733 ] 

734 for e in _imported_extensions: 

735 _extensions.extend( 

736 [ 

737 v 

738 for v in vars(e).values() 

739 if isclass(v) 

740 and v.__name__ != "Extension" 

741 and issubclass(v, Extension) 

742 ], 

743 ) 

744 bytecode_cache = AsyncRedisBytecodeCache(prefix="bccache", client=cache) 

745 context_processors: list[t.Callable[..., t.Any]] = [] 

746 for processor_path in self.config.templates.context_processors: 

747 module_path, func_name = processor_path.rsplit(".", 1) 

748 module = import_module(module_path) 

749 processor = getattr(module, func_name) 

750 context_processors.append(processor) 

751 templates = AsyncJinja2Templates( 

752 directory=AsyncPath("templates"), 

753 context_processors=context_processors, 

754 extensions=_extensions, 

755 bytecode_cache=bytecode_cache, 

756 enable_async=True, 

757 ) 

758 loader = self.get_loader(template_paths) 

759 if loader: 

760 templates.env.loader = loader 

761 elif self.config.templates.loader: 

762 templates.env.loader = literal_eval(self.config.templates.loader) 

763 for delimiter, value in self.config.templates.delimiters.items(): 

764 setattr(templates.env, delimiter, value) 

765 templates.env.globals["config"] = self.config # type: ignore[assignment] 

766 templates.env.globals["render_block"] = templates.render_block # type: ignore[assignment] 

767 templates.env.globals["render_component"] = self._get_htmy_component_renderer() # type: ignore[assignment] 

768 if admin: 

769 try: 

770 from sqladmin.helpers import ( # type: ignore[import-not-found,import-untyped] 

771 get_object_identifier, 

772 ) 

773 except ImportError: 

774 get_object_identifier = str 

775 templates.env.globals["min"] = min # type: ignore[assignment] 

776 templates.env.globals["zip"] = zip # type: ignore[assignment] 

777 templates.env.globals["admin"] = self # type: ignore[assignment] 

778 templates.env.globals["is_list"] = lambda x: isinstance(x, list) # type: ignore[assignment] 

779 templates.env.globals["get_object_identifier"] = get_object_identifier # type: ignore[assignment] 

780 for k, v in self.config.templates.globals.items(): 

781 templates.env.globals[k] = v # type: ignore[assignment] 

782 return templates 

783 

784 def _resolve_cache(self, cache: t.Any | None) -> t.Any | None: 

785 if cache is None: 

786 try: 

787 cache = depends.get("cache") 

788 except Exception: 

789 cache = None 

790 return cache 

791 

792 async def _setup_admin_templates(self, cache: t.Any | None) -> None: 

793 if self.enabled_admin: 

794 self.admin_searchpaths = await self.get_searchpaths(self.enabled_admin) 

795 self._admin_cache = cache 

796 

797 def _log_loader_info(self) -> None: 

798 if self.app and self.app.env.loader and hasattr(self.app.env.loader, "loaders"): 

799 for loader in self.app.env.loader.loaders: 

800 self.logger.debug(f"{loader.__class__.__name__} initialized") 

801 

802 def _log_extension_info(self) -> None: 

803 if self.app and hasattr(self.app.env, "extensions"): 

804 for ext in self.app.env.extensions: 

805 self.logger.debug(f"{ext.split('.')[-1]} loaded") 

806 

807 async def _clear_debug_cache(self, cache: t.Any | None) -> None: 

808 if getattr(self.config.debug, "templates", False): 

809 try: 

810 for namespace in ( 

811 "templates", 

812 "_templates", 

813 "bccache", 

814 "template", 

815 "test", 

816 ): 

817 await cache.clear(namespace) 

818 self.logger.debug("Template caches cleared") 

819 with suppress(Exception): 

820 htmy_adapter = depends.get("htmy") 

821 if htmy_adapter: 

822 await htmy_adapter.clear_component_cache() 

823 self.logger.debug("HTMY component caches cleared via adapter") 

824 except (NotImplementedError, AttributeError) as e: 

825 self.logger.debug(f"Cache clear not supported: {e}") 

826 

827 def _get_htmy_component_renderer(self) -> t.Callable[..., t.Any]: 

828 async def render_component( 

829 component_name: str, 

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

831 **kwargs: t.Any, 

832 ) -> str: 

833 try: 

834 htmy_adapter = depends.get("htmy") 

835 if htmy_adapter: 

836 htmy_adapter.jinja_templates = self 

837 

838 response = await htmy_adapter.render_component( 

839 request=None, 

840 component=component_name, 

841 context=context, 

842 **kwargs, 

843 ) 

844 return ( 

845 response.body.decode() 

846 if hasattr(response.body, "decode") 

847 else str(response.body) 

848 ) 

849 else: 

850 debug( 

851 f"HTMY adapter not available for component '{component_name}'" 

852 ) 

853 return f"<!-- HTMY adapter not available for '{component_name}' -->" 

854 except Exception as e: 

855 debug( 

856 f"Failed to render component '{component_name}' via HTMY adapter: {e}" 

857 ) 

858 return f"<!-- Error rendering component '{component_name}': {e} -->" 

859 

860 return render_component 

861 

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

863 cache = self._resolve_cache(cache) 

864 app_adapter = self.enabled_app 

865 if app_adapter is None: 

866 try: 

867 app_adapter = depends.get("app") 

868 debug("Retrieved app adapter from dependency injection") 

869 except Exception: 

870 try: 

871 from ..app.default import App 

872 

873 app_adapter = depends.get("app") or App() 

874 debug("Created app adapter by direct import") 

875 depends.set("app", app_adapter) 

876 except Exception: 

877 from types import SimpleNamespace 

878 

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

880 debug( 

881 "Created fallback app adapter - ACB discovery failed, direct import failed" 

882 ) 

883 self.app_searchpaths = await self.get_searchpaths(app_adapter) 

884 self.app = await self.init_envs(self.app_searchpaths, cache=cache) 

885 depends.set("templates", self) 

886 self._admin = None 

887 self._admin_initialized = False 

888 await self._setup_admin_templates(cache) 

889 self._log_loader_info() 

890 self._log_extension_info() 

891 await self._clear_debug_cache(cache) 

892 

893 @staticmethod 

894 def get_attr(html: str, attr: str) -> str | None: 

895 parser = HTMLParser() 

896 parser.feed(html) 

897 soup = parser.get_starttag_text() 

898 attr_pattern = _get_attr_pattern(attr) 

899 _attr = f"{attr}=" 

900 for s in soup.split(): 

901 if attr_pattern.search(s): 

902 return s.replace(_attr, "").strip('"') 

903 return None 

904 

905 def _add_filters(self, env: t.Any) -> None: 

906 if hasattr(self, "filters") and self.filters: 

907 for name, filter_func in self.filters.items(): 

908 if hasattr(env, "add_filter"): 

909 env.add_filter(filter_func, name) 

910 else: 

911 env.filters[name] = filter_func 

912 

913 async def render_template( 

914 self, 

915 request: t.Any, 

916 template: str, 

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

918 status_code: int = 200, 

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

920 ) -> t.Any: 

921 if context is None: 

922 context = {} 

923 if headers is None: 

924 headers = {} 

925 

926 templates_env = self.app 

927 if templates_env: 

928 return await templates_env.TemplateResponse( 

929 request=request, 

930 name=template, 

931 context=context, 

932 status_code=status_code, 

933 headers=headers, 

934 ) 

935 from starlette.responses import HTMLResponse 

936 

937 return HTMLResponse( 

938 content=f"<html><body>Template {template} not found</body></html>", 

939 status_code=404, 

940 headers=headers, 

941 ) 

942 

943 async def render_component( 

944 self, 

945 request: t.Any, 

946 component: str, 

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

948 status_code: int = 200, 

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

950 **kwargs: t.Any, 

951 ) -> t.Any: 

952 try: 

953 htmy_adapter = depends.get("htmy") 

954 if htmy_adapter: 

955 htmy_adapter.jinja_templates = self 

956 return await htmy_adapter.render_component( 

957 request=request, 

958 component=component, 

959 context=context, 

960 status_code=status_code, 

961 headers=headers, 

962 **kwargs, 

963 ) 

964 else: 

965 from starlette.responses import HTMLResponse 

966 

967 return HTMLResponse( 

968 content=f"<html><body>HTMY adapter not available for component '{component}'</body></html>", 

969 status_code=500, 

970 headers=headers, 

971 ) 

972 except Exception as e: 

973 from starlette.responses import HTMLResponse 

974 

975 return HTMLResponse( 

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

977 status_code=500, 

978 headers=headers, 

979 ) 

980 

981 def filter( 

982 self, 

983 name: str | None = None, 

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

985 def decorator(f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: 

986 if self.app and hasattr(self.app.env, "filters"): 

987 self.app.env.filters[name or f.__name__] = f 

988 if self.admin and hasattr(self.admin.env, "filters"): 

989 self.admin.env.filters[name or f.__name__] = f 

990 return f 

991 

992 return decorator 

993 

994 def _load_extensions(self) -> list[t.Any]: 

995 _extensions: list[t.Any] = [loopcontrols, i18n, jinja_debug] 

996 extensions_list = getattr( 

997 getattr(self, "settings", None), 

998 "extensions", 

999 self.config.templates.extensions, 

1000 ) 

1001 _imported_extensions = [import_module(e) for e in extensions_list] 

1002 for e in _imported_extensions: 

1003 _extensions.extend( 

1004 [ 

1005 v 

1006 for v in vars(e).values() 

1007 if isclass(v) 

1008 and v.__name__ != "Extension" 

1009 and issubclass(v, Extension) 

1010 ], 

1011 ) 

1012 return _extensions 

1013 

1014 

1015MODULE_ID = UUID("01937d86-4f2a-7b3c-8d9e-1234567890ab") 

1016MODULE_STATUS = AdapterStatus.STABLE 

1017 

1018with suppress(Exception): 

1019 depends.set(Templates)