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
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 22:06 +0000
1"""The main interface for a `coursebook` project.
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.
10"""
12from __future__ import annotations
14import concurrent.futures
15import dataclasses
16import logging
17import pathlib
18import tempfile
19from typing import TYPE_CHECKING, TypeAlias
21from . import (
22 assembly,
23 exporters,
24 markdown,
25 outline,
26 settings,
27 sources,
28 styles,
29 templates,
30)
32if TYPE_CHECKING:
33 from collections.abc import Iterable
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
40_LOGGER = logging.getLogger(__name__)
41_PathLike: TypeAlias = str | pathlib.Path
42_SUFFIXES: dict[str, str] = {'text': '', 'slides': '_slides', 'notes': '_notes'}
45@dataclasses.dataclass
46class Project:
47 """The settings, outline, sources, and style of a project.
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.
58 """
60 folder: pathlib.Path
61 settings: settings.Settings
62 outline: outline.Outline
63 sources: dict[str, sources.Source]
64 style: styles.Style
66 """ Class Methods """
68 @classmethod
69 def from_folder(cls, folder: str | pathlib.Path = '.') -> Project:
70 """Creates a project from the files in `folder`.
72 Args:
73 folder: the project folder. Defaults to the current folder.
75 Returns:
76 The project in `folder`.
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.
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)
104 """ Properties """
106 @property
107 def include(self) -> list[str]:
108 """Returns the sections to create files for.
110 Returns:
111 The "Include" setting, or the sections of the style if it is not
112 set.
114 """
115 if self.settings.include is not None:
116 return list(self.settings.include)
117 return list(self.style.sections)
119 @property
120 def missing(self) -> list[OutlineEntry]:
121 """Returns the outline entries that have no source file.
123 Returns:
124 The entries without a source file, in outline order.
126 """
127 return [
128 entry for entry in self.outline
129 if entry.name not in self.sources]
131 @property
132 def output_folder(self) -> pathlib.Path:
133 """Returns the folder for the created files.
135 Returns:
136 The "Output Folder" setting, inside the project folder if it is a
137 relative path.
139 """
140 return self._resolve(self.settings.output_folder)
142 @property
143 def unused(self) -> list[Source]:
144 """Returns the source files that are not in the outline.
146 Returns:
147 The sources that no outline entry uses.
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]
155 """ Public Methods """
157 def assemble(self, section: str) -> str:
158 """Returns the combined markdown for `section`.
160 Args:
161 section: 'text', 'slides', or 'notes'.
163 Returns:
164 The markdown that is exported for `section`.
166 Raises:
167 ValueError: if `section` is not a known section.
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)
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.
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.
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`.
203 Returns:
204 The paths to the created files, in the order of `include` and then
205 of the formats of each section.
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.
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]
247 """ Private Methods """
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`.
256 Args:
257 section: 'text', 'slides', or 'notes'.
258 exporter: the exporter for `section`.
259 temporary: folder for converted and created templates.
261 Returns:
262 The export options.
264 Raises:
265 FileNotFoundError: if the template, a bibliography file, or the CSL
266 file is not found.
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)
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`.
301 Args:
302 section: 'text', 'slides', or 'notes'.
303 exporter: the exporter for `section`.
304 temporary: folder for converted and created templates.
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.
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)
328 def _find(self, name: str) -> pathlib.Path:
329 """Returns the path to a file named in the settings.
331 Args:
332 name: a path, absolute or relative to the project folder.
334 Returns:
335 The path to the file.
337 Raises:
338 FileNotFoundError: if the file does not exist.
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
347 def _resolve(self, name: str | pathlib.Path) -> pathlib.Path:
348 """Returns a path, placing a relative path inside the project folder.
350 Args:
351 name: an absolute path or a path relative to the project folder.
353 Returns:
354 The absolute path.
356 """
357 return self.folder / pathlib.Path(name).expanduser()
360""" Public Functions """
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`.
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.
376 Returns:
377 The paths to the created files.
379 """
380 project = Project.from_folder(folder)
381 return project.build(include = include, output_folder = output_folder)
384""" Private Functions """
387def _formats_for(section: str) -> list[str]:
388 """Returns the names of the formats that can hold `section`.
390 Args:
391 section: 'text', 'slides', or 'notes'.
393 Returns:
394 The first alias of each exporter that can export `section`.
396 """
397 return sorted({
398 exporter.aliases[0] for exporter in exporters.Exporter.registry.values()
399 if exporter.aliases and section in exporter.sections})
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').
408 Files in the project folder are already loaded, so this loads the files in
409 subfolders and adds them to `project_sources`.
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.
416 Raises:
417 ValueError: if a named file is not valid.
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)
429def _warn_shared_sources(project_outline: outline.Outline) -> None:
430 """Warns when outline entries would use the same source file.
432 Args:
433 project_outline: the outline of the project.
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