Coverage for src/coursebook/settings.py: 99%
183 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"""Loads the settings and outline of a `coursebook` project.
3A project file is named 'project' with an 'ini', 'toml', 'yaml', or 'yml'
4extension. It has a "Project" section with settings and an "Outline" section
5with the structure of the project.
7The outline of an `ini` file is read line by line instead of with
8`configparser` because outlines repeat labels (such as an 'A' under 'I' and
9another 'A' under 'II'), which `configparser` does not allow. For the same
10reason, the outline in a `toml` or `yaml` file is a list instead of a table.
12Contents:
13 OutlineLines: type of the outline lines read from a project file.
14 PROJECT_FILE_NAMES: the names that a project file may have.
15 SECTION_ALIASES: other names that may be used for each section.
16 SECTIONS: the kinds of sections that a source file may have.
17 Settings: the options in the "Project" section of a project file.
18 find_project_file: returns the path to the project file in a folder.
19 normalize_sections: converts section names to their standard names.
20 read_project_file: returns the settings and outline in a project file.
21 _INI_SECTION: matches a section header in an `ini` file.
22 _LIST_SEPARATORS: the separators used to split `ini` values into lists.
23 _SETTING_ALIASES: other names that may be used for some settings.
25"""
27from __future__ import annotations
29import configparser
30import dataclasses
31import pathlib
32import re
33import tomllib
34from typing import TYPE_CHECKING, Any, TypeAlias
36import camina
37import yaml
39from . import markdown, outline
41if TYPE_CHECKING:
42 from collections.abc import Iterable, Mapping
44OutlineLines: TypeAlias = list[outline.OutlineLine]
45PROJECT_FILE_NAMES: tuple[str, ...] = (
46 'project.ini', 'project.toml', 'project.yaml', 'project.yml')
47SECTIONS: tuple[str, ...] = ('text', 'slides', 'notes')
48SECTION_ALIASES: dict[str, str] = {
49 'text': 'text',
50 'manuscript': 'text',
51 'slides': 'slides',
52 'notes': 'notes'}
53# Authors are split on semicolons because names may include commas (such as
54# 'Martin Luther King, Jr.').
55_LIST_SEPARATORS: dict[str, str] = {
56 'author': r'[;\n]'}
57_SETTING_ALIASES: dict[str, str] = {
58 'authors': 'author',
59 'notes_formats': 'notes_format',
60 'project_name': 'name',
61 'slide_template': 'slides_template',
62 'slides_formats': 'slides_format',
63 'text_formats': 'text_format',
64 'title': 'name',
65 'toc': 'table_of_contents'}
66_INI_SECTION: re.Pattern[str] = re.compile(r'^\s*\[([^\]]+)\]\s*$')
69@dataclasses.dataclass
70class Settings:
71 """The options in the "Project" section of a project file.
73 Names in a project file are matched without regard to case, spaces, or
74 underscores. So, 'Output Folder' sets `output_folder`.
76 Args:
77 name: the title of the project. It is also used (in lower case with
78 underscores) for the names of the created files. Defaults to
79 'Untitled'.
80 style: name of the `Style` that formats the project. Defaults to
81 'coursebook'.
82 include: the sections to create files for ('text', 'slides', and/or
83 'notes'). Defaults to `None`, which uses the sections of the
84 style.
85 author: the authors of the project. Defaults to an empty `list`.
86 subtitle: the subtitle of the project. Defaults to `None`.
87 date: the date shown on the title page and title slide. Defaults to
88 `None`.
89 output_folder: folder for the created files. A relative path is placed
90 inside the project folder. Defaults to 'output'.
91 visuals_folder: folder with the images used in the source files. A
92 relative path is placed inside the project folder. Defaults to
93 'visuals'.
94 text_template: name or path of the Word template for the text.
95 Defaults to `None`, which uses the template of the style.
96 slides_template: name or path of the PowerPoint template for the
97 slides. Defaults to `None`, which uses the template of the style.
98 notes_template: name or path of the Word template for the notes.
99 Defaults to `None`, which uses `text_template`.
100 text_format: the formats of the text files ('word', 'pdf', and/or
101 'html'). Defaults to ['word'].
102 slides_format: the formats of the slides files. Defaults to
103 ['powerpoint'].
104 notes_format: the formats of the notes files ('word', 'pdf', and/or
105 'html'). Defaults to ['word'].
106 bibliography: paths to bibliography files (such as BibTeX files) for
107 citations. Defaults to an empty `list`.
108 csl: name or path of a Citation Style Language file for formatting
109 citations. A name is looked for in the project folder and then
110 among the styles included with `coursebook` (such as
111 'bluebook-law-review'). Defaults to `None`, which uses the style's
112 CSL or, if it has none, pandoc's default (Chicago author-date).
113 table_of_contents: whether to add a table of contents to the text and
114 notes. Defaults to `None`, which uses the setting of the style.
115 number_headings: whether to put the outline labels in headings (such
116 as 'I. Introduction'). Defaults to `None`, which uses the setting
117 of the style.
118 section_slides: whether to add a slide with the title of each outline
119 entry before its slides. Defaults to `True`.
120 visuals_in_notes: whether to copy the images in the text of each
121 source file into its notes. A source file can change this in its
122 front matter. Defaults to `False`.
123 extras: settings that `coursebook` does not use itself, such as those
124 for a custom `Style`. Defaults to an empty `dict`.
126 """
128 name: str = 'Untitled'
129 style: str = 'coursebook'
130 include: list[str] | None = None
131 author: list[str] = dataclasses.field(default_factory = list)
132 subtitle: str | None = None
133 date: str | None = None
134 output_folder: str = 'output'
135 visuals_folder: str = 'visuals'
136 text_template: str | None = None
137 slides_template: str | None = None
138 notes_template: str | None = None
139 text_format: list[str] = dataclasses.field(
140 default_factory = lambda: ['word'])
141 slides_format: list[str] = dataclasses.field(
142 default_factory = lambda: ['powerpoint'])
143 notes_format: list[str] = dataclasses.field(
144 default_factory = lambda: ['word'])
145 bibliography: list[str] = dataclasses.field(default_factory = list)
146 csl: str | None = None
147 table_of_contents: bool | None = None
148 number_headings: bool | None = None
149 section_slides: bool = True
150 visuals_in_notes: bool = False
151 extras: dict[str, Any] = dataclasses.field(default_factory = dict)
153 """ Initialization Methods """
155 def __post_init__(self) -> None:
156 """Validates and normalizes the sections in `include`.
158 Raises:
159 ValueError: if `include` names an unknown section.
161 """
162 if self.include is not None:
163 self.include = normalize_sections(self.include)
165 """ Class Methods """
167 @classmethod
168 def from_mapping(
169 cls,
170 mapping: Mapping[str, Any],
171 infer_types: bool = False) -> Settings:
172 """Creates settings from a `dict` of options.
174 Args:
175 mapping: options with names as they appear in a project file (such
176 as 'Output Folder').
177 infer_types: whether to convert `str` values to other types (such
178 as `bool` or `list`). This should be `True` for `ini` files,
179 which only store `str` values. Defaults to `False`.
181 Returns:
182 Settings with the options in `mapping`.
184 Raises:
185 ValueError: if a setting has a value of the wrong type or
186 `include` names an unknown section.
188 """
189 fields = {field.name: field for field in dataclasses.fields(cls)}
190 kwargs: dict[str, Any] = {}
191 extras: dict[str, Any] = {}
192 for key, value in mapping.items():
193 name = markdown.normalize_name(str(key))
194 name = _SETTING_ALIASES.get(name, name)
195 if name not in fields or name == 'extras':
196 extras[name] = camina.typify(value) if infer_types else value
197 continue
198 kwargs[name] = _convert_setting(name, value, fields[name].type)
199 return cls(**kwargs, extras = extras)
201 """ Public Methods """
203 def formats_for(self, section: str) -> list[str]:
204 """Returns the formats of the files for `section`.
206 Args:
207 section: 'text', 'slides', or 'notes'.
209 Returns:
210 The names of the formats (such as ['word', 'pdf']).
212 """
213 return list(getattr(self, f'{section}_format'))
215 def template_for(self, section: str) -> str | None:
216 """Returns the template setting for `section`.
218 Args:
219 section: 'text', 'slides', or 'notes'.
221 Returns:
222 The name or path of the template, or `None` if the setting is not
223 set. The notes use `text_template` if `notes_template` is not
224 set.
226 """
227 template = getattr(self, f'{section}_template')
228 if template is None and section == 'notes':
229 template = self.text_template
230 return None if template is None else str(template)
233""" Public Functions """
236def find_project_file(folder: str | pathlib.Path) -> pathlib.Path:
237 """Returns the path to the project file in `folder`.
239 Args:
240 folder: the project folder.
242 Returns:
243 The path to the project file.
245 Raises:
246 FileNotFoundError: if `folder` does not exist or has no project file.
247 ValueError: if `folder` has more than one project file.
249 """
250 folder = pathlib.Path(folder)
251 if not folder.is_dir():
252 message = f'project folder {folder} does not exist'
253 raise FileNotFoundError(message)
254 names = {path.name.lower(): path for path in folder.iterdir()}
255 found = [names[name] for name in PROJECT_FILE_NAMES if name in names]
256 if not found:
257 message = (
258 f'no project file in {folder} (expected one of: '
259 f'{", ".join(PROJECT_FILE_NAMES)})')
260 raise FileNotFoundError(message)
261 if len(found) > 1:
262 listed = ', '.join(path.name for path in found)
263 message = f'{folder} has more than one project file ({listed})'
264 raise ValueError(message)
265 return found[0]
268def normalize_sections(sections: Iterable[str]) -> list[str]:
269 """Converts section names (and their aliases) to their standard names.
271 Args:
272 sections: section names (such as 'manuscript' or 'Slides').
274 Returns:
275 The standard names of the sections ('text', 'slides', or 'notes'),
276 without duplicates.
278 Raises:
279 ValueError: if a name is not a known section.
281 """
282 normalized: list[str] = []
283 for section in sections:
284 name = SECTION_ALIASES.get(markdown.normalize_name(section))
285 if name is None:
286 known = ', '.join(SECTIONS)
287 message = f'unknown section "{section}" (use {known})'
288 raise ValueError(message)
289 if name not in normalized: 289 ↛ 283line 289 didn't jump to line 283 because the condition on line 289 was always true
290 normalized.append(name)
291 return normalized
294def read_project_file(
295 path: str | pathlib.Path) -> tuple[Settings, outline.Outline]:
296 """Returns the settings and outline in a project file.
298 Args:
299 path: path to an `ini`, `toml`, `yaml`, or `yml` project file.
301 Returns:
302 The settings from the "Project" section and the outline from the
303 "Outline" section.
305 Raises:
306 FileNotFoundError: if `path` is not a file.
307 ValueError: if the file type is not supported or the contents are not
308 valid.
310 """
311 path = pathlib.Path(path)
312 if not path.is_file():
313 message = f'project file {path} not found'
314 raise FileNotFoundError(message)
315 suffix = path.suffix.lower()
316 if suffix == '.ini':
317 options, lines = _read_ini(path)
318 settings = Settings.from_mapping(options, infer_types = True)
319 elif suffix in {'.toml', '.yaml', '.yml'}:
320 options, lines = _read_structured(path)
321 settings = Settings.from_mapping(options)
322 else:
323 message = f'{path.name} is not an ini, toml, or yaml file'
324 raise ValueError(message)
325 return settings, outline.Outline.from_lines(lines)
328""" Private Functions """
331def _convert_setting(name: str, value: Any, annotation: Any) -> Any:
332 """Converts `value` to the type of the setting `name`.
334 Args:
335 name: the name of a `Settings` field.
336 value: the value from a project file.
337 annotation: the type annotation of the field (as a `str`, since
338 annotations are postponed).
340 Returns:
341 The converted value.
343 Raises:
344 ValueError: if `value` cannot be converted.
346 """
347 annotation = str(annotation)
348 if annotation.startswith('list'):
349 if isinstance(value, str):
350 separator = _LIST_SEPARATORS.get(name, r'[,\n]')
351 return [
352 item.strip() for item in re.split(separator, value)
353 if item.strip()]
354 if isinstance(value, list | tuple): 354 ↛ 356line 354 didn't jump to line 356 because the condition on line 354 was always true
355 return [str(item) for item in value]
356 return [str(value)]
357 if annotation.startswith('bool'):
358 converted = camina.typify(value) if isinstance(value, str) else value
359 if not isinstance(converted, bool):
360 message = f'the "{name}" setting must be yes or no, not "{value}"'
361 raise ValueError(message)
362 return converted
363 return str(value).strip()
366def _outline_items(items: Any) -> OutlineLines:
367 """Converts the outline in a `toml` or `yaml` file to outline lines.
369 Args:
370 items: a `list` whose items are lines (such as 'A: History'),
371 lists of a label, a title, and (optionally) a file, or (in
372 `yaml`) one-item mappings of a label to a title.
374 Returns:
375 The outline lines and tuples of their parts.
377 Raises:
378 ValueError: if `items` is not a `list` or has an item of another form.
380 """
381 if not isinstance(items, list):
382 message = 'the outline must be a list (such as ["I: Introduction"])'
383 raise ValueError(message) # noqa: TRY004
384 lines: OutlineLines = []
385 for item in items:
386 if isinstance(item, str):
387 lines.append(item)
388 elif isinstance(item, dict) and len(item) == 1:
389 label, title = next(iter(item.items()))
390 lines.append((str(label), str(title)))
391 elif isinstance(item, list | tuple) and len(item) in {2, 3}:
392 lines.append(tuple(str(part) for part in item))
393 else:
394 message = f'outline item {item!r} needs a label and a title'
395 raise ValueError(message)
396 return lines
399def _read_ini(path: pathlib.Path) -> tuple[dict[str, Any], OutlineLines]:
400 """Reads the project options and outline lines from an `ini` file.
402 Args:
403 path: path to an `ini` file.
405 Returns:
406 The options in the "Project" section and the lines in the "Outline"
407 section.
409 Raises:
410 ValueError: if the file has no "Project" section or is not a valid
411 `ini` file.
413 """
414 other: list[str] = []
415 lines: OutlineLines = []
416 in_outline = False
417 for line in path.read_text(encoding = 'utf-8').splitlines():
418 header = _INI_SECTION.match(line)
419 if header:
420 in_outline = markdown.normalize_name(header.group(1)) == 'outline'
421 if in_outline:
422 continue
423 if in_outline:
424 if not line.strip().startswith(('#', ';')):
425 lines.append(line)
426 else:
427 other.append(line)
428 parser = configparser.ConfigParser(interpolation = None)
429 parser.optionxform = str # type: ignore[assignment,method-assign]
430 try:
431 parser.read_string('\n'.join(other), source = str(path))
432 except configparser.Error as error:
433 message = f'{path.name} is not a valid ini file: {error}'
434 raise ValueError(message) from error
435 sections = {
436 markdown.normalize_name(name): dict(parser[name])
437 for name in parser.sections()}
438 if 'project' not in sections:
439 message = f'{path.name} has no [Project] section'
440 raise ValueError(message)
441 return sections['project'], lines
444def _read_structured(
445 path: pathlib.Path) -> tuple[dict[str, Any], OutlineLines]:
446 """Reads the project options and outline from a `toml` or `yaml` file.
448 The outline may be at the top level of the file or inside the "project"
449 table.
451 Args:
452 path: path to a `toml`, `yaml`, or `yml` file.
454 Returns:
455 The options in the "project" table and the outline lines.
457 Raises:
458 ValueError: if the file has no "project" table or is not valid.
460 """
461 text = path.read_text(encoding = 'utf-8')
462 try:
463 if path.suffix.lower() == '.toml':
464 data = tomllib.loads(text)
465 else:
466 data = yaml.safe_load(text) or {}
467 except (tomllib.TOMLDecodeError, yaml.YAMLError) as error:
468 message = f'{path.name} could not be read: {error}'
469 raise ValueError(message) from error
470 data = {
471 markdown.normalize_name(str(key)): value
472 for key, value in data.items()}
473 if not isinstance(data.get('project'), dict):
474 message = f'{path.name} has no "project" table'
475 raise ValueError(message) # noqa: TRY004
476 options = {
477 markdown.normalize_name(str(key)): value
478 for key, value in data['project'].items()}
479 items = data.get('outline', options.pop('outline', []))
480 return options, _outline_items(items)