Coverage for src/coursebook/sources.py: 100%
41 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 markdown source files of a `coursebook` project.
3Each source file holds the content for one entry in the outline. It may start
4with YAML front matter and is divided into sections by lines such as '<text>',
5'<slides>', and '<notes>'. Content before the first divider belongs to the
6text.
8Contents:
9 Source: the front matter and sections of a source file.
10 load_sources: loads every source file in a folder.
12"""
14from __future__ import annotations
16import dataclasses
17import pathlib
18from typing import Any
20from . import markdown, settings
23@dataclasses.dataclass
24class Source:
25 """The front matter and sections of a source file.
27 Args:
28 name: the name of the source file in lower case with words joined by
29 underscores (without the '.md' extension).
30 sections: `dict` with keys that are section names ('text', 'slides',
31 or 'notes') and values that are the markdown of those sections.
32 Defaults to an empty `dict`.
33 metadata: the front matter of the source file. Defaults to an empty
34 `dict`.
35 path: the path to the source file, if it was loaded from a file.
36 Defaults to `None`.
38 """
40 name: str
41 sections: dict[str, str] = dataclasses.field(default_factory = dict)
42 metadata: dict[str, Any] = dataclasses.field(default_factory = dict)
43 path: pathlib.Path | None = None
45 """ Class Methods """
47 @classmethod
48 def from_file(
49 cls,
50 path: str | pathlib.Path,
51 name: str | None = None) -> Source:
52 """Creates a source from a markdown file.
54 Args:
55 path: path to the markdown file.
56 name: the name of the source. Defaults to `None`, which uses the
57 normalized name of the file (see `markdown.normalize_name`).
59 Returns:
60 A source with the front matter and sections of the file.
62 Raises:
63 ValueError: if the front matter or a divider is not valid.
65 """
66 path = pathlib.Path(path)
67 text = path.read_text(encoding = 'utf-8')
68 try:
69 return cls.from_text(
70 text,
71 name = name or markdown.normalize_name(path.stem),
72 path = path)
73 except ValueError as error:
74 message = f'{path.name}: {error}'
75 raise ValueError(message) from error
77 @classmethod
78 def from_text(
79 cls,
80 text: str,
81 name: str,
82 path: pathlib.Path | None = None) -> Source:
83 """Creates a source from markdown text.
85 Args:
86 text: the contents of a source file.
87 name: the name of the source.
88 path: the path to the source file, if any. Defaults to `None`.
90 Returns:
91 A source with the front matter and sections of `text`.
93 """
94 metadata, body = markdown.split_front_matter(text)
95 sections = markdown.split_sections(
96 body,
97 names = settings.SECTION_ALIASES,
98 default = 'text')
99 return cls(
100 name = name,
101 sections = sections,
102 metadata = metadata,
103 path = path)
105 """ Public Methods """
107 def get(self, section: str) -> str:
108 """Returns the markdown of `section`.
110 Args:
111 section: 'text', 'slides', or 'notes'.
113 Returns:
114 The markdown of the section, or an empty `str` if the source does
115 not have that section.
117 """
118 return self.sections.get(section, '')
121""" Public Functions """
124def load_sources(folder: str | pathlib.Path) -> dict[str, Source]:
125 """Loads every markdown file in `folder`.
127 Subfolders are not searched.
129 Args:
130 folder: the project folder.
132 Returns:
133 A `dict` with keys that are the names of the sources (see
134 `markdown.normalize_name`) and values that are the sources.
136 Raises:
137 ValueError: if two files have the same normalized name (such as
138 'Introduction.md' and 'introduction.md') or a file is not valid.
140 """
141 sources: dict[str, Source] = {}
142 for path in sorted(pathlib.Path(folder).glob('*.md')):
143 source = Source.from_file(path)
144 if source.name in sources:
145 other = sources[source.name].path
146 name = other.name if other else source.name
147 message = f'{path.name} and {name} are for the same outline entry'
148 raise ValueError(message)
149 sources[source.name] = source
150 return sources