Coverage for app / backend / src / couchers / templating.py: 90%

126 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +0000

1""" 

2Provides string templating functionality using jinja2, with custom filters for localization. 

3""" 

4 

5import logging 

6import re 

7from dataclasses import dataclass 

8from datetime import date, datetime, time 

9from functools import lru_cache 

10from html import escape 

11from pathlib import Path 

12from typing import Any, ClassVar 

13 

14from google.protobuf.timestamp_pb2 import Timestamp 

15from jinja2 import Environment, pass_context 

16from jinja2.runtime import Context as JinjaContext 

17from markdown_it import MarkdownIt 

18from markupsafe import Markup 

19 

20from couchers.i18n import LocalizationContext 

21from couchers.i18n.i18next import I18Next 

22from couchers.i18n.localize import get_main_i18next 

23 

24logger = logging.getLogger(__name__) 

25 

26template_folder = Path(__file__).parent / ".." / ".." / "templates" / "v2" 

27 

28_markdown = MarkdownIt("zero", {"typographer": True}).enable( 

29 ["smartquotes", "heading", "hr", "list", "link", "emphasis"] 

30) 

31 

32 

33@dataclass(frozen=True, slots=True, kw_only=True) 

34class Jinja2Template: 

35 """Context available to filter functions during templating.""" 

36 

37 source: str 

38 """The jinja2 template source code.""" 

39 

40 html: bool 

41 """If true, the template will be treated as HTML, so placeholders will be escaped by default.""" 

42 

43 def render(self, args: dict[str, Any], loc_context: LocalizationContext, i18next: I18Next | None = None) -> str: 

44 filter_context = _FilterContext( 

45 output_html=self.html, i18next=i18next or get_main_i18next(), loc_context=loc_context 

46 ) 

47 args = {**args, _FilterContext.KEY: filter_context} 

48 return _get_jinja_env().from_string(self.source).render(args) 

49 

50 

51@dataclass(frozen=True, slots=True, kw_only=True) 

52class _FilterContext: 

53 """Context available to filter functions during templating.""" 

54 

55 KEY: ClassVar[str] = "_filter_context" 

56 

57 output_html: bool 

58 i18next: I18Next 

59 loc_context: LocalizationContext 

60 

61 @staticmethod 

62 def from_jinja(jinja_context: JinjaContext) -> _FilterContext: 

63 context: _FilterContext = jinja_context[_FilterContext.KEY] 

64 return context 

65 

66 

67@lru_cache(maxsize=1) 

68def _get_jinja_env() -> Environment: 

69 env = Environment(trim_blocks=True) 

70 env.autoescape = False # We do escaping in _finalize 

71 env.finalize = _finalize 

72 env.filters["multiline"] = _filter_multiline 

73 env.filters["quotelines"] = _filter_quotelines 

74 env.filters["markdown"] = _filter_markdown 

75 env.filters["html"] = _filter_html 

76 env.filters["date"] = _filter_date 

77 env.filters["time"] = _filter_time 

78 env.filters["datetime"] = _filter_datetime 

79 env.filters["translate"] = _filter_translate 

80 return env 

81 

82 

83@pass_context 

84def _finalize(jinja_context: JinjaContext, value: Any) -> str: 

85 """ 

86 Converts a placeholder value into a string for output in a jinja template. 

87 For example, "{{ my_date }}" will honor the context's locale and timezone. 

88 """ 

89 return _format_default(value, _FilterContext.from_jinja(jinja_context)) 

90 

91 

92def _format_default(value: Any, filter_context: _FilterContext) -> str: 

93 """Formats a placeholder value into a string with useful defaults.""" 

94 match value: 

95 case date(): 

96 return filter_context.loc_context.localize_date(value) 

97 case datetime(): 97 ↛ 98line 97 didn't jump to line 98 because the pattern on line 97 never matched

98 return filter_context.loc_context.localize_datetime(value) 

99 case time(): 99 ↛ 100line 99 didn't jump to line 100 because the pattern on line 99 never matched

100 return filter_context.loc_context.localize_time(value) 

101 case Timestamp(): 101 ↛ 102line 101 didn't jump to line 102 because the pattern on line 101 never matched

102 return filter_context.loc_context.localize_datetime(value) 

103 case Markup(): 

104 return str(value) 

105 case _: 

106 return escape(str(value)) if filter_context.output_html else str(value) 

107 

108 

109@pass_context 

110def _filter_multiline(jinja_context: JinjaContext, value: Any) -> str | Markup: 

111 """Converts newlines to HTML line breaks, if rendering HTML.""" 

112 filter_context = _FilterContext.from_jinja(jinja_context) 

113 if filter_context.output_html: 

114 value = _format_default(value, filter_context) # Escape input HTML unless Markup() 

115 return Markup(value.replace("\n", "<br>")) 

116 elif isinstance(value, Markup): 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true

117 return value 

118 else: 

119 return _format_default(value, filter_context) 

120 

121 

122@pass_context 

123def _filter_quotelines(jinja_context: JinjaContext, value: str | Markup) -> str | Markup: 

124 """If plaintext, prefixes each line to indicate a quote.""" 

125 if _FilterContext.from_jinja(jinja_context).output_html: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true

126 return value 

127 else: 

128 return "\n".join(f"> {line}" for line in value.splitlines()) 

129 

130 

131@pass_context 

132def _filter_markdown(jinja_context: JinjaContext, value: str) -> Markup: 

133 """Renders markdown into html.""" 

134 filter_context = _FilterContext.from_jinja(jinja_context) 

135 if filter_context.output_html: 135 ↛ 138line 135 didn't jump to line 138 because the condition on line 135 was always true

136 return Markup(_markdown.render(value)) 

137 else: 

138 return Markup(value) 

139 

140 

141def _filter_html(value: Any) -> Markup: 

142 """Marks a string as safely containing HTML, so it won't get escaped.""" 

143 return Markup(value) 

144 

145 

146@pass_context 

147def _filter_date(jinja_context: JinjaContext, value: date | datetime | str) -> str: 

148 """Formats a date using the context's locale.""" 

149 filter_context = _FilterContext.from_jinja(jinja_context) 

150 if isinstance(value, str): 150 ↛ 152line 150 didn't jump to line 152 because the condition on line 150 was always true

151 value = date.fromisoformat(value) 

152 return filter_context.loc_context.localize_date(value) 

153 

154 

155@pass_context 

156def _filter_time(jinja_context: JinjaContext, value: datetime | time) -> str: 

157 """Formats a time using the context's locale.""" 

158 filter_context = _FilterContext.from_jinja(jinja_context) 

159 return filter_context.loc_context.localize_time(value) 

160 

161 

162@pass_context 

163def _filter_datetime(jinja_context: JinjaContext, value: datetime | Timestamp) -> str: 

164 """Formats a date+time using the context's locale and timezone.""" 

165 filter_context = _FilterContext.from_jinja(jinja_context) 

166 return filter_context.loc_context.localize_datetime(value) 

167 

168 

169@pass_context 

170def _filter_translate(jinja_context: JinjaContext, key: str, **kwargs: Any) -> Markup: 

171 """ 

172 Looks up a localized string, applying substitutions. 

173 

174 Usage in template: 

175 {{ "greeting_key"|translate(name=user.name) }} 

176 """ 

177 

178 filter_context = _FilterContext.from_jinja(jinja_context) 

179 

180 # Stringify substitutions, applying default formatting and 

181 # escaping unless they have been marked as safely containing HTML. 

182 substitutions: dict[str, str | int] = {} 

183 for k, v in kwargs.items(): 

184 match v: 

185 case int(): 

186 substitutions[k] = v 

187 case _: 

188 substitutions[k] = _format_default(v, filter_context) 

189 

190 value = filter_context.i18next.localize(key, filter_context.loc_context.locale, substitutions) 

191 

192 # Translated strings are trusted to contain simple HTML tags, 

193 # they can also have newlines for translator convenience. 

194 if filter_context.output_html: 

195 # Turn newlines into HTML line breaks. 

196 value = value.replace("\n", "<br>") 

197 else: 

198 # Strip HTML tags that came from the strings since we're not outputting HTML. 

199 # Doesn't support nesting, but should be sufficient for our needs. 

200 value = re.sub(r"<(?P<name>\w+)(?P<attrs>[^>]*)>(?P<inner>.*?)</(?P=name)>", _replace_html_tag_match, value) 

201 value = re.sub(r"<br\s*/?>", "\n", value) 

202 

203 return Markup(value) 

204 

205 

206def _replace_html_tag_match(match: re.Match[str]) -> str: 

207 inner_text = match.group("inner") 

208 if match.group("name").lower() == "a": 

209 # <a href="url">text</a> -> <url> 

210 # If no url, fallback to text. 

211 href_attr = re.search(r'\bhref="([^"]+)"', match.group("attrs")) 

212 link_text: str 

213 if href_attr: 213 ↛ 216line 213 didn't jump to line 216 because the condition on line 213 was always true

214 link_text = href_attr.group(1).removeprefix("mailto:") 

215 else: 

216 link_text = inner_text 

217 return f"<{link_text}>" 

218 else: 

219 # <b>hello</b> -> hello 

220 return inner_text