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

1"""Loads the settings and outline of a `coursebook` project. 

2 

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. 

6 

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. 

11 

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. 

24 

25""" 

26 

27from __future__ import annotations 

28 

29import configparser 

30import dataclasses 

31import pathlib 

32import re 

33import tomllib 

34from typing import TYPE_CHECKING, Any, TypeAlias 

35 

36import camina 

37import yaml 

38 

39from . import markdown, outline 

40 

41if TYPE_CHECKING: 

42 from collections.abc import Iterable, Mapping 

43 

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

67 

68 

69@dataclasses.dataclass 

70class Settings: 

71 """The options in the "Project" section of a project file. 

72 

73 Names in a project file are matched without regard to case, spaces, or 

74 underscores. So, 'Output Folder' sets `output_folder`. 

75 

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

125 

126 """ 

127 

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) 

152 

153 """ Initialization Methods """ 

154 

155 def __post_init__(self) -> None: 

156 """Validates and normalizes the sections in `include`. 

157 

158 Raises: 

159 ValueError: if `include` names an unknown section. 

160 

161 """ 

162 if self.include is not None: 

163 self.include = normalize_sections(self.include) 

164 

165 """ Class Methods """ 

166 

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. 

173 

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

180 

181 Returns: 

182 Settings with the options in `mapping`. 

183 

184 Raises: 

185 ValueError: if a setting has a value of the wrong type or 

186 `include` names an unknown section. 

187 

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) 

200 

201 """ Public Methods """ 

202 

203 def formats_for(self, section: str) -> list[str]: 

204 """Returns the formats of the files for `section`. 

205 

206 Args: 

207 section: 'text', 'slides', or 'notes'. 

208 

209 Returns: 

210 The names of the formats (such as ['word', 'pdf']). 

211 

212 """ 

213 return list(getattr(self, f'{section}_format')) 

214 

215 def template_for(self, section: str) -> str | None: 

216 """Returns the template setting for `section`. 

217 

218 Args: 

219 section: 'text', 'slides', or 'notes'. 

220 

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. 

225 

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) 

231 

232 

233""" Public Functions """ 

234 

235 

236def find_project_file(folder: str | pathlib.Path) -> pathlib.Path: 

237 """Returns the path to the project file in `folder`. 

238 

239 Args: 

240 folder: the project folder. 

241 

242 Returns: 

243 The path to the project file. 

244 

245 Raises: 

246 FileNotFoundError: if `folder` does not exist or has no project file. 

247 ValueError: if `folder` has more than one project file. 

248 

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] 

266 

267 

268def normalize_sections(sections: Iterable[str]) -> list[str]: 

269 """Converts section names (and their aliases) to their standard names. 

270 

271 Args: 

272 sections: section names (such as 'manuscript' or 'Slides'). 

273 

274 Returns: 

275 The standard names of the sections ('text', 'slides', or 'notes'), 

276 without duplicates. 

277 

278 Raises: 

279 ValueError: if a name is not a known section. 

280 

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 

292 

293 

294def read_project_file( 

295 path: str | pathlib.Path) -> tuple[Settings, outline.Outline]: 

296 """Returns the settings and outline in a project file. 

297 

298 Args: 

299 path: path to an `ini`, `toml`, `yaml`, or `yml` project file. 

300 

301 Returns: 

302 The settings from the "Project" section and the outline from the 

303 "Outline" section. 

304 

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. 

309 

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) 

326 

327 

328""" Private Functions """ 

329 

330 

331def _convert_setting(name: str, value: Any, annotation: Any) -> Any: 

332 """Converts `value` to the type of the setting `name`. 

333 

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

339 

340 Returns: 

341 The converted value. 

342 

343 Raises: 

344 ValueError: if `value` cannot be converted. 

345 

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

364 

365 

366def _outline_items(items: Any) -> OutlineLines: 

367 """Converts the outline in a `toml` or `yaml` file to outline lines. 

368 

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. 

373 

374 Returns: 

375 The outline lines and tuples of their parts. 

376 

377 Raises: 

378 ValueError: if `items` is not a `list` or has an item of another form. 

379 

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 

397 

398 

399def _read_ini(path: pathlib.Path) -> tuple[dict[str, Any], OutlineLines]: 

400 """Reads the project options and outline lines from an `ini` file. 

401 

402 Args: 

403 path: path to an `ini` file. 

404 

405 Returns: 

406 The options in the "Project" section and the lines in the "Outline" 

407 section. 

408 

409 Raises: 

410 ValueError: if the file has no "Project" section or is not a valid 

411 `ini` file. 

412 

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 

442 

443 

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. 

447 

448 The outline may be at the top level of the file or inside the "project" 

449 table. 

450 

451 Args: 

452 path: path to a `toml`, `yaml`, or `yml` file. 

453 

454 Returns: 

455 The options in the "project" table and the outline lines. 

456 

457 Raises: 

458 ValueError: if the file has no "project" table or is not valid. 

459 

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)