Coverage for src/coursebook/outline.py: 90%
161 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 structure of a `coursebook` project.
3An outline is a list of labeled titles, such as:
5 I: Introduction
6 II: The Problem
7 A: History
8 1: Early Cases
9 2: Modern Cases
10 B: Current Law
11 III: Conclusion
13The level of each entry comes from its label instead of from indentation. A
14label can be an upper case Roman numeral (I), an upper case letter (A), an
15Arabic number (1), a lower case letter (a), or a lower case Roman numeral (i).
16An entry is placed:
181. after the deepest earlier entry whose next label it is (so 'B' follows
19 'A'); or, if there is none,
202. beneath the previous entry if it is the first label of a kind that is not
21 used by the previous entry or its parents (so 'A' or '1' opens a new
22 level); or, if neither applies,
233. after the deepest earlier entry with the same kind of label, even if some
24 labels were skipped (so 'C' can follow 'A'), with a warning.
26Because of the first rule, 'i' after 'h' continues the lower case letters
27instead of starting lower case Roman numerals.
29An entry without a number (such as a preface or an appendix) has a label of
30dashes: '-' for the top level, '--' for the level below it, and so on. An
31unnumbered entry takes the place of a numbered one in the outline, so the
32numbering continues after it. A numbered label that opens a new level after an
33unnumbered entry is placed at the level of the unnumbered entry instead of
34beneath it.
36Each entry is matched to the source file named after its title. To use
37another file, add it after a '|' (the file name must end with '.md'):
39 I: Introduction | chapters/intro.md
41Contents:
42 Outline: the entries of a project in order.
43 OutlineEntry: a single entry in an outline.
44 OutlineLine: type of an outline line or a tuple of its parts.
45 parse_outline_line: splits an outline line into its label, title, and
46 file.
47 _LABEL_KINDS: the kinds of labels that may be used in an outline, each
48 with a function that creates a label from a number and a function that
49 returns the number of a label (or `None`).
50 _LOGGER: logger for warnings about skipped labels.
51 _Stack: type of the open entries while an outline is read.
52 _ROMAN_NUMERALS: Roman numeral symbols and their values.
54"""
56from __future__ import annotations
58import dataclasses
59import logging
60import pathlib
61import string
62from typing import TYPE_CHECKING, TypeAlias
64from . import markdown
66if TYPE_CHECKING:
67 from collections.abc import Callable, Iterable, Iterator
69OutlineLine: TypeAlias = str | tuple[str, ...]
70# Each item is an open entry and the kind and number of the labels at its level
71# (or `None` for an unnumbered level).
72_Stack: TypeAlias = 'list[tuple[OutlineEntry, int | None, int]]'
73_LOGGER = logging.getLogger(__name__)
74_ROMAN_NUMERALS: tuple[tuple[int, str], ...] = (
75 (1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'),
76 (90, 'XC'), (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'),
77 (4, 'IV'), (1, 'I'))
80@dataclasses.dataclass
81class OutlineEntry:
82 """A single entry in an outline.
84 Args:
85 label: the label of the entry as written in the outline (such as 'IV'
86 or 'b'), without any trailing period or surrounding parentheses.
87 An unnumbered entry has an empty label.
88 title: the title of the entry.
89 level: depth of the entry in the outline, starting at 0 for the top
90 level. Defaults to 0.
91 file: path to the source file, relative to the project folder.
92 Defaults to `None`, which uses a file named after `title`.
93 children: entries directly beneath this entry. Defaults to an empty
94 `list`.
96 """
98 label: str
99 title: str
100 level: int = 0
101 file: str | None = None
102 children: list[OutlineEntry] = dataclasses.field(default_factory = list)
104 """ Properties """
106 @property
107 def file_name(self) -> str:
108 """Returns the path to the source file, relative to the project folder.
110 Returns:
111 `file`, or the name of the entry with the '.md' extension.
113 """
114 return self.file or f'{self.name}.md'
116 @property
117 def name(self) -> str:
118 """Returns the name of the source for this entry.
120 Returns:
121 The path of `file` without its extension, or `title`, in lower case
122 with words joined by underscores.
124 """
125 if self.file:
126 path = pathlib.PurePosixPath(self.file.replace('\\', '/'))
127 return markdown.normalize_name(str(path.with_suffix('')))
128 return markdown.normalize_name(self.title)
130 @property
131 def numbered(self) -> bool:
132 """Returns whether the entry has a numbered label.
134 Returns:
135 Whether `label` is not empty.
137 """
138 return bool(self.label)
140 """ Public Methods """
142 def walk(self) -> Iterator[OutlineEntry]:
143 """Yields this entry and every entry beneath it, in outline order.
145 Yields:
146 This entry, followed by each entry beneath it (depth first).
148 """
149 yield self
150 for child in self.children:
151 yield from child.walk()
154@dataclasses.dataclass
155class Outline:
156 """The entries of a project in order.
158 Args:
159 entries: the top level entries of the outline. Each entry stores the
160 entries beneath it in its `children`. Defaults to an empty `list`.
162 """
164 entries: list[OutlineEntry] = dataclasses.field(default_factory = list)
166 """ Class Methods """
168 @classmethod
169 def from_lines(cls, lines: Iterable[OutlineLine]) -> Outline:
170 """Creates an outline from labeled lines.
172 Args:
173 lines: each item is either a line from an outline (such as
174 'A: History') or a `tuple` of a label, a title, and
175 (optionally) a file. Blank lines are skipped.
177 Returns:
178 An outline with the level of each entry set from its label.
180 Raises:
181 ValueError: if a line has no label or title, or if a label cannot
182 be placed in the outline.
184 """
185 outline = cls()
186 # The last item is the most recent entry.
187 stack: _Stack = []
188 for line in lines:
189 if isinstance(line, str):
190 if not line.strip():
191 continue
192 label, title, file = parse_outline_line(line)
193 else:
194 label, title, file = _parse_parts(line)
195 if _is_unnumbered(label):
196 depth, kind, number = _place_unnumbered(label, title, stack)
197 label = ''
198 else:
199 depth, kind, number = _place_label(label, stack)
200 entry = OutlineEntry(
201 label = label, title = title, level = depth, file = file)
202 del stack[depth:]
203 if depth == 0:
204 outline.entries.append(entry)
205 else:
206 stack[-1][0].children.append(entry)
207 stack.append((entry, kind, number))
208 return outline
210 """ Public Methods """
212 def walk(self) -> Iterator[OutlineEntry]:
213 """Yields every entry in the outline, in order.
215 Yields:
216 Each entry in the outline (depth first).
218 """
219 for entry in self.entries:
220 yield from entry.walk()
222 """ Dunder Methods """
224 def __iter__(self) -> Iterator[OutlineEntry]:
225 """Yields every entry in the outline, in order.
227 Returns:
228 An iterator of each entry in the outline (depth first).
230 """
231 return self.walk()
233 def __len__(self) -> int:
234 """Returns the number of entries in the outline at every level.
236 Returns:
237 The number of entries.
239 """
240 return sum(1 for _ in self.walk())
243""" Public Functions """
246def parse_outline_line(line: str) -> tuple[str, str, str | None]:
247 """Splits a line from an outline into its label, title, and file.
249 The label and title may be separated by ':' or '=' (the same separators
250 allowed in an `ini` file). A file may follow the title after a '|'.
252 Args:
253 line: a line from an outline (such as 'A: History' or
254 'A: History | history_notes.md').
256 Returns:
257 The label (without any trailing period or surrounding parentheses),
258 the title, and the file (or `None`).
260 Raises:
261 ValueError: if `line` does not have both a label and a title.
263 """
264 positions = [line.find(mark) for mark in ':=' if mark in line]
265 if not positions:
266 message = f'outline line "{line.strip()}" needs a label and a title'
267 raise ValueError(message)
268 split = min(positions)
269 return _parse_parts((line[:split], line[split + 1:]), line)
272""" Private Functions """
275def _split_file(title: str) -> tuple[str, str | None]:
276 """Separates a file at the end of `title` (after a '|').
278 Args:
279 title: the title part of an outline line.
281 Returns:
282 The title and the file, or `title` and `None` if it does not end with
283 a '|' followed by a file name ending in '.md'.
285 """
286 before, separator, after = title.rpartition('|')
287 if separator and after.strip().lower().endswith('.md'):
288 return before.strip(), after.strip()
289 return title.strip(), None
292def _parse_parts(
293 parts: tuple[str, ...],
294 line: str | None = None) -> tuple[str, str, str | None]:
295 """Returns the label, title, and file from the parts of an outline line.
297 Args:
298 parts: a label and a title, and optionally a file. If there is no
299 file, the title may end with one (after a '|').
300 line: the original line, for error messages. Defaults to `None`.
302 Returns:
303 The label, title, and file (or `None`).
305 Raises:
306 ValueError: if a label or title is missing or there are too many
307 parts.
309 """
310 text = line.strip() if line is not None else ' | '.join(map(str, parts))
311 if len(parts) not in {2, 3}:
312 message = f'outline line "{text}" needs a label and a title'
313 raise ValueError(message)
314 label = _clean_label(str(parts[0]))
315 if len(parts) == 3: # noqa: PLR2004
316 title, file = str(parts[1]).strip(), str(parts[2]).strip() or None
317 else:
318 title, file = _split_file(str(parts[1]))
319 if not label or not title:
320 message = f'outline line "{text}" needs a label and a title'
321 raise ValueError(message)
322 return label, title, file
325def _clean_label(label: str) -> str:
326 """Removes a trailing period and surrounding parentheses from `label`.
328 Args:
329 label: a label as written in an outline (such as 'A.' or '(1)').
331 Returns:
332 The label without the extra characters.
334 """
335 label = str(label).strip().rstrip('.')
336 if label.startswith('('):
337 label = label[1:]
338 return label.rstrip(')').strip()
341def _is_unnumbered(label: str) -> bool:
342 """Returns whether `label` marks an unnumbered entry.
344 Args:
345 label: a label from an outline.
347 Returns:
348 Whether `label` is only dashes.
350 """
351 return bool(label) and set(label) == {'-'}
354def _letters(number: int, alphabet: str) -> str:
355 """Returns the outline letter label for `number`.
357 After 'Z', labels double the letter ('AA', 'BB', and so on).
359 Args:
360 number: the position of the label, starting at 1.
361 alphabet: the 26 letters to use.
363 Returns:
364 The letter label.
366 """
367 return alphabet[(number - 1) % 26] * ((number - 1) // 26 + 1)
370def _parse_letters(label: str, alphabet: str) -> int | None:
371 """Returns the position of a letter label, if it is one.
373 Args:
374 label: a label from an outline.
375 alphabet: the 26 letters that may be used.
377 Returns:
378 The position of `label` (starting at 1), or `None` if `label` is not a
379 letter label.
381 """
382 if not label or label[0] not in alphabet or set(label) != {label[0]}: 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true
383 return None
384 return alphabet.index(label[0]) + 1 + 26 * (len(label) - 1)
387def _parse_number(label: str) -> int | None:
388 """Returns the value of an Arabic number label, if it is one.
390 Args:
391 label: a label from an outline.
393 Returns:
394 The value of `label`, or `None` if `label` is not a positive number.
396 """
397 if label.isdigit() and int(label) > 0:
398 return int(label)
399 return None
402def _parse_roman(label: str) -> int | None:
403 """Returns the value of an upper case Roman numeral label, if it is one.
405 Args:
406 label: a label from an outline.
408 Returns:
409 The value of `label`, or `None` if `label` is not a Roman numeral in
410 standard form.
412 """
413 if not label or set(label) - set('MDCLXVI'): 413 ↛ 415line 413 didn't jump to line 415 because the condition on line 413 was always true
414 return None
415 total, rest = 0, label
416 for value, symbol in _ROMAN_NUMERALS:
417 while rest.startswith(symbol):
418 total += value
419 rest = rest[len(symbol):]
420 if rest or _roman(total) != label:
421 return None
422 return total
425def _place_label(
426 label: str,
427 stack: _Stack) -> tuple[int, int, int]:
428 """Returns where the entry with `label` belongs in the outline.
430 The rules are described in the module docstring.
432 Args:
433 label: the label of the new entry.
434 stack: the open entries, from the top level down to the most recent
435 entry, each with the kind and number of the labels at its level.
437 Returns:
438 The level of the new entry and the kind (an index of `_LABEL_KINDS`)
439 and number of its label.
441 Raises:
442 ValueError: if `label` cannot be placed in the outline.
444 """
445 numbered = [
446 (depth, kind, number) for depth, (_, kind, number) in enumerate(stack)
447 if kind is not None]
448 for depth, kind, number in reversed(numbered):
449 if _LABEL_KINDS[kind][0](number + 1) == label:
450 return depth, kind, number + 1
451 used = {kind for _, kind, _ in numbered}
452 # A new level replaces any unnumbered entries at the end of the stack.
453 opening = len(stack)
454 while opening and stack[opening - 1][1] is None:
455 opening -= 1
456 for kind, (formatter, _) in enumerate(_LABEL_KINDS):
457 if kind not in used and formatter(1) == label:
458 return opening, kind, 1
459 for depth, kind, number in reversed(numbered):
460 found = _LABEL_KINDS[kind][1](label)
461 if found is not None and found > number:
462 _LOGGER.warning(
463 'Outline label "%s" skips labels after "%s"', label,
464 _LABEL_KINDS[kind][0](number))
465 return depth, kind, found
466 expected = [
467 _LABEL_KINDS[kind][0](number + 1) for _, kind, number in numbered]
468 expected.extend(
469 formatter(1) for kind, (formatter, _) in enumerate(_LABEL_KINDS)
470 if kind not in used)
471 message = (
472 f'cannot place outline label "{label}" (expected one of: '
473 f'{", ".join(expected)})')
474 raise ValueError(message)
477def _place_unnumbered(
478 label: str,
479 title: str,
480 stack: _Stack) -> tuple[int, int | None, int]:
481 """Returns where an unnumbered entry belongs in the outline.
483 Args:
484 label: the dashes of the entry ('-' for the top level).
485 title: the title of the entry, for error messages.
486 stack: the open entries, from the top level down to the most recent
487 entry, each with the kind and number of the labels at its level.
489 Returns:
490 The level of the entry and the kind and number of the labels at that
491 level, so that numbering continues after the entry.
493 Raises:
494 ValueError: if the entry would be more than one level below the
495 previous entry.
497 """
498 depth = len(label) - 1
499 if depth > len(stack):
500 message = (
501 f'unnumbered entry "{label}: {title}" is more than one level '
502 'below the entry before it')
503 raise ValueError(message)
504 if depth < len(stack):
505 return depth, stack[depth][1], stack[depth][2]
506 return depth, None, 0
509def _roman(number: int) -> str:
510 """Returns the upper case Roman numeral for `number`.
512 Args:
513 number: a positive number.
515 Returns:
516 The Roman numeral.
518 """
519 numeral = ''
520 for value, symbol in _ROMAN_NUMERALS:
521 count, number = divmod(number, value)
522 numeral += symbol * count
523 return numeral
526_LABEL_KINDS: tuple[
527 tuple[Callable[[int], str], Callable[[str], int | None]], ...] = (
528 (_roman, _parse_roman),
529 (lambda n: _letters(n, string.ascii_uppercase),
530 lambda label: _parse_letters(label, string.ascii_uppercase)),
531 (str, _parse_number),
532 (lambda n: _letters(n, string.ascii_lowercase),
533 lambda label: _parse_letters(label, string.ascii_lowercase)),
534 (lambda n: _roman(n).lower(),
535 lambda label: (
536 _parse_roman(label.upper()) if label.islower() else None)))