Coverage for fastblocks/actions/gather/templates.py: 44%

265 statements  

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

1"""Template component gathering to consolidate loader, extension, processor, and filter collection.""" 

2 

3import typing as t 

4from contextlib import suppress 

5from importlib import import_module 

6from inspect import isclass 

7 

8from acb.debug import debug 

9from anyio import Path as AsyncPath 

10from jinja2.ext import Extension 

11 

12from .strategies import GatherStrategy, gather_with_strategy 

13 

14 

15class TemplateGatherResult: 

16 def __init__( 

17 self, 

18 *, 

19 loaders: list[t.Any] | None = None, 

20 extensions: list[t.Any] | None = None, 

21 context_processors: list[t.Callable[..., t.Any]] | None = None, 

22 filters: dict[str, t.Callable[..., t.Any]] | None = None, 

23 globals: dict[str, t.Any] | None = None, 

24 errors: list[Exception] | None = None, 

25 ) -> None: 

26 self.loaders = loaders if loaders is not None else [] 

27 self.extensions = extensions if extensions is not None else [] 

28 self.context_processors = ( 

29 context_processors if context_processors is not None else [] 

30 ) 

31 self.filters = filters if filters is not None else {} 

32 self.globals = globals if globals is not None else {} 

33 self.errors = errors if errors is not None else [] 

34 

35 @property 

36 def total_components(self) -> int: 

37 return ( 

38 len(self.loaders) 

39 + len(self.extensions) 

40 + len(self.context_processors) 

41 + len(self.filters) 

42 + len(self.globals) 

43 ) 

44 

45 @property 

46 def has_errors(self) -> bool: 

47 return len(self.errors) > 0 

48 

49 

50async def gather_templates( 

51 *, 

52 template_paths: list[AsyncPath] | None = None, 

53 loader_types: list[str] | None = None, 

54 extension_modules: list[str] | None = None, 

55 context_processor_paths: list[str] | None = None, 

56 filter_modules: list[str] | None = None, 

57 admin_mode: bool = False, 

58 strategy: GatherStrategy | None = None, 

59) -> TemplateGatherResult: 

60 config = _prepare_template_gather_config( 

61 template_paths, 

62 loader_types, 

63 extension_modules, 

64 context_processor_paths, 

65 filter_modules, 

66 admin_mode, 

67 strategy, 

68 ) 

69 result = TemplateGatherResult() 

70 

71 tasks = _build_template_gather_tasks(config) 

72 

73 gather_result = await gather_with_strategy( 

74 tasks, 

75 config["strategy"], 

76 cache_key=f"templates:{admin_mode}:{':'.join(config['loader_types'])}", 

77 ) 

78 

79 _process_template_gather_results(gather_result, result) 

80 

81 result.errors.extend(gather_result.errors) 

82 debug(f"Gathered {result.total_components} template components") 

83 

84 return result 

85 

86 

87def _prepare_template_gather_config( 

88 template_paths: list[AsyncPath] | None, 

89 loader_types: list[str] | None, 

90 extension_modules: list[str] | None, 

91 context_processor_paths: list[str] | None, 

92 filter_modules: list[str] | None, 

93 admin_mode: bool, 

94 strategy: GatherStrategy | None, 

95) -> dict[str, t.Any]: 

96 if loader_types is None: 

97 loader_types = ["redis", "storage", "filesystem"] 

98 if admin_mode: 

99 loader_types.append("package") 

100 

101 return { 

102 "template_paths": template_paths 

103 if template_paths is not None 

104 else [AsyncPath("templates")], 

105 "loader_types": loader_types, 

106 "extension_modules": extension_modules, 

107 "context_processor_paths": context_processor_paths, 

108 "filter_modules": filter_modules, 

109 "admin_mode": admin_mode, 

110 "strategy": strategy or GatherStrategy(), 

111 } 

112 

113 

114def _build_template_gather_tasks( 

115 config: dict[str, t.Any], 

116) -> list[t.Coroutine[t.Any, t.Any, t.Any]]: 

117 tasks = [] 

118 

119 tasks.append( 

120 _gather_loaders( 

121 config["template_paths"], 

122 config["loader_types"], 

123 config["admin_mode"], 

124 ), 

125 ) 

126 

127 if config["extension_modules"]: 

128 tasks.append(_gather_extensions(config["extension_modules"])) 

129 else: 

130 tasks.append(_gather_default_extensions()) 

131 

132 if config["context_processor_paths"]: 

133 tasks.append(_gather_context_processors(config["context_processor_paths"])) 

134 else: 

135 tasks.append(_gather_default_context_processors()) 

136 

137 if config["filter_modules"]: 

138 tasks.append(_gather_filters(config["filter_modules"])) 

139 else: 

140 tasks.append(_gather_default_filters()) 

141 

142 tasks.append(_gather_template_globals()) 

143 

144 return tasks 

145 

146 

147def _process_template_gather_results( 

148 gather_result: t.Any, 

149 result: TemplateGatherResult, 

150) -> None: 

151 component_mapping = [ 

152 "loaders", 

153 "extensions", 

154 "context_processors", 

155 "filters", 

156 "globals", 

157 ] 

158 

159 for i, success in enumerate(gather_result.success): 

160 if i < len(component_mapping): 

161 setattr(result, component_mapping[i], success) 

162 

163 

164async def _gather_loaders( 

165 template_paths: list[AsyncPath], 

166 loader_types: list[str], 

167 admin_mode: bool, 

168) -> list[t.Any]: 

169 try: 

170 jinja2_module = __import__( 

171 "fastblocks.adapters.templates.jinja2", 

172 fromlist=[ 

173 "ChoiceLoader", 

174 "FileSystemLoader", 

175 "PackageLoader", 

176 "RedisLoader", 

177 "StorageLoader", 

178 ], 

179 ) 

180 jinja2_module.ChoiceLoader 

181 FileSystemLoader = jinja2_module.FileSystemLoader 

182 PackageLoader = jinja2_module.PackageLoader 

183 RedisLoader = jinja2_module.RedisLoader 

184 StorageLoader = jinja2_module.StorageLoader 

185 except (ImportError, AttributeError) as e: 

186 debug(f"Error loading template loader classes: {e}") 

187 raise 

188 

189 searchpaths: list[AsyncPath] = [] 

190 for path in template_paths: 

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

192 

193 loaders = [] 

194 

195 if "redis" in loader_types: 

196 loaders.append(RedisLoader(searchpaths)) 

197 

198 if "storage" in loader_types: 

199 loaders.append(StorageLoader(searchpaths)) 

200 

201 if "filesystem" in loader_types: 

202 loaders.append(FileSystemLoader(searchpaths)) 

203 

204 if "package" in loader_types and admin_mode: 

205 try: 

206 from acb.adapters import get_adapter 

207 

208 enabled_admin = get_adapter("admin") 

209 if enabled_admin: 

210 loaders.append(PackageLoader(enabled_admin.name, "templates", "admin")) 

211 except Exception as e: 

212 debug(f"Could not create package loader: {e}") 

213 

214 debug(f"Created {len(loaders)} template loaders") 

215 return loaders 

216 

217 

218async def _gather_extensions(extension_modules: list[str]) -> list[t.Any]: 

219 extensions = [] 

220 from jinja2.ext import debug as jinja_debug 

221 from jinja2.ext import i18n, loopcontrols 

222 

223 extensions.extend([loopcontrols, i18n, jinja_debug]) 

224 for module_path in extension_modules: 

225 try: 

226 module = import_module(module_path) 

227 for attr_name in dir(module): 

228 attr = getattr(module, attr_name) 

229 if ( 

230 isclass(attr) 

231 and attr.__name__ != "Extension" 

232 and issubclass(attr, Extension) 

233 ): 

234 extensions.append(attr) 

235 debug(f"Found extension {attr.__name__} in {module_path}") 

236 except Exception as e: 

237 debug(f"Error loading extensions from {module_path}: {e}") 

238 debug(f"Gathered {len(extensions)} Jinja2 extensions") 

239 return extensions 

240 

241 

242async def _gather_default_extensions() -> list[t.Any]: 

243 from jinja2.ext import debug as jinja_debug 

244 from jinja2.ext import i18n, loopcontrols 

245 

246 extensions = [loopcontrols, i18n, jinja_debug] 

247 await _load_config_extensions(extensions) 

248 

249 return extensions 

250 

251 

252async def _load_config_extensions(extensions: list[t.Any]) -> None: 

253 with suppress(Exception): 

254 from acb.depends import depends 

255 

256 config = depends.get("config") 

257 if _has_template_extensions_config(config): 

258 _process_extension_paths(config.templates.extensions, extensions) 

259 

260 

261def _has_template_extensions_config(config: t.Any) -> bool: 

262 return hasattr(config, "templates") and hasattr(config.templates, "extensions") 

263 

264 

265def _process_extension_paths(ext_paths: list[str], extensions: list[t.Any]) -> None: 

266 for ext_path in ext_paths: 

267 try: 

268 module = import_module(ext_path) 

269 _extract_extension_classes_from_module(module, extensions) 

270 except Exception as e: 

271 debug(f"Error loading extension {ext_path}: {e}") 

272 

273 

274def _extract_extension_classes_from_module( 

275 module: t.Any, 

276 extensions: list[t.Any], 

277) -> None: 

278 for attr_name in dir(module): 

279 attr = getattr(module, attr_name) 

280 if _is_valid_extension_class(attr): 

281 extensions.append(attr) 

282 

283 

284def _is_valid_extension_class(attr: t.Any) -> bool: 

285 return ( 

286 isclass(attr) and attr.__name__ != "Extension" and issubclass(attr, Extension) 

287 ) 

288 

289 

290async def _gather_context_processors( 

291 processor_paths: list[str], 

292) -> list[t.Callable[..., t.Any]]: 

293 processors = [] 

294 for processor_path in processor_paths: 

295 try: 

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

297 module = import_module(module_path) 

298 processor = getattr(module, func_name) 

299 if callable(processor): 

300 processors.append(processor) 

301 debug(f"Found context processor {func_name} in {module_path}") 

302 else: 

303 debug(f"Context processor {func_name} is not callable") 

304 except Exception as e: 

305 debug(f"Error loading context processor {processor_path}: {e}") 

306 debug(f"Gathered {len(processors)} context processors") 

307 return processors 

308 

309 

310async def _gather_default_context_processors() -> list[t.Callable[..., t.Any]]: 

311 processors = [] 

312 with suppress(Exception): 

313 from acb.depends import depends 

314 

315 config = depends.get("config") 

316 if hasattr(config, "templates") and hasattr( 

317 config.templates, 

318 "context_processors", 

319 ): 

320 for processor_path in config.templates.context_processors: 

321 try: 

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

323 module = import_module(module_path) 

324 processor = getattr(module, func_name) 

325 if callable(processor): 

326 processors.append(processor) 

327 except Exception as e: 

328 debug(f"Error loading context processor {processor_path}: {e}") 

329 

330 return processors 

331 

332 

333async def _gather_filters( 

334 filter_modules: list[str], 

335) -> dict[str, t.Callable[..., t.Any]]: 

336 filters = {} 

337 for module_path in filter_modules: 

338 try: 

339 module = import_module(module_path) 

340 _extract_filters_from_module(module, module_path, filters) 

341 except Exception as e: 

342 debug(f"Error loading filters from {module_path}: {e}") 

343 debug(f"Gathered {len(filters)} template filters") 

344 return filters 

345 

346 

347def _extract_filters_from_module( 

348 module: t.Any, 

349 module_path: str, 

350 filters: dict[str, t.Callable[..., t.Any]], 

351) -> None: 

352 if hasattr(module, "Filters"): 

353 filters_class = module.Filters 

354 _extract_filters_from_class(filters_class, module_path, filters) 

355 

356 _extract_filter_functions(module, module_path, filters) 

357 

358 

359def _extract_filters_from_class( 

360 filters_class: t.Any, 

361 module_path: str, 

362 filters: dict[str, t.Callable[..., t.Any]], 

363) -> None: 

364 for attr_name in dir(filters_class): 

365 if not attr_name.startswith("_"): 

366 attr = getattr(filters_class, attr_name) 

367 if callable(attr): 

368 filters[attr_name] = attr 

369 debug(f"Found filter {attr_name} in {module_path}") 

370 

371 

372def _extract_filter_functions( 

373 module: t.Any, 

374 module_path: str, 

375 filters: dict[str, t.Callable[..., t.Any]], 

376) -> None: 

377 for attr_name in dir(module): 

378 if attr_name.endswith("_filter") and not attr_name.startswith("_"): 

379 attr = getattr(module, attr_name) 

380 if callable(attr): 

381 filter_name = attr_name.removesuffix("_filter") 

382 filters[filter_name] = attr 

383 debug(f"Found filter function {filter_name} in {module_path}") 

384 

385 

386async def _gather_default_filters() -> dict[str, t.Callable[..., t.Any]]: 

387 filters = {} 

388 try: 

389 filters_module = __import__( 

390 "fastblocks.adapters.templates._filters", 

391 fromlist=["Filters"], 

392 ) 

393 Filters = filters_module.Filters 

394 for attr_name in dir(Filters): 

395 if not attr_name.startswith("_") and attr_name != "register_filters": 

396 attr = getattr(Filters, attr_name) 

397 if callable(attr): 

398 filters[attr_name] = attr 

399 except (ImportError, AttributeError) as e: 

400 debug(f"Error loading default filters: {e}") 

401 

402 return filters 

403 

404 

405async def _gather_template_globals() -> dict[str, t.Any]: 

406 globals_dict = {} 

407 try: 

408 from acb.depends import depends 

409 

410 config = depends.get("config") 

411 globals_dict["config"] = config 

412 try: 

413 models = depends.get("models") 

414 globals_dict["models"] = models 

415 except Exception: 

416 globals_dict["models"] = None 

417 if hasattr(config, "templates") and hasattr(config.templates, "globals"): 

418 globals_dict.update(config.templates.globals) 

419 except Exception as e: 

420 debug(f"Error gathering template globals: {e}") 

421 

422 return globals_dict 

423 

424 

425async def create_choice_loader( 

426 loaders: list[t.Any], 

427 config: t.Any | None = None, 

428) -> t.Any: 

429 try: 

430 jinja2_module = __import__( 

431 "fastblocks.adapters.templates.jinja2", 

432 fromlist=["ChoiceLoader"], 

433 ) 

434 ChoiceLoader = jinja2_module.ChoiceLoader 

435 except (ImportError, AttributeError) as e: 

436 debug(f"Error loading ChoiceLoader: {e}") 

437 raise 

438 

439 ordered_loaders = [] 

440 

441 if config and not getattr(config, "deployed", False): 

442 filesystem_loaders = [ 

443 loader for loader in loaders if "FileSystem" in str(type(loader)) 

444 ] 

445 other_loaders = [ 

446 loader for loader in loaders if "FileSystem" not in str(type(loader)) 

447 ] 

448 ordered_loaders = filesystem_loaders + other_loaders 

449 else: 

450 cache_loaders = [ 

451 loader 

452 for loader in loaders 

453 if any(x in str(type(loader)) for x in ("Redis", "Storage")) 

454 ] 

455 other_loaders = [ 

456 loader 

457 for loader in loaders 

458 if not any(x in str(type(loader)) for x in ("Redis", "Storage")) 

459 ] 

460 ordered_loaders = cache_loaders + other_loaders 

461 

462 debug(f"Created ChoiceLoader with {len(ordered_loaders)} loaders") 

463 return ChoiceLoader(ordered_loaders) 

464 

465 

466async def create_template_environment( 

467 gather_result: TemplateGatherResult, 

468 cache: t.Any | None = None, 

469) -> t.Any: 

470 from jinja2_async_environment.bccache import AsyncRedisBytecodeCache 

471 from starlette_async_jinja import AsyncJinja2Templates 

472 

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

474 

475 choice_loader = await create_choice_loader(gather_result.loaders) 

476 

477 templates = AsyncJinja2Templates( 

478 directory=AsyncPath("templates"), 

479 context_processors=gather_result.context_processors, 

480 extensions=gather_result.extensions, 

481 bytecode_cache=bytecode_cache, 

482 enable_async=True, 

483 ) 

484 

485 if choice_loader: 

486 templates.env.loader = choice_loader 

487 

488 for name, value in gather_result.globals.items(): 

489 templates.env.globals[name] = value 

490 

491 for name, filter_func in gather_result.filters.items(): 

492 templates.env.filters[name] = filter_func 

493 

494 templates.env.block_start_string = "[%" 

495 templates.env.block_end_string = "%]" 

496 templates.env.variable_start_string = "[[" 

497 templates.env.variable_end_string = "]]" 

498 templates.env.comment_start_string = "[#" 

499 templates.env.comment_end_string = "#]" 

500 

501 debug("Created Jinja2 environment with gathered components") 

502 return templates 

503 

504 

505def register_template_filters( 

506 templates: t.Any, 

507 filters: dict[str, t.Callable[..., t.Any]], 

508) -> None: 

509 for name, filter_func in filters.items(): 

510 if hasattr(templates, "filter"): 

511 templates.filter(name)(filter_func) 

512 else: 

513 templates.env.filters[name] = filter_func 

514 

515 debug(f"Registered {len(filters)} template filters")