Coverage for src/lektor_ng/sourceobj.py: 92%

143 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-05 15:26 +0000

1from __future__ import annotations 

2 

3import posixpath 

4from typing import TYPE_CHECKING 

5from urllib.parse import parse_qsl, urlsplit 

6from weakref import ref as weakref 

7 

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 

12 

13if TYPE_CHECKING: 

14 from lektor_ng.db import Pad 

15 

16 

17class SourceObject: 

18 source_classification = "generic" 

19 

20 # We consider this class at least what public usage is to considered 

21 # to be from another place. 

22 __module__ = "db" 

23 

24 def __init__(self, pad: Pad): 

25 self._pad = weakref(pad) 

26 

27 @property 

28 def alt(self): 

29 """Returns the effective alt of this source object (unresolved).""" 

30 return PRIMARY_ALT 

31 

32 @property 

33 def source_filename(self): 

34 """The primary source filename of this source object. 

35 

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) 

41 

42 is_hidden = False 

43 is_discoverable = True 

44 

45 @property 

46 def is_visible(self): 

47 """The negated version of :attr:`is_hidden`.""" 

48 return not self.is_hidden 

49 

50 @property 

51 def is_undiscoverable(self): 

52 """The negated version of :attr:`is_discoverable`.""" 

53 return not self.is_discoverable 

54 

55 def iter_source_filenames(self): 

56 """An iterable of the source filenames for this source object. 

57 

58 The first returned filename should be the "primary" one. 

59 """ 

60 # pylint: disable=no-self-use 

61 return () 

62 

63 @property 

64 def url_path(self): 

65 """The URL path of this source object if available.""" 

66 raise NotImplementedError() 

67 

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 

75 

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") 

83 

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 

92 

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) 

102 

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. 

118 

119 If a `base_url` is provided then it's used instead of the URL of 

120 the record itself. 

121 

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. 

124 

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. 

127 

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 

142 

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 ) 

174 

175 return self.pad.make_url(url_path, base_url, absolute, external) 

176 

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 

210 

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() 

214 

215 @property 

216 def url_content_path(self): 

217 """URL path to the directory that contains children of this source object. 

218 

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 

223 

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 

228 

229 

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) 

241 

242 

243class DBSourceObject(SourceObject): 

244 """This is the base class for objects that live in the lektor db. 

245 

246 I.e. this is the type of object returned by pad.get(). 

247 

248 """ 

249 

250 @property 

251 def path(self): 

252 """Return the full database path to the source object. 

253 

254 All DBSourceObjects must have paths. 

255 """ 

256 raise NotImplementedError() 

257 

258 # XXX: move SourceObject.url_to here? 

259 

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 

266 

267 def __hash__(self): 

268 return hash((self.path, self.alt)) 

269 

270 

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 """ 

275 

276 def __init__(self, record): 

277 super().__init__(record.pad) 

278 self.record = record 

279 

280 @property 

281 def path(self): 

282 raise NotImplementedError() 

283 

284 def get_mtime(self, path_cache): 

285 # pylint: disable=no-self-use 

286 return None 

287 

288 def get_checksum(self, path_cache): 

289 # pylint: disable=no-self-use 

290 return None 

291 

292 @property 

293 def parent(self): 

294 return self.record 

295 

296 @property 

297 def alt(self): 

298 return self.record.alt 

299 

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()