Coverage for src/lektor_ng/assets.py: 100%

113 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-03 16:04 +0000

1from __future__ import annotations 

2 

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 

12 

13from werkzeug.utils import cached_property 

14 

15from lektor_ng.sourceobj import SourceObject 

16from lektor_ng.utils import DeprecatedWarning, deprecated 

17 

18if TYPE_CHECKING: 

19 from _typeshed import StrPath 

20 

21 from lektor_ng.db import Pad 

22 

23 

24def get_asset_root(pad: Pad, asset_roots: Iterable[StrPath]) -> Directory: 

25 """Get the merged asset root. 

26 

27 This represents a logical merging or overlaying of (possibly) multiple asset trees, 

28 rooted at directories given by ``asset_roots``. 

29 

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) 

35 

36 

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) 

44 

45 

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) 

51 

52 

53class Asset(SourceObject): 

54 source_classification = "asset" 

55 artifact_extension = "" 

56 

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 

62 

63 def iter_source_filenames(self) -> Generator[str]: 

64 yield from map(str, self._paths) 

65 

66 @property 

67 def url_name(self) -> str: 

68 name = self.name 

69 base, ext = posixpath.splitext(name) 

70 

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

75 

76 return base + ext + self.artifact_extension 

77 

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) 

83 

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 

89 

90 @property 

91 def children(self) -> Iterable[Asset]: 

92 return () 

93 

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 

99 

100 def resolve_url_path(self, url_path: Sequence[str]) -> Asset | None: 

101 if len(url_path) == 0: 

102 return self 

103 return None 

104 

105 def __repr__(self) -> str: 

106 return f"<{self.__class__.__name__} {self.artifact_name!r}>" 

107 

108 

109class Directory(Asset): 

110 """Represents a merged set of asset directories.""" 

111 

112 @property 

113 def children(self) -> Iterable[Asset]: 

114 return self._children_by_name.values() 

115 

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) 

120 

121 @cached_property 

122 def _children_by_name(self) -> dict[str, Asset]: 

123 return {asset.name: asset for asset in self._iter_children()} 

124 

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) 

133 

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

148 

149 @property 

150 def url_name(self) -> str: 

151 return self.name + self.artifact_extension 

152 

153 @property 

154 def url_path(self) -> str: 

155 path = super().url_path 

156 if not path.endswith("/"): 

157 path += "/" 

158 return path 

159 

160 def resolve_url_path(self, url_path: Sequence[str]) -> Asset | None: 

161 if len(url_path) == 0: 

162 return self 

163 

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:]) 

168 

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 

178 

179 @property 

180 def url_content_path(self) -> str: 

181 return self.url_path 

182 

183 

184class File(Asset): 

185 """Represents a static asset file."""