Coverage for src/fjc_biographies/rules.py: 93%

240 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-22 22:47 +0000

1"""Rules that make new fields from the text of judges' biographies. 

2 

3The rules are written in CSV files (spreadsheets), so they can be read and 

4changed without knowing Python. They are the rules of courtpy, whose "judges" 

5rulebook (in fjc_biographies' "instructions" folder) codes each judge's 

6professional career. Each row is one rule. The columns are: 

7 

8| Column | Meaning | 

9| --- | --- | 

10| `target` | The section of text to search, such as "career". Several sections can be listed, separated by commas, and are searched in order. | 

11| `variable` | The name of the column that the rule makes (or adds to). | 

12| `kind` | What the rule does (see below). | 

13| `pattern` | A regular expression to search for. | 

14| `value` | For a `label` rule, the value to record. Otherwise blank. | 

15| `ignorecase` | TRUE to ignore the difference between capital and lower-case letters. | 

16| `dotall` | TRUE for "." in the pattern to also match line breaks. | 

17| `note` | Anything you want to say about the rule. It is not used. | 

18 

19The kinds of rules: 

20 

21| Kind | Makes | 

22| --- | --- | 

23| `flag` | TRUE if the pattern appears, and otherwise FALSE. | 

24| `count` | The number of times the pattern appears. | 

25| `matches` | A list of every match. | 

26| `label` | The rule's `value` if the pattern appears. The first rule for a variable that matches wins, so it can turn text into categories (such as kinds of positions into numbers). | 

27| `names` | A list of names, found by splitting the text wherever the pattern matches. The pattern should match the words and punctuation between names. | 

28| `section` | A new section of text: the first match. Later rules can search it. | 

29| `excerpts` | A new section of text: every match, one per line. | 

30| `split` | Two new sections, named in `variable` (such as "before, after"): the text before and after the first match. | 

31| `remove` | Nothing. It deletes every match from the target for the rules after it. | 

32 

33Rules are applied from top to bottom, so a rule can search a section made by 

34a rule above it. Rules with the same `variable` are combined: flags are TRUE if 

35any rule matches, counts are added, lists of matches and names are joined, and 

36the first `label` or `section` that matches is used. 

37 

38Contents: 

39 KINDS: the kinds of rules. 

40 Rule: one rule from a CSV file. 

41 Rulebook: an ordered collection of rules. 

42 find_instructions: returns the CSV files for a built-in or custom rulebook. 

43 split_names: splits text into names wherever a pattern matches. 

44 

45""" 

46 

47from __future__ import annotations 

48 

49import csv 

50import dataclasses 

51import importlib.resources 

52import pathlib 

53import re 

54from collections.abc import ( 

55 Iterable, 

56 Iterator, 

57 Mapping, 

58 MutableMapping, 

59 Sequence, 

60) 

61from typing import Any 

62 

63KINDS: tuple[str, ...] = ( 

64 'count', 'excerpts', 'flag', 'label', 'matches', 'names', 'remove', 

65 'section', 'split') 

66_SECTION_KINDS: frozenset[str] = frozenset({'excerpts', 'section', 'split'}) 

67# The curly apostrophe (U+2019) is added with `chr` so that the source file 

68# has only plain characters. 

69_PUNCTUATION: re.Pattern[str] = re.compile("[.'" + chr(0x2019) + ']') 

70# Columns that every rules file must have. "value" and "note" are optional. 

71_REQUIRED: tuple[str, ...] = ( 

72 'target', 'variable', 'kind', 'pattern', 'ignorecase', 'dotall') 

73 

74 

75@dataclasses.dataclass(frozen = True) 

76class Rule: 

77 """One rule from a CSV file. 

78 

79 Args: 

80 targets: names of the sections of text to search, in order. 

81 variables: names of the columns (or, for `section`, `excerpts`, and 

82 `split` rules, the sections) that the rule makes. Only a `split` 

83 rule has more than one. 

84 kind: one of `KINDS`. 

85 pattern: the compiled regular expression. 

86 value: for a `label` rule, the value to record. Defaults to None. 

87 note: a description of the rule. Defaults to an empty `str`. 

88 

89 """ 

90 

91 targets: tuple[str, ...] 

92 variables: tuple[str, ...] 

93 kind: str 

94 pattern: re.Pattern[str] 

95 value: Any = None 

96 note: str = '' 

97 

98 """ Class Methods """ 

99 

100 @classmethod 

101 def create(cls, row: Mapping[str, Any]) -> Rule: 

102 """Returns a rule made from a row of a CSV file. 

103 

104 Args: 

105 row: the cells of the row, by the names of the columns. 

106 

107 Returns: 

108 The rule. 

109 

110 Raises: 

111 ValueError: if the kind is not one of `KINDS`, the pattern is not 

112 a valid regular expression, a cell that is needed is blank, or 

113 a `split` rule does not name two variables. 

114 

115 """ 

116 kind = str(row.get('kind') or '').strip().lower() 

117 if kind not in KINDS: 

118 message = f'the kind {kind!r} is not one of {list(KINDS)}' 

119 raise ValueError(message) 

120 targets = tuple(_listify(row.get('target'))) 

121 variables = tuple(_listify(row.get('variable'))) 

122 if not targets: 

123 message = 'the target is blank' 

124 raise ValueError(message) 

125 if kind == 'split' and len(variables) != 2: 

126 message = ( 

127 'a split rule needs two variables, separated by a comma (such ' 

128 'as "before, after")' 

129 ) 

130 raise ValueError(message) 

131 if kind != 'remove' and not variables: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true

132 message = 'the variable is blank' 

133 raise ValueError(message) 

134 source = str(row.get('pattern') or '') 

135 if not source: 

136 message = 'the pattern is blank' 

137 raise ValueError(message) 

138 flags = 0 

139 if _to_bool(row.get('ignorecase')): 

140 flags |= re.IGNORECASE 

141 if _to_bool(row.get('dotall')): 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true

142 flags |= re.DOTALL 

143 try: 

144 pattern = re.compile(source, flags) 

145 except re.error as error: 

146 message = f'the pattern {source!r} is not valid: {error}' 

147 raise ValueError(message) from error 

148 value = row.get('value') 

149 return cls( 

150 targets = targets, 

151 variables = variables, 

152 kind = kind, 

153 pattern = pattern, 

154 value = _convert(value) if kind == 'label' else None, 

155 note = str(row.get('note') or '')) 

156 

157 """ Properties """ 

158 

159 @property 

160 def variable(self) -> str: 

161 """Returns the name of the (first) variable that the rule makes. 

162 

163 Returns: 

164 The first of `variables`, or an empty `str` for a `remove` rule, 

165 which makes none. 

166 

167 """ 

168 return self.variables[0] if self.variables else '' 

169 

170 """ Public Methods """ 

171 

172 def apply( 

173 self, 

174 sections: MutableMapping[str, str], 

175 results: MutableMapping[str, Any]) -> None: 

176 """Applies the rule to `sections`, recording what it finds in `results`. 

177 

178 Args: 

179 sections: the sections of text, by name. `remove`, `section`, 

180 `excerpts`, and `split` rules change it. 

181 results: the variables found so far, by name. It is changed in 

182 place. 

183 

184 """ 

185 texts = [sections[t] for t in self.targets if sections.get(t)] 

186 if self.kind == 'remove': 

187 for target in self.targets: 

188 if sections.get(target): 188 ↛ 187line 188 didn't jump to line 187 because the condition on line 188 was always true

189 sections[target] = self.pattern.sub(' ', sections[target]) 

190 elif self.kind == 'split': 

191 self._split(texts, sections, results) 

192 elif self.kind == 'flag': 

193 flagged = any(self.pattern.search(text) for text in texts) 

194 results[self.variable] = bool(results.get(self.variable)) or flagged 

195 elif self.kind == 'count': 

196 counted = sum( 

197 1 for text in texts for _ in self.pattern.finditer(text)) 

198 results[self.variable] = results.get(self.variable, 0) + counted 

199 elif self.kind == 'matches': 

200 matches = results.setdefault(self.variable, []) 

201 matches.extend( 

202 m.group(0).strip() for t in texts 

203 for m in self.pattern.finditer(t)) 

204 elif self.kind == 'label': 

205 if results.get(self.variable) is None: 

206 matched = any(self.pattern.search(text) for text in texts) 

207 results[self.variable] = self.value if matched else None 

208 elif self.kind == 'names': 

209 names = results.setdefault(self.variable, []) 

210 for text in texts: 

211 names.extend(n for n in split_names(text, self.pattern) 

212 if n not in names) 

213 elif self.kind == 'section': 

214 if not results.get(self.variable): 214 ↛ exitline 214 didn't return from function 'apply' because the condition on line 214 was always true

215 match = next( 

216 (m for t in texts if (m := self.pattern.search(t))), None) 

217 section = match.group(0).strip() if match else None 

218 results[self.variable] = section 

219 if section: 

220 sections[self.variable] = section 

221 elif self.kind == 'excerpts': 221 ↛ exitline 221 didn't return from function 'apply' because the condition on line 221 was always true

222 excerpts = [ 

223 m.group(0).strip() for t in texts 

224 for m in self.pattern.finditer(t) if m.group(0).strip()] 

225 existing = results.get(self.variable) 

226 joined = '\n'.join(([existing] if existing else []) + excerpts) 

227 results[self.variable] = joined or None 

228 if joined: 

229 sections[self.variable] = joined 

230 

231 """ Private Methods """ 

232 

233 def _split( 

234 self, 

235 texts: Sequence[str], 

236 sections: MutableMapping[str, str], 

237 results: MutableMapping[str, Any]) -> None: 

238 """Divides the first target at the first match of the pattern. 

239 

240 Args: 

241 texts: the texts of the targets that are not blank. 

242 sections: the sections of text, by name. 

243 results: the variables found so far, by name. 

244 

245 """ 

246 first, second = self.variables 

247 if not texts: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true

248 results.setdefault(first, None) 

249 results.setdefault(second, None) 

250 return 

251 text = texts[0] 

252 match = self.pattern.search(text) 

253 if match is None: 

254 before, after = text, '' 

255 else: 

256 before, after = text[:match.start()], text[match.end():] 

257 for name, part in ((first, before.strip()), (second, after.strip())): 

258 results[name] = part or None 

259 if part: 

260 sections[name] = part 

261 

262 

263@dataclasses.dataclass 

264class Rulebook: 

265 """An ordered collection of rules. 

266 

267 Args: 

268 rules: the rules, in the order they are applied. Defaults to an empty 

269 `list`. 

270 name: a name for the rulebook, such as the file it came from. Defaults 

271 to an empty `str`. 

272 

273 """ 

274 

275 rules: list[Rule] = dataclasses.field(default_factory = list) 

276 name: str = '' 

277 

278 """ Class Methods """ 

279 

280 @classmethod 

281 def create(cls, source: Rulebook | pathlib.Path | str) -> Rulebook: 

282 """Returns a rulebook made from `source`. 

283 

284 Args: 

285 source: a rulebook, or the name of a built-in rulebook or the path 

286 of a CSV file or a folder of them (see `load`). 

287 

288 Returns: 

289 The rulebook. 

290 

291 """ 

292 return source if isinstance(source, Rulebook) else cls.load(source) 

293 

294 @classmethod 

295 def load(cls, *sources: str | pathlib.Path) -> Rulebook: 

296 """Loads rules from CSV files. 

297 

298 Args: 

299 *sources: names of built-in rulebooks (such as "judges"), or paths 

300 to CSV files or to folders of them. The rules are applied in 

301 the order they are given (and, in a folder, in the 

302 alphabetical order of the files). A file that two sources 

303 name is read only the first time. 

304 

305 Returns: 

306 A rulebook with the rules of every source. 

307 

308 Raises: 

309 FileNotFoundError: if a source is not a built-in rulebook or a file 

310 or folder. 

311 ValueError: if a file is missing a column or a row is not a valid 

312 rule. The message names the file and row. 

313 

314 """ 

315 rules: list[Rule] = [] 

316 names: list[str] = [] 

317 read: set[pathlib.Path] = set() 

318 for source in sources: 

319 for path in find_instructions(source): 

320 if path.resolve() in read: 

321 continue 

322 read.add(path.resolve()) 

323 rules.extend(_read(path)) 

324 names.append(path.stem) 

325 return cls(rules = rules, name = ', '.join(names)) 

326 

327 """ Properties """ 

328 

329 @property 

330 def sections(self) -> list[str]: 

331 """Returns the names of the sections of text that the rules make. 

332 

333 Returns: 

334 The variables of the `section`, `excerpts`, and `split` rules, in 

335 order, without repeats. 

336 

337 """ 

338 return _unique( 

339 v for r in self.rules if r.kind in _SECTION_KINDS 

340 for v in r.variables) 

341 

342 @property 

343 def targets(self) -> list[str]: 

344 """Returns the names of the sections of text that the rules search. 

345 

346 Returns: 

347 The targets of every rule, in order, without repeats. 

348 

349 """ 

350 return _unique(t for rule in self.rules for t in rule.targets) 

351 

352 @property 

353 def variables(self) -> list[str]: 

354 """Returns the names of the columns that the rules make. 

355 

356 Returns: 

357 The variables of every rule except `remove` rules (which make 

358 none), in order, without repeats. 

359 

360 """ 

361 return _unique( 

362 v for r in self.rules if r.kind != 'remove' for v in r.variables) 

363 

364 """ Public Methods """ 

365 

366 def apply(self, sections: MutableMapping[str, str]) -> dict[str, Any]: 

367 """Applies every rule, in order, to `sections`. 

368 

369 Args: 

370 sections: the sections of text, by name. Rules that make or change 

371 sections change it in place. 

372 

373 Returns: 

374 The value of each variable, by name. A variable whose rules found 

375 nothing is False (flags), 0 (counts), an empty `list` 

376 (matches and names), or None (labels and sections). 

377 

378 """ 

379 results: dict[str, Any] = {} 

380 for rule in self.rules: 

381 rule.apply(sections, results) 

382 return results 

383 

384 """ Dunder Methods """ 

385 

386 def __add__(self, other: Rulebook) -> Rulebook: 

387 """Returns a rulebook with the rules of both rulebooks. 

388 

389 Args: 

390 other: the rulebook whose rules come second. 

391 

392 Returns: 

393 A new rulebook with this rulebook's rules followed by those of 

394 `other`, named for both. 

395 

396 """ 

397 name = ', '.join(n for n in (self.name, other.name) if n) 

398 return Rulebook(rules = [*self.rules, *other.rules], name = name) 

399 

400 def __iter__(self) -> Iterator[Rule]: 

401 """Returns an iterator of the rules. 

402 

403 Returns: 

404 An iterator of `rules`, in the order they are applied. 

405 

406 """ 

407 return iter(self.rules) 

408 

409 def __len__(self) -> int: 

410 """Returns the number of rules. 

411 

412 Returns: 

413 The length of `rules`. 

414 

415 """ 

416 return len(self.rules) 

417 

418 

419def find_instructions(source: str | pathlib.Path) -> list[pathlib.Path]: 

420 """Returns the CSV files for a built-in or custom rulebook. 

421 

422 Args: 

423 source: the name of a built-in rulebook (a file in fjc_biographies' 

424 "instructions" folder, such as "judges"), or the path to a CSV 

425 file or a folder of them. 

426 

427 Returns: 

428 The paths of the CSV files, in the order they are applied. 

429 

430 Raises: 

431 FileNotFoundError: if `source` is not a built-in rulebook, a CSV file, 

432 or a folder with CSV files. 

433 

434 """ 

435 path = pathlib.Path(source) 

436 if not path.exists(): 

437 folder = pathlib.Path( 

438 str(importlib.resources.files('fjc_biographies'))) / 'instructions' 

439 name = str(source) 

440 for candidate in (folder / name, folder / f'{name}.csv'): 

441 if candidate.exists(): 

442 path = candidate 

443 break 

444 else: 

445 built_in = sorted(p.stem for p in folder.glob('*.csv')) 

446 message = ( 

447 f'{source!r} is not a CSV file, a folder, or a built-in ' 

448 f'rulebook ({built_in})' 

449 ) 

450 raise FileNotFoundError(message) 

451 if path.is_dir(): 

452 files = sorted(path.glob('*.csv')) 

453 if not files: 

454 message = f'there are no CSV files in {path}' 

455 raise FileNotFoundError(message) 

456 return files 

457 return [path] 

458 

459 

460def split_names(text: str, separators: re.Pattern[str]) -> list[str]: 

461 """Splits `text` into names wherever `separators` matches. 

462 

463 The text is made upper-case and periods and apostrophes are removed first 

464 (so "O'Scannlain" becomes "OSCANNLAIN"), and line breaks always separate 

465 names. Pieces without at least two letters are dropped. 

466 

467 Args: 

468 text: text with names, such as "Hon. SMITH, JONES, and BROWN". 

469 separators: a pattern that matches the words and punctuation between 

470 the names. It is matched against the upper-case text. 

471 

472 Returns: 

473 The names, in order, without repeats. 

474 

475 """ 

476 cleaned = _PUNCTUATION.sub('', text.upper()) 

477 names: list[str] = [] 

478 for line in cleaned.splitlines(): 

479 # The text between matches is used (rather than `re.split`) so that 

480 # groups in the pattern are not mistaken for names. 

481 pieces, start = [], 0 

482 for match in separators.finditer(line): 

483 pieces.append(line[start:match.start()]) 

484 start = match.end() 

485 pieces.append(line[start:]) 

486 for piece in pieces: 

487 name = ' '.join(piece.split()) 

488 if sum(c.isalpha() for c in name) >= 2 and name not in names: 

489 names.append(name) 

490 return names 

491 

492 

493""" Private Functions """ 

494 

495 

496def _convert(value: Any) -> Any: 

497 """Returns a cell of a CSV file as a number if it is one. 

498 

499 Args: 

500 value: the text of the cell. 

501 

502 Returns: 

503 An `int` or `float` if the text is a number, the text otherwise, or 

504 None if the cell is blank. 

505 

506 """ 

507 if value is None: 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true

508 return None 

509 text = str(value).strip() 

510 if not text: 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true

511 return None 

512 for kind in (int, float): 

513 try: 

514 return kind(text) 

515 except ValueError: 

516 continue 

517 return text 

518 

519 

520def _listify(item: Any) -> list[str]: 

521 """Returns a cell of a CSV file as a list of names. 

522 

523 Args: 

524 item: None, or a `str` that may list names separated by commas. 

525 

526 Returns: 

527 The names, with surrounding spaces removed and blank names dropped. 

528 

529 """ 

530 if item is None: 530 ↛ 531line 530 didn't jump to line 531 because the condition on line 530 was never true

531 return [] 

532 return [name.strip() for name in str(item).split(',') if name.strip()] 

533 

534 

535def _read(path: pathlib.Path) -> list[Rule]: 

536 """Returns the rules in the CSV file at `path`. 

537 

538 Args: 

539 path: path to a CSV file of rules, in UTF-8 (with or without a byte 

540 order mark, as Excel saves them). 

541 

542 Returns: 

543 The rules, in order. 

544 

545 Raises: 

546 ValueError: if the file is missing a column or a row is not a valid 

547 rule. 

548 

549 """ 

550 with path.open(encoding = 'utf-8-sig', newline = '') as file: 

551 reader = csv.DictReader(file) 

552 columns = [c.strip().lower() for c in reader.fieldnames or []] 

553 missing = [c for c in _REQUIRED if c not in columns] 

554 if missing: 

555 message = f'{path} is missing the columns {missing}' 

556 raise ValueError(message) 

557 rules = [] 

558 # Row 1 is the header, so the first rule is on row 2. 

559 for number, row in enumerate(reader, start = 2): 

560 cells = { 

561 str(k).strip().lower(): v for k, v in row.items() 

562 if k is not None} 

563 if not any(str(v or '').strip() for v in cells.values()): 563 ↛ 564line 563 didn't jump to line 564 because the condition on line 563 was never true

564 continue 

565 try: 

566 rules.append(Rule.create(cells)) 

567 except ValueError as error: 

568 message = f'{path}, row {number}: {error}' 

569 raise ValueError(message) from error 

570 return rules 

571 

572 

573def _to_bool(value: Any) -> bool: 

574 """Converts a cell of a CSV file to a boolean. 

575 

576 Args: 

577 value: a `bool`, or text such as "TRUE", "yes", "1", "t", "FALSE", 

578 "no", "0", or "f" (in any case). Blank text is False. 

579 

580 Returns: 

581 The boolean value. 

582 

583 Raises: 

584 ValueError: if `value` is text that is not one of those. 

585 

586 """ 

587 if isinstance(value, bool): 587 ↛ 588line 587 didn't jump to line 588 because the condition on line 587 was never true

588 return value 

589 text = '' if value is None else str(value).strip().lower() 

590 if text in {'true', 't', 'yes', 'y', '1'}: 

591 return True 

592 if text in {'false', 'f', 'no', 'n', '0', ''}: 

593 return False 

594 message = f'{value!r} is not TRUE or FALSE' 

595 raise ValueError(message) 

596 

597 

598def _unique(items: Iterable[str]) -> list[str]: 

599 """Returns `items` in order, without repeats. 

600 

601 Args: 

602 items: names. 

603 

604 Returns: 

605 The names, in the order first seen. 

606 

607 """ 

608 return list(dict.fromkeys(items))