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

605 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 00:47 -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 Inject, 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 

51 

52# Import event tracking decorator (with fallback if unavailable) 

53try: 

54 from ._events_wrapper import track_template_render 

55except ImportError: 

56 # Fallback no-op decorator if events integration unavailable 

57 def track_template_render(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: 

58 return func 

59 

60 

61from acb.depends import depends 

62from anyio import Path as AsyncPath 

63from jinja2 import TemplateNotFound 

64from jinja2.ext import Extension, i18n, loopcontrols 

65from jinja2.ext import debug as jinja_debug 

66from jinja2_async_environment.bccache import AsyncRedisBytecodeCache 

67from jinja2_async_environment.loaders import AsyncBaseLoader, SourceType 

68from starlette_async_jinja import AsyncJinja2Templates 

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

70from fastblocks.actions.sync.templates import sync_templates 

71 

72from ._base import TemplatesBase, TemplatesBaseSettings 

73 

74try: 

75 Cache, Storage, Models = import_adapter() 

76except Exception: 

77 Cache = Storage = Models = None 

78 

79_TEMPLATE_REPLACEMENTS = [ 

80 (b"{{", b"[["), 

81 (b"}}", b"]]"), 

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

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

84] 

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

86 

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

88 

89 

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

91 if attr not in _ATTR_PATTERN_CACHE: 

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

93 _ATTR_PATTERN_CACHE[attr] = re.compile( 

94 escaped_attr 

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

96 return _ATTR_PATTERN_CACHE[attr] 

97 

98 

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

100 for old_pattern, new_pattern in _TEMPLATE_REPLACEMENTS: 

101 source = source.replace(old_pattern, new_pattern) 

102 if deployed: 

103 source = source.replace(*_HTTP_TO_HTTPS) 

104 

105 return source 

106 

107 

108class BaseTemplateLoader(AsyncBaseLoader): # type: ignore[misc] 

109 config: t.Any = None 

110 cache: t.Any = None 

111 storage: t.Any = None 

112 

113 def __init__( 

114 self, 

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

116 ) -> None: 

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

118 if self.storage is None: 

119 try: 

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

121 except Exception: 

122 self.storage = get_adapter("storage") 

123 if self.cache is None: 

124 try: 

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

126 except Exception: 

127 self.cache = get_adapter("cache") 

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

129 try: 

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

131 except Exception: 

132 config_adapter = get_adapter("config") 

133 self.config = ( 

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

135 ) 

136 

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

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

139 

140 async def _list_templates_for_extensions( 

141 self, 

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

143 ) -> list[str]: 

144 found: set[str] = set() 

145 for searchpath in self.searchpath: 

146 for ext in extensions: 

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

148 found.add(str(p)) 

149 return sorted(found) 

150 

151 def _normalize_template( 

152 self, 

153 environment_or_template: t.Any, 

154 template: str | AsyncPath | None = None, 

155 ) -> str | AsyncPath: 

156 if template is None: 

157 template = environment_or_template 

158 assert template is not None 

159 return template 

160 

161 async def _find_template_path_parallel( 

162 self, 

163 template: str | AsyncPath, 

164 ) -> AsyncPath | None: 

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

166 path = searchpath / template 

167 if await path.is_file(): 

168 return path 

169 return None 

170 

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

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

173 

174 for result in results: 

175 if isinstance(result, AsyncPath): 

176 return result 

177 return None 

178 

179 async def _find_storage_path_parallel( 

180 self, 

181 template: str | AsyncPath, 

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

183 async def check_storage_path( 

184 searchpath: AsyncPath, 

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

186 path = searchpath / template 

187 storage_path = Templates.get_storage_path(path) 

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

189 return path, storage_path 

190 return None 

191 

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

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

194 

195 for result in results: 

196 if isinstance(result, tuple): 

197 return result 

198 return None 

199 

200 async def _find_cache_path_parallel( 

201 self, 

202 template: str | AsyncPath, 

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

204 async def check_cache_path( 

205 searchpath: AsyncPath, 

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

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

208 storage_path = Templates.get_storage_path(path) 

209 cache_key = Templates.get_cache_key(storage_path) 

210 if ( 

211 storage_path 

212 and self.cache is not None 

213 and await self.cache.exists(cache_key) 

214 ): 

215 return path, storage_path, cache_key 

216 return None 

217 

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

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

220 

221 for result in results: 

222 if isinstance(result, tuple): 

223 return result 

224 return None 

225 

226 

227class LoaderProtocol(t.Protocol): 

228 cache: t.Any 

229 config: t.Any 

230 storage: t.Any 

231 

232 async def get_source_async( 

233 self, 

234 environment_or_template: t.Any, 

235 template: str | AsyncPath | None = None, 

236 ) -> tuple[ 

237 str, 

238 str | None, 

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

240 ]: ... 

241 

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

243 

244 

245class FileSystemLoader(BaseTemplateLoader): 

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

247 if self.storage is not None: 

248 return t.cast(bool, await self.storage.templates.exists(storage_path)) 

249 return False 

250 

251 async def _sync_template_file( 

252 self, 

253 path: AsyncPath, 

254 storage_path: AsyncPath, 

255 ) -> tuple[bytes, int]: 

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

257 return await self._sync_from_storage_fallback(path, storage_path) 

258 

259 try: 

260 strategy = SyncStrategy( 

261 backup_on_conflict=False, 

262 ) 

263 

264 template_paths = [path] 

265 result = await sync_templates( 

266 template_paths=template_paths, 

267 strategy=strategy, 

268 ) 

269 

270 resp = await path.read_bytes() 

271 local_stat = await path.stat() 

272 local_mtime = int(local_stat.st_mtime) 

273 

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

275 return resp, local_mtime 

276 

277 except Exception as e: 

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

279 return await self._sync_from_storage_fallback(path, storage_path) 

280 

281 async def _sync_from_storage_fallback( 

282 self, 

283 path: AsyncPath, 

284 storage_path: AsyncPath, 

285 ) -> tuple[bytes, int]: 

286 local_stat = await path.stat() 

287 local_mtime = int(local_stat.st_mtime) 

288 local_size = local_stat.st_size 

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

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

291 storage_size = storage_stat.get("size") 

292 

293 if local_mtime < storage_mtime and local_size != storage_size: 

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

295 await path.write_bytes(resp) 

296 else: 

297 resp = await path.read_bytes() 

298 if local_size != storage_size: 

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

300 return resp, local_mtime 

301 

302 async def _read_and_store_template( 

303 self, 

304 path: AsyncPath, 

305 storage_path: AsyncPath, 

306 ) -> bytes: 

307 try: 

308 resp = await path.read_bytes() 

309 if self.storage is not None: 

310 try: 

311 import asyncio 

312 

313 await asyncio.wait_for( 

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

315 ) 

316 except (TimeoutError, Exception) as e: 

317 debug( 

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

319 ) 

320 return resp 

321 except FileNotFoundError: 

322 raise TemplateNotFound(path.name) 

323 

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

325 if self.cache is not None: 

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

327 

328 async def get_source_async( 

329 self, 

330 environment_or_template: t.Any, 

331 template: str | AsyncPath | None = None, 

332 ) -> SourceType: 

333 template = self._normalize_template(environment_or_template, template) 

334 path = await self._find_template_path_parallel(template) 

335 if path is None: 

336 raise TemplateNotFound(str(template)) 

337 storage_path = Templates.get_storage_path(path) 

338 debug(path) 

339 

340 fs_exists = await path.exists() 

341 storage_exists = await self._check_storage_exists(storage_path) 

342 local_mtime = 0 

343 

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

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

346 else: 

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

348 

349 await self._cache_template(storage_path, resp) 

350 

351 async def uptodate() -> bool: 

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

353 

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

355 

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

357 return await self._list_templates_for_extensions( 

358 self.get_supported_extensions(), 

359 ) 

360 

361 

362class StorageLoader(BaseTemplateLoader): 

363 async def _check_filesystem_sync_opportunity( 

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

365 ) -> AsyncPath | None: 

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

367 return None 

368 

369 fs_result = await self._find_template_path_parallel(template) 

370 if fs_result and await fs_result.exists(): 

371 return fs_result 

372 return None 

373 

374 async def _sync_storage_with_filesystem( 

375 self, 

376 fs_path: AsyncPath, 

377 storage_path: AsyncPath, 

378 ) -> tuple[bytes, int]: 

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

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

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

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

383 

384 try: 

385 strategy = SyncStrategy( 

386 direction=SyncDirection.PULL, 

387 backup_on_conflict=False, 

388 ) 

389 

390 result = await sync_templates( 

391 template_paths=[fs_path], 

392 strategy=strategy, 

393 ) 

394 

395 resp = await fs_path.read_bytes() 

396 local_stat = await fs_path.stat() 

397 local_mtime = int(local_stat.st_mtime) 

398 

399 debug( 

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

401 ) 

402 return resp, local_mtime 

403 

404 except Exception as e: 

405 debug( 

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

407 ) 

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

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

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

411 

412 async def get_source_async( 

413 self, 

414 environment_or_template: t.Any, 

415 template: str | AsyncPath | None = None, 

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

417 template = self._normalize_template(environment_or_template, template) 

418 result = await self._find_storage_path_parallel(template) 

419 if result is None: 

420 raise TemplateNotFound(str(template)) 

421 _, storage_path = result 

422 debug(storage_path) 

423 

424 try: 

425 fs_path = await self._check_filesystem_sync_opportunity( 

426 template, storage_path 

427 ) 

428 

429 if fs_path: 

430 resp, local_mtime = await self._sync_storage_with_filesystem( 

431 fs_path, storage_path 

432 ) 

433 else: 

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

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

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

437 

438 if self.cache is not None: 

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

440 

441 async def uptodate() -> bool: 

442 if fs_path and await fs_path.exists(): 

443 fs_stat = await fs_path.stat() 

444 return int(fs_stat.st_mtime) == local_mtime 

445 else: 

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

447 mtime = storage_stat.get("mtime") 

448 if hasattr(mtime, "timestamp"): 

449 timestamp = t.cast(float, mtime.timestamp()) 

450 return round(timestamp) == local_mtime 

451 return False 

452 

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

454 except (FileNotFoundError, AttributeError): 

455 raise TemplateNotFound(str(template)) 

456 

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

458 found: list[str] = [] 

459 for searchpath in self.searchpath: 

460 with suppress(FileNotFoundError): 

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

462 Templates.get_storage_path(searchpath), 

463 ) 

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

465 found.sort() 

466 return found 

467 

468 

469class RedisLoader(BaseTemplateLoader): 

470 async def get_source_async( 

471 self, 

472 environment_or_template: t.Any, 

473 template: str | AsyncPath | None = None, 

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

475 template = self._normalize_template(environment_or_template, template) 

476 result = await self._find_cache_path_parallel(template) 

477 if result is None: 

478 raise TemplateNotFound(str(template)) 

479 path, _, cache_key = result 

480 debug(cache_key) 

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

482 if not resp: 

483 raise TemplateNotFound(path.name) 

484 

485 async def uptodate() -> bool: 

486 return True 

487 

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

489 

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

491 found: list[str] = [] 

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

493 scan_result: t.Any = ( 

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

495 ) 

496 if hasattr(scan_result, "__aiter__"): 

497 async for k in scan_result: # type: ignore 

498 found.append(k) 

499 else: 

500 found.extend(scan_result) 

501 found.sort() 

502 return found 

503 

504 

505class PackageLoader(BaseTemplateLoader): 

506 _template_root: AsyncPath 

507 _adapter: str 

508 package_name: str 

509 _loader: t.Any 

510 

511 def __init__( 

512 self, 

513 package_name: str, 

514 path: str = "templates", 

515 adapter: str = "admin", 

516 ) -> None: 

517 self.package_path = Path(package_name) 

518 self.path = self.package_path / path 

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

520 self.package_name = package_name 

521 self._adapter = adapter 

522 self._template_root = AsyncPath(".") 

523 try: 

524 if package_name.startswith("/"): 

525 spec = None 

526 self._loader = None 

527 return 

528 import_module(package_name) 

529 spec = find_spec(package_name) 

530 if spec is None: 

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

532 raise ImportError(msg) 

533 except ModuleNotFoundError: 

534 spec = None 

535 self._loader = None 

536 return 

537 roots: list[Path] = [] 

538 template_root = None 

539 loader = spec.loader 

540 self._loader = loader 

541 if spec.submodule_search_locations: 

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

543 elif spec.origin is not None: 

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

545 for root in roots: 

546 root = root / path 

547 if root.is_dir(): 

548 template_root = root 

549 break 

550 if template_root is None: 

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

552 raise ValueError( 

553 msg, 

554 ) 

555 self._template_root = AsyncPath(template_root) 

556 

557 async def get_source_async( 

558 self, 

559 environment_or_template: t.Any, 

560 template: str | AsyncPath | None = None, 

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

562 if template is None: 

563 template = environment_or_template 

564 assert template is not None 

565 template_path: AsyncPath = AsyncPath(template) 

566 path = self._template_root / template_path 

567 debug(path) 

568 if not await path.is_file(): 

569 raise TemplateNotFound(template_path.name) 

570 source = await path.read_bytes() 

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

572 

573 async def uptodate() -> bool: 

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

575 

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

577 storage_path = Templates.get_storage_path(path) 

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

579 _storage_path[0] = "_templates" 

580 _storage_path.insert(1, self._adapter) 

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

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

583 cache_key = Templates.get_cache_key(storage_path) 

584 if self.cache is not None: 

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

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

587 

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

589 found: set[str] = set() 

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

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

592 return sorted(found) 

593 

594 

595class ChoiceLoader(AsyncBaseLoader): # type: ignore[misc] 

596 loaders: list[AsyncBaseLoader | LoaderProtocol] 

597 

598 def __init__( 

599 self, 

600 loaders: list[AsyncBaseLoader | LoaderProtocol], 

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

602 ) -> None: 

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

604 self.loaders = loaders 

605 

606 async def get_source_async( 

607 self, 

608 environment_or_template: t.Any, 

609 template: str | AsyncPath | None = None, 

610 ) -> SourceType: 

611 if template is None: 

612 template = environment_or_template 

613 assert template is not None 

614 for loader in self.loaders: 

615 try: 

616 result = await loader.get_source_async( 

617 environment_or_template, template 

618 ) 

619 return result 

620 except TemplateNotFound: 

621 continue 

622 except Exception: # nosec B112 

623 continue 

624 raise TemplateNotFound(str(template)) 

625 

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

627 found: set[str] = set() 

628 for loader in self.loaders: 

629 templates = await loader.list_templates_async() 

630 found.update(templates) 

631 return sorted(found) 

632 

633 

634class TemplatesSettings(TemplatesBaseSettings): 

635 loader: str | None = None 

636 extensions: list[str] = [] 

637 delimiters: dict[str, str] = { 

638 "block_start_string": "[%", 

639 "block_end_string": "%]", 

640 "variable_start_string": "[[", 

641 "variable_end_string": "]]", 

642 "comment_start_string": "[#", 

643 "comment_end_string": "#]", 

644 } 

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

646 context_processors: list[str] = [] 

647 

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

649 from pydantic import BaseModel 

650 

651 BaseModel.__init__(self, **data) # type: ignore[arg-type] 

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

653 self.cache_timeout = 300 

654 try: 

655 models = depends.get("models") 

656 self.globals["models"] = models 

657 except Exception: 

658 self.globals["models"] = None 

659 

660 

661class Templates(TemplatesBase): 

662 app: AsyncJinja2Templates | None = None 

663 

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

665 super().__init__(**kwargs) 

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

667 self.enabled_admin = get_adapter("admin") 

668 self.enabled_app = self._get_app_adapter() 

669 self._admin = None 

670 self._admin_initialized = False 

671 

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

673 app_adapter = get_adapter("app") 

674 if app_adapter is not None: 

675 return app_adapter 

676 with suppress(Exception): 

677 app_adapter = depends.get("app") 

678 if app_adapter is not None: 

679 return app_adapter 

680 

681 return None 

682 

683 @property 

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

685 if not self._admin_initialized and self.enabled_admin: 

686 import asyncio 

687 

688 loop = asyncio.get_event_loop() 

689 if ( 

690 hasattr(self, "_admin_cache") 

691 and hasattr(self, "admin_searchpaths") 

692 and self.admin_searchpaths is not None 

693 ): 

694 debug("Initializing admin templates environment") 

695 self._admin = loop.run_until_complete( 

696 self.init_envs( 

697 self.admin_searchpaths, 

698 admin=True, 

699 cache=self._admin_cache, 

700 ), 

701 ) 

702 else: 

703 debug( 

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

705 ) 

706 self._admin_initialized = True 

707 return self._admin 

708 

709 @admin.setter 

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

711 self._admin = value 

712 self._admin_initialized = True 

713 

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

715 searchpaths: list[AsyncPath] = [] 

716 for path in template_paths: 

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

718 loaders: list[AsyncBaseLoader] = [ 

719 RedisLoader(searchpaths), 

720 StorageLoader(searchpaths), 

721 ] 

722 file_loaders: list[AsyncBaseLoader | LoaderProtocol] = [ 

723 FileSystemLoader(searchpaths), 

724 ] 

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

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

727 jinja_loaders = file_loaders + loaders 

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

729 jinja_loaders.append( 

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

731 ) 

732 debug(jinja_loaders) 

733 return ChoiceLoader(jinja_loaders) 

734 

735 async def init_envs( 

736 self, 

737 template_paths: list[AsyncPath], 

738 admin: bool = False, 

739 cache: t.Any | None = None, 

740 ) -> AsyncJinja2Templates: 

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

742 _imported_extensions = [ 

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

744 ] 

745 for e in _imported_extensions: 

746 _extensions.extend( 

747 [ 

748 v 

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

750 if isclass(v) 

751 and v.__name__ != "Extension" 

752 and issubclass(v, Extension) 

753 ], 

754 ) 

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

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

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

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

759 module = import_module(module_path) 

760 processor = getattr(module, func_name) 

761 context_processors.append(processor) 

762 templates = AsyncJinja2Templates( 

763 directory=AsyncPath("templates"), 

764 context_processors=context_processors, 

765 extensions=_extensions, 

766 bytecode_cache=bytecode_cache, 

767 enable_async=True, 

768 ) 

769 loader = self.get_loader(template_paths) 

770 if loader: 

771 templates.env.loader = loader 

772 elif self.config.templates.loader: 

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

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

775 setattr(templates.env, delimiter, value) 

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

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

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

779 if admin: 

780 try: 

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

782 get_object_identifier, 

783 ) 

784 except ImportError: 

785 get_object_identifier = str 

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

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

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

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

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

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

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

793 return templates 

794 

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

796 if cache is None: 

797 try: 

798 cache = depends.get("cache") 

799 except Exception: 

800 cache = None 

801 return cache 

802 

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

804 if self.enabled_admin: 

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

806 self._admin_cache = cache 

807 

808 def _log_loader_info(self) -> None: 

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

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

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

812 

813 def _log_extension_info(self) -> None: 

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

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

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

817 

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

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

820 try: 

821 for namespace in ( 

822 "templates", 

823 "_templates", 

824 "bccache", 

825 "template", 

826 "test", 

827 ): 

828 if cache is not None: 

829 await cache.clear(namespace) 

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

831 with suppress(Exception): 

832 htmy_adapter = depends.get("htmy") 

833 if htmy_adapter: 

834 await htmy_adapter.clear_component_cache() 

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

836 except (NotImplementedError, AttributeError) as e: 

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

838 

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

840 async def render_component( 

841 component_name: str, 

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

843 **kwargs: t.Any, 

844 ) -> str: 

845 try: 

846 htmy_adapter = depends.get("htmy") 

847 if htmy_adapter: 

848 htmy_adapter.jinja_templates = self 

849 

850 response = await htmy_adapter.render_component( 

851 request=None, 

852 component=component_name, 

853 context=context, 

854 **kwargs, 

855 ) 

856 return ( 

857 response.body.decode() 

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

859 else str(response.body) 

860 ) 

861 else: 

862 debug( 

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

864 ) 

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

866 except Exception as e: 

867 debug( 

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

869 ) 

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

871 

872 return render_component 

873 

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

875 cache = self._resolve_cache(cache) 

876 app_adapter = self.enabled_app 

877 if app_adapter is None: 

878 try: 

879 app_adapter = depends.get("app") 

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

881 except Exception: 

882 try: 

883 from ..app.default import App 

884 

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

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

887 depends.set("app", app_adapter) 

888 except Exception: 

889 from types import SimpleNamespace 

890 

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

892 debug( 

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

894 ) 

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

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

897 depends.set("templates", self) 

898 self._admin = None 

899 self._admin_initialized = False 

900 await self._setup_admin_templates(cache) 

901 self._log_loader_info() 

902 self._log_extension_info() 

903 await self._clear_debug_cache(cache) 

904 

905 @staticmethod 

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

907 parser = HTMLParser() 

908 parser.feed(html) 

909 soup = parser.get_starttag_text() 

910 if soup is None: 

911 return None 

912 attr_pattern = _get_attr_pattern(attr) 

913 _attr = f"{attr}=" 

914 for s in soup.split(): 

915 if attr_pattern.search(s): 

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

917 return None 

918 

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

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

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

922 if hasattr(env, "add_filter"): 

923 env.add_filter(filter_func, name) 

924 else: 

925 env.filters[name] = filter_func 

926 

927 @track_template_render 

928 async def render_template( 

929 self, 

930 request: t.Any, 

931 template: str, 

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

933 status_code: int = 200, 

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

935 ) -> t.Any: 

936 if context is None: 

937 context = {} 

938 if headers is None: 

939 headers = {} 

940 

941 templates_env = self.app 

942 if templates_env: 

943 return await templates_env.TemplateResponse( 

944 request=request, 

945 name=template, 

946 context=context, 

947 status_code=status_code, 

948 headers=headers, 

949 ) 

950 from starlette.responses import HTMLResponse 

951 

952 return HTMLResponse( 

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

954 status_code=404, 

955 headers=headers, 

956 ) 

957 

958 @track_template_render 

959 async def render_component( 

960 self, 

961 request: t.Any, 

962 component: str, 

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

964 status_code: int = 200, 

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

966 **kwargs: t.Any, 

967 ) -> t.Any: 

968 try: 

969 htmy_adapter = depends.get("htmy") 

970 if htmy_adapter: 

971 htmy_adapter.jinja_templates = self 

972 return await htmy_adapter.render_component( 

973 request=request, 

974 component=component, 

975 context=context, 

976 status_code=status_code, 

977 headers=headers, 

978 **kwargs, 

979 ) 

980 else: 

981 from starlette.responses import HTMLResponse 

982 

983 return HTMLResponse( 

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

985 status_code=500, 

986 headers=headers, 

987 ) 

988 except Exception as e: 

989 from starlette.responses import HTMLResponse 

990 

991 return HTMLResponse( 

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

993 status_code=500, 

994 headers=headers, 

995 ) 

996 

997 def filter( 

998 self, 

999 name: str | None = None, 

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

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

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

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

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

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

1006 return f 

1007 

1008 return decorator 

1009 

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

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

1012 extensions_list = getattr( 

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

1014 "extensions", 

1015 self.config.templates.extensions, 

1016 ) 

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

1018 for e in _imported_extensions: 

1019 _extensions.extend( 

1020 [ 

1021 v 

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

1023 if isclass(v) 

1024 and v.__name__ != "Extension" 

1025 and issubclass(v, Extension) 

1026 ], 

1027 ) 

1028 return _extensions 

1029 

1030 

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

1032MODULE_STATUS = AdapterStatus.STABLE 

1033 

1034with suppress(Exception): 

1035 depends.set(Templates)