Coverage for src/fjc_biographies/names.py: 100%
49 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-22 22:47 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-22 22:47 +0000
1"""Writes judges' names in the ways that other sources may write them.
3The FJC gives each judge's name in parts: first, middle, and last names and a
4suffix (such as "Jr." or "III"). Opinions, other datasets, and news write a
5judge's name in other ways, so the roster has a column for each way, which can
6be matched against them:
8| Column | Example |
9| --- | --- |
10| `name_first_middle_last` | Robert Lanier Anderson |
11| `name_first_middle_last_suffix` | Robert Lanier Anderson, III |
12| `name_first_middle_initial_last` | Robert L. Anderson |
13| `name_first_middle_initial_last_suffix` | Robert L. Anderson, III |
14| `name_first_last` | Robert Anderson |
15| `name_first_last_suffix` | Robert Anderson, III |
17The FJC puts the letters of a name that a judge does not use in square
18brackets, as in "R[obert] Lanier Anderson III", who went by "R. Lanier
19Anderson". So each column above has the brackets removed ("Robert"), and a
20second column, whose name ends in "_bracket_period", has the brackets and the
21letters in them replaced with a period ("R."):
23| Column | Example |
24| --- | --- |
25| `name_first_middle_last_bracket_period` | R. Lanier Anderson |
26| `name_first_middle_last_suffix_bracket_period` | R. Lanier Anderson, III |
27| ... | ... |
29For a name without brackets, the two columns are the same. A part of a name
30that is missing is left out, so a judge without a middle name has
31"Mary Lynch" for both "name_first_middle_last" and "name_first_last", and a
32judge without a suffix has no comma.
34Contents:
35 add_permutations: adds a column for each way of writing judges' names.
36 permutations: returns each way of writing a judge's name.
38"""
40from __future__ import annotations
42import re
43from typing import Any
45import pandas as pd
47# The ways of writing a name without its suffix, in order. Each part is
48# written as `_format` finds it.
49_FORMS: dict[str, tuple[str, ...]] = {
50 'first_middle_last': ('first', 'middle', 'last'),
51 'first_middle_initial_last': ('first', 'middle_initial', 'last'),
52 'first_last': ('first', 'last')}
53# The letters in square brackets, such as "obert" in "R[obert]".
54_BRACKETS: re.Pattern[str] = re.compile(r'\[([^\]]*)\]')
55_PARTS: tuple[str, ...] = ('first_name', 'middle_name', 'last_name', 'suffix')
58def add_permutations(table: pd.DataFrame) -> pd.DataFrame:
59 """Adds a column for each way of writing judges' names.
61 Args:
62 table: a table with the columns "first_name", "middle_name",
63 "last_name", and "suffix" (such as a roster or the tidy table of
64 the file of demographics).
66 Returns:
67 A new table with the columns of `permutations` after "suffix".
69 Raises:
70 KeyError: if `table` lacks one of the parts of a name.
72 """
73 missing = [part for part in _PARTS if part not in table.columns]
74 if missing:
75 message = (
76 f'names are written from the columns {missing}, which the table '
77 f'does not have')
78 raise KeyError(message)
79 written = pd.DataFrame(
80 [permutations(*parts) for parts in zip(
81 *(table[part] for part in _PARTS), strict = True)],
82 index = table.index, columns = columns(), dtype = 'str')
83 place = list(table.columns).index('suffix') + 1
84 before, after = table.iloc[:, :place], table.iloc[:, place:]
85 return pd.concat([before, written, after], axis = 1)
88def columns() -> list[str]:
89 """Returns the names of the columns that `permutations` makes.
91 Returns:
92 The names, in order.
94 """
95 names = []
96 for form in _FORMS:
97 for suffix in ('', '_suffix'):
98 names.extend([
99 f'name_{form}{suffix}', f'name_{form}{suffix}_bracket_period'])
100 return names
103def permutations(
104 first: Any,
105 middle: Any,
106 last: Any,
107 suffix: Any = None) -> dict[str, str | None]:
108 """Returns each way of writing a judge's name.
110 Args:
111 first: the first name, such as "R[obert]".
112 middle: the middle name or names, such as "Lanier". It may be missing.
113 last: the last name, such as "Anderson".
114 suffix: the suffix, such as "III". Defaults to None.
116 Returns:
117 Each way of writing the name, by the name of its column (see the
118 module's documentation). All are None if the name has no first
119 or last name.
121 """
122 first, middle, last, suffix = (
123 _text(part) for part in (first, middle, last, suffix))
124 if not first and not last:
125 return dict.fromkeys(columns())
126 found: dict[str, str | None] = {}
127 for form, parts in _FORMS.items():
128 for with_suffix in (False, True):
129 name = f'name_{form}{"_suffix" if with_suffix else ""}'
130 for brackets, ending in (('remove', ''), ('period', '_bracket_period')):
131 cleaned = {
132 'first': _brackets(first, brackets),
133 'middle': _brackets(middle, brackets),
134 'middle_initial': _initial(_brackets(middle, 'remove')),
135 'last': _brackets(last, brackets)}
136 written = ' '.join(
137 cleaned[part] for part in parts if cleaned[part])
138 if with_suffix and suffix:
139 written = f'{written}, {suffix}'
140 found[f'{name}{ending}'] = written
141 return found
144""" Private Functions """
147def _brackets(part: str, how: str) -> str:
148 """Removes the square brackets in a part of a name.
150 Args:
151 part: the part, such as "R[obert]".
152 how: "remove" to keep the letters in the brackets ("Robert"), or
153 "period" to put a period in place of them ("R.").
155 Returns:
156 The part without brackets.
158 """
159 if how == 'remove':
160 return _BRACKETS.sub(r'\1', part)
161 return _BRACKETS.sub('.', part)
164def _initial(middle: str) -> str:
165 """Returns the initial of a middle name.
167 Args:
168 middle: the middle name or names, such as "Lanier" or "Q.".
170 Returns:
171 The first letter of the first middle name with a period, such as
172 "L.", or an empty `str` if there is no middle name.
174 """
175 letter = next((c for c in middle if c.isalpha()), '')
176 return f'{letter.upper()}.' if letter else ''
179def _text(part: Any) -> str:
180 """Returns a part of a name as text with single spaces.
182 Args:
183 part: the part, which may be missing.
185 Returns:
186 The part, or an empty `str` if it is missing.
188 """
189 if part is None or (not isinstance(part, str) and pd.isna(part)):
190 return ''
191 return ' '.join(str(part).split())