Coverage for src/lexigram/web/templates/core.py: 29%
77 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Template rendering core implementation (migrated from legacy module).
3This module provides `Jinja2Templates`, `TemplateResponse`, `render_template`,
4and `template_response` as the canonical implementations.
5"""
7from __future__ import annotations
9from datetime import UTC, datetime
10from pathlib import Path
11from typing import Any, cast
13from markupsafe import Markup
14from starlette.responses import HTMLResponse
16from lexigram import serialization as json
17from lexigram.logging import get_logger
19logger = get_logger(__name__)
21try:
22 from jinja2 import Environment, FileSystemLoader, select_autoescape
23except ImportError:
24 Environment = None # type: ignore[assignment, misc]
25 FileSystemLoader = None # type: ignore[assignment, misc]
26 select_autoescape = None # type: ignore[assignment]
29class Jinja2Templates:
30 """Jinja2-powered template engine with constructor injection support.
32 Designed for full DI compliance: all configuration is passed through the
33 constructor so the engine can be registered as a container singleton and
34 injected into controllers or request handlers.
36 Example::
38 # Provider registration
39 templates = Jinja2Templates(
40 directory="templates",
41 context_processors=[add_request_context],
42 )
43 container.singleton(Jinja2Templates, lambda: templates)
45 # Controller usage
46 class MyController:
47 def __init__(self, templates: Jinja2Templates) -> None:
48 self._templates = templates
50 @get("/")
51 async def index(self) -> HTMLResponse:
52 return self._templates.render_response("index.html", {"title": "Home"})
54 Args:
55 directory: Path to the Jinja2 templates directory. Defaults to
56 ``"templates"`` relative to the working directory.
57 context_processors: Optional list of callables that receive the
58 context dict and may add/transform keys before rendering.
59 **env_kwargs: Extra keyword arguments forwarded to the underlying
60 :class:`jinja2.Environment` constructor.
61 """
63 def __init__(
64 self,
65 directory: str | Path = "templates",
66 context_processors: list | None = None,
67 **env_kwargs: Any,
68 ):
69 if Environment is None:
70 raise ImportError(
71 "Jinja2 is required to use templates. Install it with 'pip install jinja2' "
72 "or avoid importing template helpers when Jinja2 is not available.",
73 )
75 self.directory = Path(directory)
76 self.context_processors = context_processors or []
78 default_env_kwargs: dict[str, Any] = {
79 "loader": FileSystemLoader(str(self.directory)),
80 "autoescape": select_autoescape(["html", "xml"]),
81 "trim_blocks": True,
82 "lstrip_blocks": True,
83 }
84 default_env_kwargs.update(env_kwargs)
86 self.env = Environment(**default_env_kwargs)
88 self._add_default_filters()
89 self._add_default_globals()
91 def _add_default_filters(self) -> None:
92 def tojson(obj: Any) -> Markup | str:
93 try:
94 json_str = json.dumps(obj, default=str, ensure_ascii=False)
95 return Markup(json_str)
96 except (TypeError, ValueError) as e:
97 logger.debug("tojson filter failed, falling back to str(): %s", e)
98 return str(obj)
100 def format_datetime(value: Any, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
101 if value is None:
102 return ""
103 if isinstance(value, (int, float)):
104 try:
105 return datetime.fromtimestamp(value).strftime(fmt)
106 except (ValueError, OSError, TypeError) as e:
107 logger.debug("format_datetime timestamp conversion failed: %s", e)
108 return str(value)
109 try:
110 return cast("str", value.strftime(fmt))
111 except (AttributeError, TypeError) as e:
112 logger.debug("format_datetime failed to format value: %s", e)
113 return str(value)
115 self.env.filters["tojson"] = tojson
116 self.env.filters["format_datetime"] = format_datetime
118 def _add_default_globals(self) -> None:
119 self.env.globals["now"] = lambda: datetime.now(UTC)
120 self.env.globals["static_url"] = lambda path: f"/static/{str(path).lstrip('/')}"
122 def get_template(self, name: str) -> Any:
123 return self.env.get_template(name)
125 def render_template(
126 self,
127 name: str,
128 context: dict[str, Any] | None = None,
129 **kwargs: Any,
130 ) -> str:
131 """Render a template to a string.
133 Args:
134 name: Template file name relative to the templates directory.
135 context: Base context mapping passed to the template.
136 **kwargs: Additional context variables merged over *context*.
138 Returns:
139 Rendered HTML string.
140 """
141 template = self.get_template(name)
142 context = context or {}
143 context.update(kwargs)
145 for processor in self.context_processors:
146 context = processor(context)
148 return cast("str", template.render(**context))
150 def render_response(
151 self,
152 name: str,
153 context: dict[str, Any] | None = None,
154 status_code: int = 200,
155 headers: dict[str, str] | None = None,
156 **kwargs: Any,
157 ) -> HTMLResponse:
158 """Render a template and return an :class:`~starlette.responses.HTMLResponse`.
160 Args:
161 name: Template file name relative to the templates directory.
162 context: Base context mapping passed to the template.
163 status_code: HTTP status code for the response. Defaults to 200.
164 headers: Optional extra HTTP response headers.
165 **kwargs: Additional context variables merged over *context*.
167 Returns:
168 An :class:`~starlette.responses.HTMLResponse` with the rendered content.
169 """
170 content = self.render_template(name, context, **kwargs)
171 return HTMLResponse(content=content, status_code=status_code, headers=headers)
174class TemplateResponse(HTMLResponse):
175 """Template-based HTML response"""
177 def __init__(
178 self,
179 template: Jinja2Templates,
180 name: str,
181 context: dict[str, Any] | None = None,
182 status_code: int = 200,
183 headers: dict[str, str] | None = None,
184 **kwargs: Any,
185 ) -> None:
186 content = template.render_template(name, context, **kwargs)
187 super().__init__(content=content, status_code=status_code, headers=headers)
190def render_template(
191 name: str,
192 context: dict[str, Any] | None = None,
193 templates: Jinja2Templates | None = None,
194 **kwargs: Any,
195) -> str:
196 if templates is None:
197 templates = Jinja2Templates()
198 return templates.render_template(name, context, **kwargs)
201def template_response(
202 name: str,
203 context: dict[str, Any] | None = None,
204 templates: Jinja2Templates | None = None,
205 status_code: int = 200,
206 headers: dict[str, str] | None = None,
207 **kwargs: Any,
208) -> TemplateResponse:
209 if templates is None:
210 templates = Jinja2Templates()
211 return TemplateResponse(templates, name, context, status_code, headers, **kwargs)