Coverage for src/lektor_ng/sourceobj.py: 92%
143 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 15:10 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 15:10 +0000
1from __future__ import annotations
3import posixpath
4from typing import TYPE_CHECKING
5from urllib.parse import parse_qsl, urlsplit
6from weakref import ref as weakref
8from lektor_ng.constants import PRIMARY_ALT
9from lektor_ng.context import ignore_url_unaffecting_dependencies
10from lektor_ng.reporter import reporter
11from lektor_ng.utils import is_path_child_of, join_path
13if TYPE_CHECKING:
14 from lektor_ng.db import Pad
17class SourceObject:
18 source_classification = "generic"
20 # We consider this class at least what public usage is to considered
21 # to be from another place.
22 __module__ = "db"
24 def __init__(self, pad: Pad):
25 self._pad = weakref(pad)
27 @property
28 def alt(self):
29 """Returns the effective alt of this source object (unresolved)."""
30 return PRIMARY_ALT
32 @property
33 def source_filename(self):
34 """The primary source filename of this source object.
36 In general, subclasses should implement/override ``iter_source_filenames``
37 rather than this property.
38 """
39 source_filenames = self.iter_source_filenames()
40 return next(iter(source_filenames), None)
42 is_hidden = False
43 is_discoverable = True
45 @property
46 def is_visible(self):
47 """The negated version of :attr:`is_hidden`."""
48 return not self.is_hidden
50 @property
51 def is_undiscoverable(self):
52 """The negated version of :attr:`is_discoverable`."""
53 return not self.is_discoverable
55 def iter_source_filenames(self):
56 """An iterable of the source filenames for this source object.
58 The first returned filename should be the "primary" one.
59 """
60 # pylint: disable=no-self-use
61 return ()
63 @property
64 def url_path(self):
65 """The URL path of this source object if available."""
66 raise NotImplementedError()
68 @property
69 def path(self):
70 """Return the full path to the source object. Not every source
71 object actually has a path but source objects without paths need
72 to subclass `VirtualSourceObject`.
73 """
74 return None
76 @property
77 def pad(self) -> Pad:
78 """The associated pad of this source object."""
79 rv = self._pad()
80 if rv is not None:
81 return rv
82 raise AttributeError("The pad went away")
84 def resolve_url_path(self, url_path):
85 """Given a URL path as list this resolves the most appropriate
86 direct child and returns the list of remaining items. If no
87 match can be found, the result is `None`.
88 """
89 if not url_path:
90 return self
91 return None
93 def is_child_of(self, path, strict=False):
94 """Checks if the current object is a child of the passed object
95 or path.
96 """
97 if isinstance(path, SourceObject):
98 path = path.path
99 if self.path is None or path is None:
100 return False
101 return is_path_child_of(self.path, path, strict=strict)
103 def url_to(
104 self,
105 path, # : Union[str, "SourceObject", "SupportsUrlPath"]
106 *,
107 alt: str | None = None,
108 absolute: bool | None = None,
109 external: bool | None = None,
110 base_url: str | None = None,
111 resolve: bool | None = None,
112 strict_resolve: bool | None = None,
113 ) -> str:
114 """Calculates the URL from the current source object to the given
115 other source object. Alternatively a path can also be provided
116 instead of a source object. If the path starts with a leading
117 bang (``!``) then no resolving is performed.
119 If a `base_url` is provided then it's used instead of the URL of
120 the record itself.
122 If path is a string and resolve=False is passed, then no attempt is
123 made to resolve the path to a Lektor source object.
125 If path is a string and strict_resolve=True is passed, then an exception
126 is raised if the path can not be resolved to a Lektor source object.
128 API CHANGE: It used to be (lektor <= 3.3.1) that if absolute was true-ish,
129 then a url_path (URL path relative to the site's ``base_path`` was returned.
130 This is changed so that now an absolute URL path is returned.
131 """
132 if base_url is None:
133 base_url = self.url_path
134 if absolute:
135 # This sort of reproduces the old behaviour, where when
136 # ``absolute`` was trueish, the "absolute" URL path
137 # (relative to config.base_path) was returned, regardless
138 # of the value of ``external``.
139 external = False
140 if resolve is None and strict_resolve:
141 resolve = True
143 if isinstance(path, SourceObject):
144 # assert not isinstance(path, Asset)
145 target = path
146 if alt is not None and alt != target.alt:
147 # NB: path.path includes page_num
148 alt_target = self.pad.get(path.path, alt=alt, persist=False)
149 if alt_target is not None:
150 target = alt_target
151 # FIXME: issue warning or fail if cannot get correct alt?
152 url_path = target.url_path
153 elif hasattr(path, "url_path"): # e.g. Thumbnail
154 assert path.url_path.startswith("/")
155 url_path = path.url_path
156 elif path[:1] == "!":
157 # XXX: error if used with explicit alt?
158 if resolve:
159 raise RuntimeError("Resolve=True is incompatible with '!' prefix.")
160 url_path = _join_url_path(self, path[1:])
161 elif resolve is not None and not resolve:
162 # XXX: error if used with explicit alt?
163 url_path = _join_url_path(self, path)
164 else:
165 with ignore_url_unaffecting_dependencies():
166 return self._resolve_url(
167 path,
168 alt=alt,
169 absolute=absolute,
170 external=external,
171 base_url=base_url,
172 strict=strict_resolve,
173 )
175 return self.pad.make_url(url_path, base_url, absolute, external)
177 def _resolve_url(
178 self,
179 _url: str,
180 alt: str | None,
181 absolute: bool | None,
182 external: bool | None,
183 base_url: str | None,
184 strict: bool | None,
185 ) -> str:
186 """Resolve (possibly relative) URL or db path to URL."""
187 url = urlsplit(_url)
188 if url.scheme or url.netloc:
189 resolved = url
190 else:
191 # Interpret path as (possibly relative) db-path
192 dbpath = join_path(self.path, url.path)
193 params = dict(parse_qsl(url.query, keep_blank_values=False))
194 query_alt = params.get("alt")
195 # XXX: support page_num in query, too?
196 if not alt:
197 alt = query_alt or self.alt
198 elif query_alt and query_alt != alt:
199 raise RuntimeError("Conflicting values for alt.")
200 target = self.pad.get(dbpath, alt=alt)
201 if target is not None:
202 url_path = target.url_path
203 query = ""
204 elif strict:
205 raise RuntimeError(f"Can not resolve link {_url!r}")
206 else:
207 # Fall back to interpreting path as (possibly relative) URL path
208 url_path = _join_url_path(self, url.path)
209 query = url.query
211 result = self.pad.make_url(url_path, absolute=absolute, external=external, base_url=base_url)
212 resolved = urlsplit(result)._replace(query=query, fragment=url.fragment)
213 return resolved.geturl()
215 @property
216 def url_content_path(self):
217 """URL path to the directory that contains children of this source object.
219 For container types, the record's ``url_content_path`` is often
220 the same as its ``url_path``. The exception to this is when
221 the page's slug contains a dot (".").
222 See https://www.getlektor.com/docs/content/urls/#content-below-dotted-slugs
224 The ``url_content_path`` should be ``None`` for attachments and other
225 SourceObject types that can not contain child source objects.
226 """
227 return None
230def _join_url_path(source, path):
231 """Join possibly relative url path relative to source.url_content_path."""
232 if posixpath.isabs(path):
233 return path
234 content_path = source.url_content_path
235 if content_path is None:
236 # Source is not a container type (e.g. it is an attachment).
237 # Punt and treat path as relative to the source's containing directory.
238 content_path = posixpath.dirname(source.url_path) or "/"
239 reporter.report_generic(f"Suspicious use of relative URL {path!r} from non-container source {source!r}")
240 return posixpath.join(content_path, path)
243class DBSourceObject(SourceObject):
244 """This is the base class for objects that live in the lektor db.
246 I.e. this is the type of object returned by pad.get().
248 """
250 @property
251 def path(self):
252 """Return the full database path to the source object.
254 All DBSourceObjects must have paths.
255 """
256 raise NotImplementedError()
258 # XXX: move SourceObject.url_to here?
260 def __eq__(self, other):
261 if other is self:
262 return True # optimization
263 if other.__class__ is not self.__class__:
264 return False # optimization
265 return other.alt == self.alt and other.path == self.path and other.pad == self.pad
267 def __hash__(self):
268 return hash((self.path, self.alt))
271class VirtualSourceObject(DBSourceObject):
272 """Virtual source objects live below a parent record but do not
273 originate from the source tree with a separate file.
274 """
276 def __init__(self, record):
277 super().__init__(record.pad)
278 self.record = record
280 @property
281 def path(self):
282 raise NotImplementedError()
284 def get_mtime(self, path_cache):
285 # pylint: disable=no-self-use
286 return None
288 def get_checksum(self, path_cache):
289 # pylint: disable=no-self-use
290 return None
292 @property
293 def parent(self):
294 return self.record
296 @property
297 def alt(self):
298 return self.record.alt
300 def iter_source_filenames(self):
301 # This is a default. However, if artifacts produced from a
302 # particular virtual source type do not explicitly vary with
303 # the parent record, it may make sense to override this to
304 # return an empty (or some other) list of file names.
305 return self.record.iter_source_filenames()