Coverage for src/lektor_ng/assets.py: 100%
113 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 10:32 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 10:32 +0000
1from __future__ import annotations
3import posixpath
4import warnings
5from collections import defaultdict
6from collections.abc import Generator, Iterable, Sequence
7from contextlib import suppress
8from itertools import takewhile
9from operator import methodcaller
10from pathlib import Path
11from typing import TYPE_CHECKING
13from werkzeug.utils import cached_property
15from lektor_ng.sourceobj import SourceObject
16from lektor_ng.utils import DeprecatedWarning, deprecated
18if TYPE_CHECKING:
19 from _typeshed import StrPath
21 from lektor_ng.db import Pad
24def get_asset_root(pad: Pad, asset_roots: Iterable[StrPath]) -> Directory:
25 """Get the merged asset root.
27 This represents a logical merging or overlaying of (possibly) multiple asset trees,
28 rooted at directories given by ``asset_roots``.
30 Any paths listed in ``asset_roots`` that do not refer to a directory
31 are silently ignored.
32 """
33 root_paths = tuple(Path(root).absolute() for root in asset_roots if Path(root).is_dir())
34 return Directory(pad, parent=None, name="", paths=root_paths)
37@deprecated(version="3.4.0")
38def get_asset(pad: Pad, filename: str, parent: Asset | None = None) -> Asset | None:
39 if parent is None:
40 parent = pad.asset_root
41 else:
42 assert pad is parent.pad
43 return parent.get_child(filename)
46_FROM_URL_DEPRECATED = DeprecatedWarning(
47 "from_url",
48 reason="The `from_url` parameter of `Asset.get_child` is now ignored.",
49 version="3.4.0",
50)
53class Asset(SourceObject):
54 source_classification = "asset"
55 artifact_extension = ""
57 def __init__(self, pad: Pad, name: str, parent: Asset | None, paths: tuple[Path, ...]):
58 super().__init__(pad)
59 self.name = name
60 self.parent = parent
61 self._paths = paths
63 def iter_source_filenames(self) -> Generator[str]:
64 yield from map(str, self._paths)
66 @property
67 def url_name(self) -> str:
68 name = self.name
69 base, ext = posixpath.splitext(name)
71 # If this is a known extension from an attachment then convert it
72 # to lowercase
73 if ext.lower() in self.pad.db.config["ATTACHMENT_TYPES"]:
74 ext = ext.lower()
76 return base + ext + self.artifact_extension
78 @property
79 def url_path(self) -> str:
80 if self.parent is None:
81 return "/" + self.name
82 return posixpath.join(self.parent.url_path, self.url_name)
84 @property
85 def artifact_name(self) -> str:
86 if self.parent is not None:
87 return self.parent.artifact_name.rstrip("/") + "/" + self.url_name
88 return self.url_path
90 @property
91 def children(self) -> Iterable[Asset]:
92 return ()
94 # pylint: disable-next=no-self-use,useless-return
95 def get_child(self, name: str, from_url: bool = False) -> Asset | None:
96 if from_url:
97 warnings.warn(_FROM_URL_DEPRECATED, stacklevel=2)
98 return None
100 def resolve_url_path(self, url_path: Sequence[str]) -> Asset | None:
101 if len(url_path) == 0:
102 return self
103 return None
105 def __repr__(self) -> str:
106 return f"<{self.__class__.__name__} {self.artifact_name!r}>"
109class Directory(Asset):
110 """Represents a merged set of asset directories."""
112 @property
113 def children(self) -> Iterable[Asset]:
114 return self._children_by_name.values()
116 def get_child(self, name: str, from_url: bool = False) -> Asset | None:
117 if from_url:
118 warnings.warn(_FROM_URL_DEPRECATED, stacklevel=2)
119 return self._children_by_name.get(name)
121 @cached_property
122 def _children_by_name(self) -> dict[str, Asset]:
123 return {asset.name: asset for asset in self._iter_children()}
125 def _iter_children(self) -> Generator[Asset]:
126 env = self.pad.env
127 candidates_by_name = defaultdict(list)
128 for path in self._paths:
129 with suppress(OSError):
130 for child in path.iterdir():
131 if not env.is_uninteresting_source_name(child.name):
132 candidates_by_name[child.name].append(child)
134 for name, candidates in candidates_by_name.items():
135 leading_dirs = tuple(takewhile(methodcaller("is_dir"), candidates))
136 if leading_dirs:
137 # Merge directories at the top of the overlay stack.
138 #
139 # Directories overlayed above a non-directory shadow (hide) that
140 # non-directory. (That non-directory, in turn, shadows anything under
141 # it.)
142 yield Directory(self.pad, parent=self, name=name, paths=leading_dirs)
143 else:
144 # If first candidate is not a directory, it shadows any below it.
145 path = candidates[0]
146 asset_class = env.special_file_assets.get(path.suffix, File)
147 yield asset_class(self.pad, parent=self, name=name, paths=(path,))
149 @property
150 def url_name(self) -> str:
151 return self.name + self.artifact_extension
153 @property
154 def url_path(self) -> str:
155 path = super().url_path
156 if not path.endswith("/"):
157 path += "/"
158 return path
160 def resolve_url_path(self, url_path: Sequence[str]) -> Asset | None:
161 if len(url_path) == 0:
162 return self
164 # Optimization: try common case where file extension has not been mangled
165 child = self.get_child(url_path[0])
166 if child is not None:
167 return child.resolve_url_path(url_path[1:])
169 if len(url_path) == 1:
170 # There are a number of ways a file extension can be mangled to form the
171 # url_path. (See `Asset.url_name`.) It can be lower-cased, and/or it could
172 # have had `artifact_extension` appended. It's not easy to check all these
173 # cases individually, so we'll just look for a matching child.
174 for child in self.children:
175 if child.url_name == url_path[0]:
176 return child
177 return None
179 @property
180 def url_content_path(self) -> str:
181 return self.url_path
184class File(Asset):
185 """Represents a static asset file."""