Coverage for src/lektor_ng/build_programs.py: 97%

137 statements  

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

1import os 

2import shutil 

3from itertools import chain 

4 

5from lektor_ng.assets import Directory, File 

6from lektor_ng.constants import PRIMARY_ALT 

7from lektor_ng.db import Attachment, Page 

8from lektor_ng.exception import LektorException 

9 

10 

11class BuildError(LektorException): 

12 pass 

13 

14 

15builtin_build_programs = [] 

16 

17 

18def buildprogram(source_cls): 

19 def decorator(builder_cls): 

20 builtin_build_programs.append((source_cls, builder_cls)) 

21 return builder_cls 

22 

23 return decorator 

24 

25 

26class SourceInfo: 

27 """Holds some information about a source file for indexing into the 

28 build state. 

29 """ 

30 

31 def __init__(self, path, filename, alt=PRIMARY_ALT, type="unknown", title_i18n=None): 

32 self.path = path 

33 self.alt = alt 

34 self.filename = filename 

35 self.type = type 

36 self.title_i18n = {} 

37 

38 en_title = self.path 

39 if "en" in title_i18n: 

40 en_title = title_i18n["en"] 

41 for key, value in title_i18n.items(): 

42 if key == "en": 

43 continue 

44 if value != en_title: 

45 self.title_i18n[key] = value 

46 self.title_i18n["en"] = en_title 

47 

48 

49class BuildProgram: 

50 def __init__(self, source, build_state): 

51 self.source = source 

52 self.build_state = build_state 

53 self.artifacts = [] 

54 self._built = False 

55 

56 @property 

57 def primary_artifact(self): 

58 """Returns the primary artifact for this build program. By 

59 default this is the first artifact produced. This needs to be the 

60 one that corresponds to the URL of the source if it has one. 

61 """ 

62 try: 

63 return self.artifacts[0] 

64 except IndexError: 

65 return None 

66 

67 def describe_source_record(self): 

68 """Can be used to describe the source info by returning a 

69 :class:`SourceInfo` object. This is indexed by the builder into 

70 the build state so that the UI can quickly find files without 

71 having to scan the file system. 

72 """ 

73 

74 def build(self): 

75 """Invokes the build program.""" 

76 if self._built: 

77 raise RuntimeError("This build program was already used.") 

78 self._built = True 

79 

80 self.produce_artifacts() 

81 

82 sub_artifacts = [] 

83 failures = [] 

84 

85 gen = self.build_state.builder 

86 

87 def _build(artifact, build_func): 

88 ctx = gen.build_artifact(artifact, build_func) 

89 if ctx is not None: 

90 if ctx.exc_info is not None: 

91 failures.append(ctx.exc_info) 

92 else: 

93 sub_artifacts.extend(ctx.sub_artifacts) 

94 

95 # Step one is building the artifacts that this build program 

96 # knows about. 

97 for artifact in self.artifacts: 

98 _build(artifact, self.build_artifact) 

99 

100 # For as long as our ctx keeps producing sub artifacts, we 

101 # want to process them as well. 

102 while sub_artifacts and not failures: 

103 artifact, build_func = sub_artifacts.pop() 

104 _build(artifact, build_func) 

105 

106 # If we failed anywhere we want to mark *all* artifacts as dirty. 

107 # This means that if a sub-artifact fails we also rebuild the 

108 # parent next time around. 

109 if failures: 

110 for artifact in self.artifacts: 

111 artifact.set_dirty_flag() 

112 

113 def produce_artifacts(self): 

114 """This produces the artifacts for building. Usually this only 

115 produces a single artifact. 

116 """ 

117 

118 def declare_artifact(self, artifact_name, sources=None, extra=None): 

119 """This declares an artifact to be built in this program.""" 

120 self.artifacts.append( 

121 self.build_state.new_artifact( 

122 artifact_name=artifact_name, 

123 sources=sources, 

124 source_obj=self.source, 

125 extra=extra, 

126 ) 

127 ) 

128 

129 def build_artifact(self, artifact): 

130 """This is invoked for each artifact declared.""" 

131 

132 def iter_child_sources(self): 

133 """This allows a build program to produce children that also need 

134 building. An individual build never recurses down to this, but 

135 a `build_all` will use this. 

136 """ 

137 # pylint: disable=no-self-use 

138 return iter(()) 

139 

140 

141@buildprogram(Page) 

142class PageBuildProgram(BuildProgram): 

143 def describe_source_record(self): 

144 # When we describe the source record we need to consider that a 

145 # page has multiple source file names but only one will actually 

146 # be used. The order of the source iter is in order the files are 

147 # attempted to be read. So we go with the first that actually 

148 # exists and then return that. 

149 for filename in self.source.iter_source_filenames(): 

150 if os.path.isfile(filename): 

151 return SourceInfo( 

152 path=self.source.path, 

153 alt=self.source["_source_alt"], 

154 filename=filename, 

155 type="page", 

156 title_i18n=self.source.get_record_label_i18n(), 

157 ) 

158 return None 

159 

160 def produce_artifacts(self): 

161 pagination_enabled = self.source.datamodel.pagination_config.enabled 

162 

163 if self.source.is_visible and (self.source.page_num is not None or not pagination_enabled): 

164 artifact_name = self.source.url_path 

165 if artifact_name.endswith("/"): 

166 artifact_name += "index.html" 

167 

168 self.declare_artifact(artifact_name, sources=list(self.source.iter_source_filenames())) 

169 

170 def build_artifact(self, artifact): 

171 # Record dependecies on all our sources and datamodel 

172 self.source.pad.db.track_record_dependency(self.source) 

173 

174 try: 

175 self.source.url_path.encode("ascii") 

176 except UnicodeError as error: 

177 raise BuildError( 

178 "The URL for this record contains non ASCII " 

179 "characters. This is currently not supported " 

180 f"for portability reasons ({self.source.url_path!r})." 

181 ) from error 

182 

183 artifact.render_template_into(self.source["_template"], this=self.source) 

184 

185 def _iter_paginated_children(self): 

186 total = self.source.datamodel.pagination_config.count_pages(self.source) 

187 for page_num in range(1, total + 1): 

188 yield Page(self.source.pad, self.source._data, page_num=page_num) 

189 

190 def iter_child_sources(self): 

191 p_config = self.source.datamodel.pagination_config 

192 pagination_enabled = p_config.enabled 

193 child_sources = [] 

194 

195 # So this requires a bit of explanation: 

196 # 

197 # the basic logic is that if we have pagination enabled then we 

198 # need to consider two cases: 

199 # 

200 # 1. our build program has page_num = None which means that we 

201 # are not yet pointing to a page. In that case we want to 

202 # iter over all children which will yield the pages. 

203 # 2. we are pointing to a page, then our child sources are the 

204 # items that are shown on that page. 

205 # 

206 # In addition, attachments and pages excluded from pagination are 

207 # linked to the page with page_num = None. 

208 # 

209 # If pagination is disabled, all children and attachments are linked 

210 # to this page. 

211 all_children = self.source.children.include_undiscoverable(True) 

212 all_children = all_children.include_hidden(True) 

213 if pagination_enabled: 

214 if self.source.page_num is None: 

215 child_sources.append(self._iter_paginated_children()) 

216 pq = p_config.get_pagination_query(self.source) 

217 child_sources.append(set(all_children) - set(pq)) 

218 child_sources.append(self.source.attachments) 

219 else: 

220 child_sources.append(self.source.pagination.items) 

221 else: 

222 child_sources.append(all_children) 

223 child_sources.append(self.source.attachments) 

224 

225 return chain(*child_sources) 

226 

227 

228@buildprogram(Attachment) 

229class AttachmentBuildProgram(BuildProgram): 

230 def describe_source_record(self): 

231 return SourceInfo( 

232 path=self.source.path, 

233 alt=self.source.alt, 

234 filename=self.source.attachment_filename, 

235 type="attachment", 

236 title_i18n={"en": self.source["_id"]}, 

237 ) 

238 

239 def produce_artifacts(self): 

240 primary_alt = self.build_state.config.primary_alternative or PRIMARY_ALT 

241 if self.source.is_visible and self.source.alt == primary_alt: 

242 self.declare_artifact(self.source.url_path, sources=list(self.source.iter_source_filenames())) 

243 

244 def build_artifact(self, artifact): 

245 with artifact.open("wb") as df: 

246 with open(self.source.attachment_filename, "rb") as sf: 

247 shutil.copyfileobj(sf, df) 

248 

249 

250@buildprogram(File) 

251class FileAssetBuildProgram(BuildProgram): 

252 def produce_artifacts(self): 

253 self.declare_artifact(self.source.artifact_name, sources=[self.source.source_filename]) 

254 

255 def build_artifact(self, artifact): 

256 with artifact.open("wb") as df: 

257 with open(self.source.source_filename, "rb") as sf: 

258 shutil.copyfileobj(sf, df) 

259 

260 

261@buildprogram(Directory) 

262class DirectoryAssetBuildProgram(BuildProgram): 

263 def iter_child_sources(self): 

264 return self.source.children