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

1"""Template rendering core implementation (migrated from legacy module). 

2 

3This module provides `Jinja2Templates`, `TemplateResponse`, `render_template`, 

4and `template_response` as the canonical implementations. 

5""" 

6 

7from __future__ import annotations 

8 

9from datetime import UTC, datetime 

10from pathlib import Path 

11from typing import Any, cast 

12 

13from markupsafe import Markup 

14from starlette.responses import HTMLResponse 

15 

16from lexigram import serialization as json 

17from lexigram.logging import get_logger 

18 

19logger = get_logger(__name__) 

20 

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] 

27 

28 

29class Jinja2Templates: 

30 """Jinja2-powered template engine with constructor injection support. 

31 

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. 

35 

36 Example:: 

37 

38 # Provider registration 

39 templates = Jinja2Templates( 

40 directory="templates", 

41 context_processors=[add_request_context], 

42 ) 

43 container.singleton(Jinja2Templates, lambda: templates) 

44 

45 # Controller usage 

46 class MyController: 

47 def __init__(self, templates: Jinja2Templates) -> None: 

48 self._templates = templates 

49 

50 @get("/") 

51 async def index(self) -> HTMLResponse: 

52 return self._templates.render_response("index.html", {"title": "Home"}) 

53 

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 """ 

62 

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 ) 

74 

75 self.directory = Path(directory) 

76 self.context_processors = context_processors or [] 

77 

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) 

85 

86 self.env = Environment(**default_env_kwargs) 

87 

88 self._add_default_filters() 

89 self._add_default_globals() 

90 

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) 

99 

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) 

114 

115 self.env.filters["tojson"] = tojson 

116 self.env.filters["format_datetime"] = format_datetime 

117 

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('/')}" 

121 

122 def get_template(self, name: str) -> Any: 

123 return self.env.get_template(name) 

124 

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. 

132 

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*. 

137 

138 Returns: 

139 Rendered HTML string. 

140 """ 

141 template = self.get_template(name) 

142 context = context or {} 

143 context.update(kwargs) 

144 

145 for processor in self.context_processors: 

146 context = processor(context) 

147 

148 return cast("str", template.render(**context)) 

149 

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`. 

159 

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*. 

166 

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) 

172 

173 

174class TemplateResponse(HTMLResponse): 

175 """Template-based HTML response""" 

176 

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) 

188 

189 

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) 

199 

200 

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)