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

1"""The structure of a `coursebook` project. 

2 

3An outline is a list of labeled titles, such as: 

4 

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 

12 

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: 

17 

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. 

25 

26Because of the first rule, 'i' after 'h' continues the lower case letters 

27instead of starting lower case Roman numerals. 

28 

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. 

35 

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'): 

38 

39 I: Introduction | chapters/intro.md 

40 

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. 

53 

54""" 

55 

56from __future__ import annotations 

57 

58import dataclasses 

59import logging 

60import pathlib 

61import string 

62from typing import TYPE_CHECKING, TypeAlias 

63 

64from . import markdown 

65 

66if TYPE_CHECKING: 

67 from collections.abc import Callable, Iterable, Iterator 

68 

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')) 

78 

79 

80@dataclasses.dataclass 

81class OutlineEntry: 

82 """A single entry in an outline. 

83 

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`. 

95 

96 """ 

97 

98 label: str 

99 title: str 

100 level: int = 0 

101 file: str | None = None 

102 children: list[OutlineEntry] = dataclasses.field(default_factory = list) 

103 

104 """ Properties """ 

105 

106 @property 

107 def file_name(self) -> str: 

108 """Returns the path to the source file, relative to the project folder. 

109 

110 Returns: 

111 `file`, or the name of the entry with the '.md' extension. 

112 

113 """ 

114 return self.file or f'{self.name}.md' 

115 

116 @property 

117 def name(self) -> str: 

118 """Returns the name of the source for this entry. 

119 

120 Returns: 

121 The path of `file` without its extension, or `title`, in lower case 

122 with words joined by underscores. 

123 

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) 

129 

130 @property 

131 def numbered(self) -> bool: 

132 """Returns whether the entry has a numbered label. 

133 

134 Returns: 

135 Whether `label` is not empty. 

136 

137 """ 

138 return bool(self.label) 

139 

140 """ Public Methods """ 

141 

142 def walk(self) -> Iterator[OutlineEntry]: 

143 """Yields this entry and every entry beneath it, in outline order. 

144 

145 Yields: 

146 This entry, followed by each entry beneath it (depth first). 

147 

148 """ 

149 yield self 

150 for child in self.children: 

151 yield from child.walk() 

152 

153 

154@dataclasses.dataclass 

155class Outline: 

156 """The entries of a project in order. 

157 

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`. 

161 

162 """ 

163 

164 entries: list[OutlineEntry] = dataclasses.field(default_factory = list) 

165 

166 """ Class Methods """ 

167 

168 @classmethod 

169 def from_lines(cls, lines: Iterable[OutlineLine]) -> Outline: 

170 """Creates an outline from labeled lines. 

171 

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. 

176 

177 Returns: 

178 An outline with the level of each entry set from its label. 

179 

180 Raises: 

181 ValueError: if a line has no label or title, or if a label cannot 

182 be placed in the outline. 

183 

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 

209 

210 """ Public Methods """ 

211 

212 def walk(self) -> Iterator[OutlineEntry]: 

213 """Yields every entry in the outline, in order. 

214 

215 Yields: 

216 Each entry in the outline (depth first). 

217 

218 """ 

219 for entry in self.entries: 

220 yield from entry.walk() 

221 

222 """ Dunder Methods """ 

223 

224 def __iter__(self) -> Iterator[OutlineEntry]: 

225 """Yields every entry in the outline, in order. 

226 

227 Returns: 

228 An iterator of each entry in the outline (depth first). 

229 

230 """ 

231 return self.walk() 

232 

233 def __len__(self) -> int: 

234 """Returns the number of entries in the outline at every level. 

235 

236 Returns: 

237 The number of entries. 

238 

239 """ 

240 return sum(1 for _ in self.walk()) 

241 

242 

243""" Public Functions """ 

244 

245 

246def parse_outline_line(line: str) -> tuple[str, str, str | None]: 

247 """Splits a line from an outline into its label, title, and file. 

248 

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 '|'. 

251 

252 Args: 

253 line: a line from an outline (such as 'A: History' or 

254 'A: History | history_notes.md'). 

255 

256 Returns: 

257 The label (without any trailing period or surrounding parentheses), 

258 the title, and the file (or `None`). 

259 

260 Raises: 

261 ValueError: if `line` does not have both a label and a title. 

262 

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) 

270 

271 

272""" Private Functions """ 

273 

274 

275def _split_file(title: str) -> tuple[str, str | None]: 

276 """Separates a file at the end of `title` (after a '|'). 

277 

278 Args: 

279 title: the title part of an outline line. 

280 

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'. 

284 

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 

290 

291 

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. 

296 

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`. 

301 

302 Returns: 

303 The label, title, and file (or `None`). 

304 

305 Raises: 

306 ValueError: if a label or title is missing or there are too many 

307 parts. 

308 

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 

323 

324 

325def _clean_label(label: str) -> str: 

326 """Removes a trailing period and surrounding parentheses from `label`. 

327 

328 Args: 

329 label: a label as written in an outline (such as 'A.' or '(1)'). 

330 

331 Returns: 

332 The label without the extra characters. 

333 

334 """ 

335 label = str(label).strip().rstrip('.') 

336 if label.startswith('('): 

337 label = label[1:] 

338 return label.rstrip(')').strip() 

339 

340 

341def _is_unnumbered(label: str) -> bool: 

342 """Returns whether `label` marks an unnumbered entry. 

343 

344 Args: 

345 label: a label from an outline. 

346 

347 Returns: 

348 Whether `label` is only dashes. 

349 

350 """ 

351 return bool(label) and set(label) == {'-'} 

352 

353 

354def _letters(number: int, alphabet: str) -> str: 

355 """Returns the outline letter label for `number`. 

356 

357 After 'Z', labels double the letter ('AA', 'BB', and so on). 

358 

359 Args: 

360 number: the position of the label, starting at 1. 

361 alphabet: the 26 letters to use. 

362 

363 Returns: 

364 The letter label. 

365 

366 """ 

367 return alphabet[(number - 1) % 26] * ((number - 1) // 26 + 1) 

368 

369 

370def _parse_letters(label: str, alphabet: str) -> int | None: 

371 """Returns the position of a letter label, if it is one. 

372 

373 Args: 

374 label: a label from an outline. 

375 alphabet: the 26 letters that may be used. 

376 

377 Returns: 

378 The position of `label` (starting at 1), or `None` if `label` is not a 

379 letter label. 

380 

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) 

385 

386 

387def _parse_number(label: str) -> int | None: 

388 """Returns the value of an Arabic number label, if it is one. 

389 

390 Args: 

391 label: a label from an outline. 

392 

393 Returns: 

394 The value of `label`, or `None` if `label` is not a positive number. 

395 

396 """ 

397 if label.isdigit() and int(label) > 0: 

398 return int(label) 

399 return None 

400 

401 

402def _parse_roman(label: str) -> int | None: 

403 """Returns the value of an upper case Roman numeral label, if it is one. 

404 

405 Args: 

406 label: a label from an outline. 

407 

408 Returns: 

409 The value of `label`, or `None` if `label` is not a Roman numeral in 

410 standard form. 

411 

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 

423 

424 

425def _place_label( 

426 label: str, 

427 stack: _Stack) -> tuple[int, int, int]: 

428 """Returns where the entry with `label` belongs in the outline. 

429 

430 The rules are described in the module docstring. 

431 

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. 

436 

437 Returns: 

438 The level of the new entry and the kind (an index of `_LABEL_KINDS`) 

439 and number of its label. 

440 

441 Raises: 

442 ValueError: if `label` cannot be placed in the outline. 

443 

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) 

475 

476 

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. 

482 

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. 

488 

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. 

492 

493 Raises: 

494 ValueError: if the entry would be more than one level below the 

495 previous entry. 

496 

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 

507 

508 

509def _roman(number: int) -> str: 

510 """Returns the upper case Roman numeral for `number`. 

511 

512 Args: 

513 number: a positive number. 

514 

515 Returns: 

516 The Roman numeral. 

517 

518 """ 

519 numeral = '' 

520 for value, symbol in _ROMAN_NUMERALS: 

521 count, number = divmod(number, value) 

522 numeral += symbol * count 

523 return numeral 

524 

525 

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)))