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