Coverage for src/lektor_ng/environment/__init__.py: 86%

214 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-03 22:18 +0000

1from __future__ import annotations 

2 

3import fnmatch 

4import os 

5import uuid 

6from functools import update_wrapper 

7from typing import TYPE_CHECKING 

8 

9import babel.dates 

10import jinja2 

11from jinja2.loaders import split_template_path 

12 

13from lektor_ng.constants import PRIMARY_ALT 

14from lektor_ng.context import ( 

15 config_proxy, 

16 get_asset_url, 

17 get_ctx, 

18 get_locale, 

19 site_proxy, 

20 url_to, 

21) 

22from lektor_ng.environment.config import ( 

23 DEFAULT_CONFIG, # noqa - reexport 

24 Config, 

25 ServerInfo, # noqa - reexport 

26 update_config_from_ini, # noqa - reexport 

27) 

28from lektor_ng.environment.expressions import ( 

29 Expression, # noqa - reexport 

30 FormatExpression, # noqa - reexport 

31) 

32from lektor_ng.markdown import Markdown 

33from lektor_ng.packages import load_packages 

34from lektor_ng.pluginsystem import PluginController, initialize_plugins 

35from lektor_ng.publisher import builtin_publishers 

36from lektor_ng.utils import format_lat_long, tojson_filter 

37 

38if TYPE_CHECKING: 

39 from typing import Literal 

40 

41 from lektor_ng.assets import Asset 

42 from lektor_ng.build_programs import BuildProgram 

43 from lektor_ng.sourceobj import SourceObject 

44 

45 

46def _prevent_inlining(wrapped): 

47 """Ensure wrapped jinja filter does not get inlined by the template compiler. 

48 

49 The jinja compiler normally assumes that filters are pure functions (whose 

50 result depends only on their parameters) and will inline filter calls that 

51 are applied to compile-time constants. 

52 

53 E.g. 

54 

55 'say {{ "foo" | upper }}' 

56 

57 will be compiled to 

58 

59 "say Foo" 

60 

61 Many of our filters depend on global state (e..g the Lektor build context). 

62 

63 Applying this decorator to them will ensure they are not inlined. 

64 """ 

65 

66 # the use of @pass_context will prevent inlining 

67 @jinja2.pass_context 

68 def wrapper(_jinja_ctx, *args, **kwargs): 

69 return wrapped(*args, **kwargs) 

70 

71 return update_wrapper(wrapper, wrapped) 

72 

73 

74def _dates_filter(name, wrapped): 

75 """Wrap one of the babel.dates.format_* functions for use as a jinja filter. 

76 

77 This will create a jinja filter that will: 

78 

79 - Check for *undefined* date/time input (and, in that case, return an empty string). 

80 

81 - Check that the ``format`` and ``locale`` parameters, if provided, have the correct 

82 types, otherwise raising ``TypeError``. 

83 

84 - Raise ``TypeError`` with a somewhat informative message if the wrapped formatting 

85 function raises an unexpected exception. Such an exception is most likely due to 

86 being passed an unsupported date/time time. (The Babel formatting functions 

87 accept a fairly wide range of input types — and that range might potentially vary 

88 between releases — so we do not explicitly check the input type before passing it 

89 on to Babel.) 

90 

91 If `locale` is not specified, we fill it in based on the current *alt*. 

92 

93 """ 

94 

95 @_prevent_inlining 

96 def wrapper(arg, format="medium", **kwargs): 

97 if isinstance(arg, jinja2.Undefined): 

98 # This will typically return an empty string, though it depends on the 

99 # specific type of undefined instance. E.g. if arg is a DebugUndefined, it 

100 # will return a more descriptive message, and if arg is a StrictUndefined, 

101 # an UndefinedError will be raised. 

102 return str(arg) 

103 

104 if not isinstance(format, str): 

105 raise TypeError(f"The 'format' parameter to '{name}' should be a str, not {format!r}") 

106 

107 locale = kwargs.get("locale") 

108 if locale is None: 

109 kwargs["locale"] = get_locale("en_US") 

110 

111 try: 

112 return wrapped(arg, format, **kwargs) 

113 except (TypeError, ValueError): 

114 raise 

115 except Exception as exc: 

116 raise TypeError( 

117 f"While evaluating filter '{name}', an unexpected exception was " 

118 "raised. This is likely caused by an input or parameter of an " 

119 "unsupported type." 

120 ) from exc 

121 

122 return update_wrapper(wrapper, wrapped) 

123 

124 

125@_prevent_inlining 

126def _markdown_filter( 

127 source: str, 

128 *, 

129 resolve_links: Literal["always", "never", "when-possible", None] = None, 

130 **kw: str, 

131) -> Markdown: 

132 """A jinja filter that converts markdown text to HTML.""" 

133 ctx = get_ctx() 

134 source_obj = ctx.source if ctx is not None else None 

135 return Markdown(source, source_obj, field_options={**kw, "resolve_links": resolve_links}) 

136 

137 

138# Special files that should always be ignored. 

139IGNORED_FILES = ["thumbs.db", "desktop.ini", "Icon\r"] 

140 

141# These files are important for artifacts and must not be ignored when 

142# they are built even though they start with dots. 

143SPECIAL_ARTIFACTS = [".htaccess", ".htpasswd"] 

144 

145# Default glob pattern of ignored files. 

146EXCLUDED_ASSETS = ["_*", ".*"] 

147 

148# Default glob pattern of included files (higher-priority than EXCLUDED_ASSETS). 

149INCLUDED_ASSETS = [] 

150 

151 

152def any_fnmatch(filename, patterns): 

153 for pat in patterns: 

154 if fnmatch.fnmatch(filename, pat): 

155 return True 

156 

157 return False 

158 

159 

160class CustomJinjaEnvironment(jinja2.Environment): 

161 def _load_template(self, name, globals): 

162 ctx = get_ctx() 

163 

164 try: 

165 rv = jinja2.Environment._load_template(self, name, globals) 

166 if ctx is not None: 

167 filename = rv.filename 

168 ctx.record_dependency(filename) 

169 return rv 

170 except jinja2.TemplateSyntaxError as e: 

171 if ctx is not None: 

172 ctx.record_dependency(e.filename) 

173 raise 

174 except jinja2.TemplateNotFound as e: 

175 if ctx is not None: 

176 # If we can't find the template we want to record at what 

177 # possible locations the template could exist. This will help 

178 # out watcher to pick up templates that will appear in the 

179 # future. This assumes the loader is a file system loader. 

180 for template_name in e.templates: 

181 pieces = split_template_path(template_name) 

182 for base in self.loader.searchpath: 

183 ctx.record_dependency(os.path.join(base, *pieces)) 

184 raise 

185 

186 

187@jinja2.pass_context 

188def lookup_from_bag(jinja_ctx, *args): 

189 pieces = ".".join(x for x in args if x) 

190 site = jinja_ctx.get("site", default=site_proxy) 

191 return site.databags.lookup(pieces) 

192 

193 

194class Environment: 

195 def __init__(self, project, load_plugins=True, extra_flags=None): 

196 self.project = project 

197 self.root_path = os.path.abspath(project.tree) 

198 

199 self.theme_paths = [os.path.join(self.root_path, "themes", theme) for theme in self.project.themes] 

200 

201 if not self.theme_paths: 

202 # load the directories in the themes directory as the themes 

203 try: 

204 for fname in os.listdir(os.path.join(self.root_path, "themes")): 

205 f = os.path.join(self.root_path, "themes", fname) 

206 if os.path.isdir(f): 

207 self.theme_paths.append(f) 

208 except OSError: 

209 pass 

210 

211 template_paths = [os.path.join(path, "templates") for path in [self.root_path] + self.theme_paths] 

212 

213 self.jinja_env = CustomJinjaEnvironment( 

214 autoescape=self.select_jinja_autoescape, 

215 extensions=["jinja2.ext.do"], 

216 loader=jinja2.FileSystemLoader(template_paths), 

217 ) 

218 

219 from lektor_ng.db import ( 

220 F, # pylint: disable=import-outside-toplevel 

221 get_alts, # pylint: disable=import-outside-toplevel 

222 ) 

223 

224 def latlongformat(latlong, secs=True): 

225 lat, lon = latlong 

226 return format_lat_long(lat=lat, long=lon, secs=secs) 

227 

228 self.jinja_env.filters.update( 

229 tojson=tojson_filter, 

230 latformat=lambda x, secs=True: format_lat_long(lat=x, secs=secs), 

231 longformat=lambda x, secs=True: format_lat_long(long=x, secs=secs), 

232 latlongformat=latlongformat, 

233 url=_prevent_inlining(url_to), 

234 asseturl=_prevent_inlining(get_asset_url), 

235 markdown=_markdown_filter, 

236 ) 

237 self.jinja_env.globals.update( 

238 F=F, 

239 url_to=url_to, 

240 site=site_proxy, 

241 config=config_proxy, 

242 bag=lookup_from_bag, 

243 get_alts=get_alts, 

244 get_random_id=lambda: uuid.uuid4().hex, 

245 ) 

246 self.jinja_env.filters.update( 

247 dateformat=_dates_filter("dateformat", babel.dates.format_date), 

248 datetimeformat=_dates_filter("datetimeformat", babel.dates.format_datetime), 

249 timeformat=_dates_filter("timeformat", babel.dates.format_time), 

250 ) 

251 

252 # pylint: disable=import-outside-toplevel 

253 from lektor_ng.types import builtin_types 

254 

255 self.types = builtin_types.copy() 

256 

257 self.publishers = builtin_publishers.copy() 

258 

259 # The plugins that are loaded for this environment. This is 

260 # modified by the plugin controller and registry methods on the 

261 # environment. 

262 self.plugin_controller = PluginController(self, extra_flags) 

263 self.plugins = {} 

264 self.plugin_ids_by_class = {} 

265 self.build_programs = [] 

266 self.special_file_assets = {} 

267 self.special_file_suffixes = {} 

268 self.custom_url_resolvers = [] 

269 self.custom_generators = [] 

270 self.virtual_sources = {} 

271 

272 if load_plugins: 

273 self.load_plugins() 

274 # pylint: disable=import-outside-toplevel 

275 from lektor_ng.db import siblings_resolver 

276 

277 self.virtualpathresolver("siblings")(siblings_resolver) 

278 

279 root_path: str 

280 build_programs: list[tuple[type[SourceObject], type[BuildProgram]]] 

281 special_file_assets: dict[str, type[Asset]] 

282 special_file_suffixes: dict[str, str] 

283 

284 @property 

285 def asset_path(self): 

286 return os.path.join(self.root_path, "assets") 

287 

288 @property 

289 def temp_path(self): 

290 return os.path.join(self.root_path, "temp") 

291 

292 def load_plugins(self): 

293 """Loads the plugins.""" 

294 load_packages(self) 

295 initialize_plugins(self) 

296 

297 def load_config(self): 

298 """Loads the current config.""" 

299 return Config(self.project.project_file) 

300 

301 def new_pad(self): 

302 """Convenience function to create a database and pad.""" 

303 from lektor_ng.db import Database # pylint: disable=import-outside-toplevel 

304 

305 return Database(self).new_pad() 

306 

307 def is_uninteresting_source_name(self, filename: str) -> bool: 

308 """These files are ignored when sources are built into artifacts.""" 

309 fn = filename.lower() 

310 if fn in SPECIAL_ARTIFACTS: 

311 return False 

312 

313 proj = self.project 

314 if any_fnmatch(filename, INCLUDED_ASSETS + proj.included_assets): 

315 # Included by the user's project config, thus not uninteresting. 

316 return False 

317 return any_fnmatch(filename, EXCLUDED_ASSETS + proj.excluded_assets) 

318 

319 @staticmethod 

320 def is_ignored_artifact(asset_name): 

321 """This is used by the prune tool to figure out which files in the 

322 artifact folder should be ignored. 

323 """ 

324 fn = asset_name.lower() 

325 if fn in SPECIAL_ARTIFACTS: 

326 return False 

327 return fn[:1] == "." or fn in IGNORED_FILES 

328 

329 def render_template(self, name, pad=None, this=None, values=None, alt=None): 

330 ctx = self.make_default_tmpl_values(pad, this, values, alt, template=name) 

331 return self.jinja_env.get_or_select_template(name).render(ctx) 

332 

333 def make_default_tmpl_values(self, pad=None, this=None, values=None, alt=None, template=None): 

334 values = dict(values or ()) 

335 

336 # If not provided, pick the alt from the provided "this" object. 

337 # As there is no mandatory format for it, we make sure that we can 

338 # deal with a bad attribute there. 

339 if alt is None: 

340 if this is not None: 

341 alt = getattr(this, "alt", None) 

342 if not isinstance(alt, str): 

343 alt = None 

344 if alt is None: 

345 alt = PRIMARY_ALT 

346 

347 # This is already a global variable but we can inject it as a 

348 # local override if available. 

349 if pad is None: 

350 ctx = get_ctx() 

351 if ctx is not None: 

352 pad = ctx.pad 

353 if pad is not None: 

354 values["site"] = pad 

355 if this is not None: 

356 values["this"] = this 

357 if alt is not None: 

358 values["alt"] = alt 

359 self.plugin_controller.emit("process-template-context", context=values, template=template) 

360 return values 

361 

362 @staticmethod 

363 def select_jinja_autoescape(filename): 

364 if filename is None: 

365 return False 

366 return filename.endswith((".html", ".htm", ".xml", ".xhtml")) 

367 

368 def resolve_custom_url_path(self, obj, url_path): 

369 for resolver in self.custom_url_resolvers: 

370 rv = resolver(obj, url_path) 

371 if rv is not None: 

372 return rv 

373 return None 

374 

375 # -- methods for the plugin system 

376 

377 def add_build_program(self, cls: type[SourceObject], program: type[BuildProgram]) -> None: 

378 self.build_programs.append((cls, program)) 

379 

380 def add_asset_type(self, asset_cls: type[Asset], build_program: type[BuildProgram]) -> None: 

381 self.build_programs.append((asset_cls, build_program)) 

382 self.special_file_assets[asset_cls.source_extension] = asset_cls 

383 if asset_cls.artifact_extension: 

384 cext = asset_cls.source_extension + asset_cls.artifact_extension 

385 self.special_file_suffixes[cext] = asset_cls.source_extension 

386 

387 def add_publisher(self, scheme, publisher): 

388 if scheme in self.publishers: 

389 raise RuntimeError(f"Scheme {scheme!r} is already registered.") 

390 self.publishers[scheme] = publisher 

391 

392 def add_type(self, type): 

393 name = type.name 

394 if name in self.types: 

395 raise RuntimeError(f"Type {name!r} is already registered.") 

396 self.types[name] = type 

397 

398 def virtualpathresolver(self, prefix): 

399 def decorator(func): 

400 if prefix in self.virtual_sources: 

401 raise RuntimeError(f"Prefix {prefix!r} is already registered.") 

402 self.virtual_sources[prefix] = func 

403 return func 

404 

405 return decorator 

406 

407 def urlresolver(self, func): 

408 self.custom_url_resolvers.append(func) 

409 return func 

410 

411 def generator(self, func): 

412 self.custom_generators.append(func) 

413 return func