1import functools
2from pathlib import Path
3from typing import Any
4
5from jinja2 import Environment, StrictUndefined
6from jinja2.loaders import FileSystemLoader
7from plain.packages import packages_registry
8from plain.runtime import settings
9
10from .filters import default_filters
11from .globals import default_globals
12
13
14def _finalize_callable_error(obj: Any) -> Any:
15 """Prevent direct rendering of a callable (likely just forgotten ()) by raising a TypeError"""
16 if callable(obj):
17 raise TypeError(f"{obj} is callable, did you forget parentheses?")
18
19 # TODO find a way to prevent <object representation> from being rendered
20 # if obj.__class__.__str__ is object.__str__:
21 # raise TypeError(f"{obj} does not have a __str__ method")
22
23 return obj
24
25
26def get_template_dirs() -> tuple[Path, ...]:
27 jinja_templates = Path(__file__).parent / "templates"
28 app_templates = settings.path.parent / "templates"
29 return (jinja_templates, app_templates) + _get_app_template_dirs()
30
31
32@functools.lru_cache
33def _get_app_template_dirs() -> tuple[Path, ...]:
34 """
35 Return an iterable of paths of directories to load app templates from.
36
37 dirname is the name of the subdirectory containing templates inside
38 installed applications.
39 """
40 dirname = "templates"
41 template_dirs = [
42 Path(package_config.path) / dirname
43 for package_config in packages_registry.get_package_configs()
44 if package_config.path and (Path(package_config.path) / dirname).is_dir()
45 ]
46 # Immutable return value because it will be cached and shared by callers.
47 return tuple(template_dirs)
48
49
50class DefaultEnvironment(Environment):
51 def __init__(self):
52 super().__init__(
53 loader=FileSystemLoader(get_template_dirs()),
54 autoescape=True,
55 auto_reload=settings.DEBUG,
56 undefined=StrictUndefined,
57 finalize=_finalize_callable_error,
58 extensions=["jinja2.ext.loopcontrols", "jinja2.ext.debug"],
59 )
60
61 # Load the top-level defaults
62 self.globals.update(default_globals) # ty: ignore[no-matching-overload]
63 self.filters.update(default_filters)