Coverage for src/lektor_ng/environment/__init__.py: 86%
214 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 15:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 15:42 +0000
1from __future__ import annotations
3import fnmatch
4import os
5import uuid
6from functools import update_wrapper
7from typing import TYPE_CHECKING
9import babel.dates
10import jinja2
11from jinja2.loaders import split_template_path
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
38if TYPE_CHECKING:
39 from typing import Literal
41 from lektor_ng.assets import Asset
42 from lektor_ng.build_programs import BuildProgram
43 from lektor_ng.sourceobj import SourceObject
46def _prevent_inlining(wrapped):
47 """Ensure wrapped jinja filter does not get inlined by the template compiler.
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.
53 E.g.
55 'say {{ "foo" | upper }}'
57 will be compiled to
59 "say Foo"
61 Many of our filters depend on global state (e..g the Lektor build context).
63 Applying this decorator to them will ensure they are not inlined.
64 """
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)
71 return update_wrapper(wrapper, wrapped)
74def _dates_filter(name, wrapped):
75 """Wrap one of the babel.dates.format_* functions for use as a jinja filter.
77 This will create a jinja filter that will:
79 - Check for *undefined* date/time input (and, in that case, return an empty string).
81 - Check that the ``format`` and ``locale`` parameters, if provided, have the correct
82 types, otherwise raising ``TypeError``.
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.)
91 If `locale` is not specified, we fill it in based on the current *alt*.
93 """
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)
104 if not isinstance(format, str):
105 raise TypeError(f"The 'format' parameter to '{name}' should be a str, not {format!r}")
107 locale = kwargs.get("locale")
108 if locale is None:
109 kwargs["locale"] = get_locale("en_US")
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
122 return update_wrapper(wrapper, wrapped)
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})
138# Special files that should always be ignored.
139IGNORED_FILES = ["thumbs.db", "desktop.ini", "Icon\r"]
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"]
145# Default glob pattern of ignored files.
146EXCLUDED_ASSETS = ["_*", ".*"]
148# Default glob pattern of included files (higher-priority than EXCLUDED_ASSETS).
149INCLUDED_ASSETS = []
152def any_fnmatch(filename, patterns):
153 for pat in patterns:
154 if fnmatch.fnmatch(filename, pat):
155 return True
157 return False
160class CustomJinjaEnvironment(jinja2.Environment):
161 def _load_template(self, name, globals):
162 ctx = get_ctx()
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
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)
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)
199 self.theme_paths = [os.path.join(self.root_path, "themes", theme) for theme in self.project.themes]
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
211 template_paths = [os.path.join(path, "templates") for path in [self.root_path] + self.theme_paths]
213 self.jinja_env = CustomJinjaEnvironment(
214 autoescape=self.select_jinja_autoescape,
215 extensions=["jinja2.ext.do"],
216 loader=jinja2.FileSystemLoader(template_paths),
217 )
219 from lektor_ng.db import (
220 F, # pylint: disable=import-outside-toplevel
221 get_alts, # pylint: disable=import-outside-toplevel
222 )
224 def latlongformat(latlong, secs=True):
225 lat, lon = latlong
226 return format_lat_long(lat=lat, long=lon, secs=secs)
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 )
252 # pylint: disable=import-outside-toplevel
253 from lektor_ng.types import builtin_types
255 self.types = builtin_types.copy()
257 self.publishers = builtin_publishers.copy()
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 = {}
272 if load_plugins:
273 self.load_plugins()
274 # pylint: disable=import-outside-toplevel
275 from lektor_ng.db import siblings_resolver
277 self.virtualpathresolver("siblings")(siblings_resolver)
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]
284 @property
285 def asset_path(self):
286 return os.path.join(self.root_path, "assets")
288 @property
289 def temp_path(self):
290 return os.path.join(self.root_path, "temp")
292 def load_plugins(self):
293 """Loads the plugins."""
294 load_packages(self)
295 initialize_plugins(self)
297 def load_config(self):
298 """Loads the current config."""
299 return Config(self.project.project_file)
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
305 return Database(self).new_pad()
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
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)
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
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)
333 def make_default_tmpl_values(self, pad=None, this=None, values=None, alt=None, template=None):
334 values = dict(values or ())
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
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
362 @staticmethod
363 def select_jinja_autoescape(filename):
364 if filename is None:
365 return False
366 return filename.endswith((".html", ".htm", ".xml", ".xhtml"))
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
375 # -- methods for the plugin system
377 def add_build_program(self, cls: type[SourceObject], program: type[BuildProgram]) -> None:
378 self.build_programs.append((cls, program))
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
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
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
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
405 return decorator
407 def urlresolver(self, func):
408 self.custom_url_resolvers.append(func)
409 return func
411 def generator(self, func):
412 self.custom_generators.append(func)
413 return func