Coverage for src/coursebook/project.py: 98%

134 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-31 22:06 +0000

1"""The main interface for a `coursebook` project. 

2 

3Contents: 

4 Project: the settings, outline, sources, and style of a project. 

5 build: builds the files of the project in a folder. 

6 _LOGGER: logger for warnings about missing source files. 

7 _PathLike: type of a path that may be a `str`. 

8 _SUFFIXES: the text added to the name of the file for each section. 

9 

10""" 

11 

12from __future__ import annotations 

13 

14import concurrent.futures 

15import dataclasses 

16import logging 

17import pathlib 

18import tempfile 

19from typing import TYPE_CHECKING, TypeAlias 

20 

21from . import ( 

22 assembly, 

23 exporters, 

24 markdown, 

25 outline, 

26 settings, 

27 sources, 

28 styles, 

29 templates, 

30) 

31 

32if TYPE_CHECKING: 

33 from collections.abc import Iterable 

34 

35 # The classes are imported because the `outline` and `sources` fields of 

36 # `Project` hide the modules of the same names in its annotations. 

37 from .outline import OutlineEntry 

38 from .sources import Source 

39 

40_LOGGER = logging.getLogger(__name__) 

41_PathLike: TypeAlias = str | pathlib.Path 

42_SUFFIXES: dict[str, str] = {'text': '', 'slides': '_slides', 'notes': '_notes'} 

43 

44 

45@dataclasses.dataclass 

46class Project: 

47 """The settings, outline, sources, and style of a project. 

48 

49 Args: 

50 folder: the project folder, which holds the project file and the 

51 source files. 

52 settings: the settings in the project file. 

53 outline: the outline in the project file. 

54 sources: the source files, with keys that are their names (see 

55 `markdown.normalize_name`). 

56 style: the style that formats the project. 

57 

58 """ 

59 

60 folder: pathlib.Path 

61 settings: settings.Settings 

62 outline: outline.Outline 

63 sources: dict[str, sources.Source] 

64 style: styles.Style 

65 

66 """ Class Methods """ 

67 

68 @classmethod 

69 def from_folder(cls, folder: str | pathlib.Path = '.') -> Project: 

70 """Creates a project from the files in `folder`. 

71 

72 Args: 

73 folder: the project folder. Defaults to the current folder. 

74 

75 Returns: 

76 The project in `folder`. 

77 

78 Raises: 

79 FileNotFoundError: if `folder` has no project file. 

80 ValueError: if the project file, a source file, or a setting is 

81 not valid. 

82 

83 """ 

84 folder = pathlib.Path(folder).resolve() 

85 path = settings.find_project_file(folder) 

86 project_settings, project_outline = settings.read_project_file(path) 

87 if not len(project_outline): 

88 message = f'{path.name} has no outline entries' 

89 raise ValueError(message) 

90 style = styles.get_style( 

91 project_settings.style, 

92 number_headings = project_settings.number_headings, 

93 table_of_contents = project_settings.table_of_contents) 

94 project_sources = sources.load_sources(folder) 

95 _load_named_sources(folder, project_outline, project_sources) 

96 _warn_shared_sources(project_outline) 

97 return cls( 

98 folder = folder, 

99 settings = project_settings, 

100 outline = project_outline, 

101 sources = project_sources, 

102 style = style) 

103 

104 """ Properties """ 

105 

106 @property 

107 def include(self) -> list[str]: 

108 """Returns the sections to create files for. 

109 

110 Returns: 

111 The "Include" setting, or the sections of the style if it is not 

112 set. 

113 

114 """ 

115 if self.settings.include is not None: 

116 return list(self.settings.include) 

117 return list(self.style.sections) 

118 

119 @property 

120 def missing(self) -> list[OutlineEntry]: 

121 """Returns the outline entries that have no source file. 

122 

123 Returns: 

124 The entries without a source file, in outline order. 

125 

126 """ 

127 return [ 

128 entry for entry in self.outline 

129 if entry.name not in self.sources] 

130 

131 @property 

132 def output_folder(self) -> pathlib.Path: 

133 """Returns the folder for the created files. 

134 

135 Returns: 

136 The "Output Folder" setting, inside the project folder if it is a 

137 relative path. 

138 

139 """ 

140 return self._resolve(self.settings.output_folder) 

141 

142 @property 

143 def unused(self) -> list[Source]: 

144 """Returns the source files that are not in the outline. 

145 

146 Returns: 

147 The sources that no outline entry uses. 

148 

149 """ 

150 names = {entry.name for entry in self.outline} 

151 return [ 

152 source for name, source in self.sources.items() 

153 if name not in names] 

154 

155 """ Public Methods """ 

156 

157 def assemble(self, section: str) -> str: 

158 """Returns the combined markdown for `section`. 

159 

160 Args: 

161 section: 'text', 'slides', or 'notes'. 

162 

163 Returns: 

164 The markdown that is exported for `section`. 

165 

166 Raises: 

167 ValueError: if `section` is not a known section. 

168 

169 """ 

170 if section == 'slides': 

171 return assembly.assemble_slides( 

172 self.outline, 

173 self.sources, 

174 style = self.style, 

175 section_slides = self.settings.section_slides) 

176 if section in settings.SECTIONS: 

177 return assembly.assemble_document( 

178 self.outline, 

179 self.sources, 

180 section = section, 

181 style = self.style, 

182 include_empty = section == 'text', 

183 visuals_in_notes = self.settings.visuals_in_notes) 

184 message = f'unknown section "{section}"' 

185 raise ValueError(message) 

186 

187 def build( 

188 self, 

189 include: Iterable[str] | None = None, 

190 output_folder: _PathLike | None = None) -> list[pathlib.Path]: 

191 """Creates the files for the sections of the project. 

192 

193 Each section is exported to every format set for it (such as Word and 

194 PDF). The files are exported at the same time, each in its own pandoc 

195 process. 

196 

197 Args: 

198 include: the sections to create files for. Defaults to `None`, 

199 which uses `include`. 

200 output_folder: folder for the created files. Defaults to `None`, 

201 which uses `output_folder`. 

202 

203 Returns: 

204 The paths to the created files, in the order of `include` and then 

205 of the formats of each section. 

206 

207 Raises: 

208 FileNotFoundError: if a template, bibliography, or CSL file is not 

209 found. 

210 ImportError: if a format needs a package that is not installed. 

211 ValueError: if a section, format, or template is not valid, or a 

212 format cannot hold a section. 

213 RuntimeError: if pandoc fails. 

214 

215 """ 

216 sections = settings.normalize_sections( 

217 self.include if include is None else include) 

218 folder = ( 

219 self.output_folder if output_folder is None 

220 else self._resolve(output_folder)) 

221 for entry in self.missing: 

222 _LOGGER.warning( 

223 'No source file "%s" for outline entry "%s"', 

224 entry.file_name, entry.title) 

225 folder.mkdir(parents = True, exist_ok = True) 

226 stem = markdown.normalize_name(self.settings.name) or 'coursebook' 

227 with tempfile.TemporaryDirectory() as temporary: 

228 jobs = [] 

229 for section in sections: 

230 text = self.assemble(section) 

231 for name in self.settings.formats_for(section): 

232 exporter = exporters.get_exporter(name) 

233 if section not in exporter.sections: 

234 message = ( 

235 f'the {section} cannot be exported to {name} ' 

236 f'(use {", ".join(_formats_for(section))})') 

237 raise ValueError(message) 

238 options = self._get_options( 

239 section, exporter, pathlib.Path(temporary)) 

240 path = folder / ( 

241 f'{stem}{_SUFFIXES[section]}.{exporter.extension}') 

242 jobs.append((exporter, text, path, options)) 

243 with concurrent.futures.ThreadPoolExecutor() as pool: 

244 futures = [pool.submit(job[0].export, *job[1:]) for job in jobs] 

245 return [future.result() for future in futures] 

246 

247 """ Private Methods """ 

248 

249 def _get_options( 

250 self, 

251 section: str, 

252 exporter: exporters.Exporter, 

253 temporary: pathlib.Path) -> exporters.ExportOptions: 

254 """Returns the export options for `section`. 

255 

256 Args: 

257 section: 'text', 'slides', or 'notes'. 

258 exporter: the exporter for `section`. 

259 temporary: folder for converted and created templates. 

260 

261 Returns: 

262 The export options. 

263 

264 Raises: 

265 FileNotFoundError: if the template, a bibliography file, or the CSL 

266 file is not found. 

267 

268 """ 

269 bibliography = [self._find(path) for path in self.settings.bibliography] 

270 # The citation style only matters when there are citations to format. 

271 csl_name = self.settings.csl or self.style.csl 

272 csl = None 

273 if bibliography and csl_name: 

274 csl = templates.find_template( 

275 csl_name, application = 'csl', folder = self.folder) 

276 metadata = { 

277 'title': self.settings.name, 

278 'subtitle': self.settings.subtitle, 

279 'author': self.settings.author, 

280 'date': self.settings.date} 

281 if section == 'notes': 

282 subtitle = self.settings.subtitle 

283 metadata['subtitle'] = f'{subtitle}: Notes' if subtitle else 'Notes' 

284 return exporters.ExportOptions( 

285 template = self._get_template(section, exporter, temporary), 

286 metadata = metadata, 

287 resource_paths = [ 

288 self.folder, self._resolve(self.settings.visuals_folder)], 

289 bibliography = bibliography, 

290 csl = csl, 

291 table_of_contents = self.style.table_of_contents, 

292 formatting = self.style.formatting) 

293 

294 def _get_template( 

295 self, 

296 section: str, 

297 exporter: exporters.Exporter, 

298 temporary: pathlib.Path) -> pathlib.Path | None: 

299 """Returns the reference document for `section`. 

300 

301 Args: 

302 section: 'text', 'slides', or 'notes'. 

303 exporter: the exporter for `section`. 

304 temporary: folder for converted and created templates. 

305 

306 Returns: 

307 The path to the reference document, or `None` to use pandoc's 

308 default or if the exporter does not use one. 

309 

310 """ 

311 if exporter.application not in {'word', 'powerpoint'}: 

312 return None 

313 name = self.settings.template_for(section) 

314 if name is None: 

315 if exporter.application == 'word': 

316 name = self.style.text_template 

317 elif exporter.application == 'powerpoint': 317 ↛ 319line 317 didn't jump to line 319 because the condition on line 317 was always true

318 name = self.style.slides_template 

319 if name is None: 

320 if exporter.application != 'word': 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 return None 

322 path = temporary / f'{section}_reference.docx' 

323 return templates.create_word_template(self.style.formatting, path) 

324 found = templates.find_template( 

325 name, application = exporter.application, folder = self.folder) 

326 return templates.prepare_template(found, temporary / section) 

327 

328 def _find(self, name: str) -> pathlib.Path: 

329 """Returns the path to a file named in the settings. 

330 

331 Args: 

332 name: a path, absolute or relative to the project folder. 

333 

334 Returns: 

335 The path to the file. 

336 

337 Raises: 

338 FileNotFoundError: if the file does not exist. 

339 

340 """ 

341 path = self._resolve(name) 

342 if not path.is_file(): 

343 message = f'file {name} not found in {self.folder}' 

344 raise FileNotFoundError(message) 

345 return path 

346 

347 def _resolve(self, name: str | pathlib.Path) -> pathlib.Path: 

348 """Returns a path, placing a relative path inside the project folder. 

349 

350 Args: 

351 name: an absolute path or a path relative to the project folder. 

352 

353 Returns: 

354 The absolute path. 

355 

356 """ 

357 return self.folder / pathlib.Path(name).expanduser() 

358 

359 

360""" Public Functions """ 

361 

362 

363def build( 

364 folder: str | pathlib.Path = '.', 

365 include: Iterable[str] | None = None, 

366 output_folder: _PathLike | None = None) -> list[pathlib.Path]: 

367 """Builds the files of the project in `folder`. 

368 

369 Args: 

370 folder: the project folder. Defaults to the current folder. 

371 include: the sections to create files for. Defaults to `None`, which 

372 uses the "Include" setting or the sections of the style. 

373 output_folder: folder for the created files. Defaults to `None`, 

374 which uses the "Output Folder" setting. 

375 

376 Returns: 

377 The paths to the created files. 

378 

379 """ 

380 project = Project.from_folder(folder) 

381 return project.build(include = include, output_folder = output_folder) 

382 

383 

384""" Private Functions """ 

385 

386 

387def _formats_for(section: str) -> list[str]: 

388 """Returns the names of the formats that can hold `section`. 

389 

390 Args: 

391 section: 'text', 'slides', or 'notes'. 

392 

393 Returns: 

394 The first alias of each exporter that can export `section`. 

395 

396 """ 

397 return sorted({ 

398 exporter.aliases[0] for exporter in exporters.Exporter.registry.values() 

399 if exporter.aliases and section in exporter.sections}) 

400 

401 

402def _load_named_sources( 

403 folder: pathlib.Path, 

404 project_outline: outline.Outline, 

405 project_sources: dict[str, sources.Source]) -> None: 

406 """Loads the source files that the outline names (with '| file.md'). 

407 

408 Files in the project folder are already loaded, so this loads the files in 

409 subfolders and adds them to `project_sources`. 

410 

411 Args: 

412 folder: the project folder. 

413 project_outline: the outline of the project. 

414 project_sources: the loaded sources, which are changed in place. 

415 

416 Raises: 

417 ValueError: if a named file is not valid. 

418 

419 """ 

420 for entry in project_outline: 

421 if entry.file is None or entry.name in project_sources: 

422 continue 

423 path = folder / entry.file 

424 if path.is_file(): 424 ↛ 420line 424 didn't jump to line 420 because the condition on line 424 was always true

425 project_sources[entry.name] = sources.Source.from_file( 

426 path, name = entry.name) 

427 

428 

429def _warn_shared_sources(project_outline: outline.Outline) -> None: 

430 """Warns when outline entries would use the same source file. 

431 

432 Args: 

433 project_outline: the outline of the project. 

434 

435 """ 

436 seen: dict[str, outline.OutlineEntry] = {} 

437 for entry in project_outline: 

438 if entry.name in seen: 

439 _LOGGER.warning( 

440 'Outline entries "%s" and "%s" both use "%s" (add ' 

441 '"| other_file.md" to one of them to give it its own file)', 

442 seen[entry.name].title, entry.title, entry.file_name) 

443 else: 

444 seen[entry.name] = entry