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

1"""Writes judges' names in the ways that other sources may write them. 

2 

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: 

7 

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 | 

16 

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

22 

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

28 

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. 

33 

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. 

37 

38""" 

39 

40from __future__ import annotations 

41 

42import re 

43from typing import Any 

44 

45import pandas as pd 

46 

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

56 

57 

58def add_permutations(table: pd.DataFrame) -> pd.DataFrame: 

59 """Adds a column for each way of writing judges' names. 

60 

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

65 

66 Returns: 

67 A new table with the columns of `permutations` after "suffix". 

68 

69 Raises: 

70 KeyError: if `table` lacks one of the parts of a name. 

71 

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) 

86 

87 

88def columns() -> list[str]: 

89 """Returns the names of the columns that `permutations` makes. 

90 

91 Returns: 

92 The names, in order. 

93 

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 

101 

102 

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. 

109 

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. 

115 

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. 

120 

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 

142 

143 

144""" Private Functions """ 

145 

146 

147def _brackets(part: str, how: str) -> str: 

148 """Removes the square brackets in a part of a name. 

149 

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

154 

155 Returns: 

156 The part without brackets. 

157 

158 """ 

159 if how == 'remove': 

160 return _BRACKETS.sub(r'\1', part) 

161 return _BRACKETS.sub('.', part) 

162 

163 

164def _initial(middle: str) -> str: 

165 """Returns the initial of a middle name. 

166 

167 Args: 

168 middle: the middle name or names, such as "Lanier" or "Q.". 

169 

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. 

173 

174 """ 

175 letter = next((c for c in middle if c.isalpha()), '') 

176 return f'{letter.upper()}.' if letter else '' 

177 

178 

179def _text(part: Any) -> str: 

180 """Returns a part of a name as text with single spaces. 

181 

182 Args: 

183 part: the part, which may be missing. 

184 

185 Returns: 

186 The part, or an empty `str` if it is missing. 

187 

188 """ 

189 if part is None or (not isinstance(part, str) and pd.isna(part)): 

190 return '' 

191 return ' '.join(str(part).split())