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