Skip to content

Commit bb02739

Browse files
authored
Don't extract suffix-shaped parenthesized/quoted content as nicknames (#189)
- `parse_nicknames()` used to extract everything inside parens/quotes as a nickname unconditionally, so suffix-like content such as `(Ret)`, `(Jr.)`, `(MBA)`, `(M.D)` was wrongly classified as a nickname instead of a suffix (closes #111). - Added `SUFFIX_ACRONYMS_AMBIGUOUS` (`ed`, `jd`) as a small standalone exception list for acronym suffixes that also plausibly collide with a common given-name nickname, wired into `Constants` as a plain `SetManager` attribute. Existing `SUFFIX_ACRONYMS`/`suffix_acronyms` are otherwise unchanged (no API break). - Moved `'(ret)'`/`'(vet)'` out of `SUFFIX_ACRONYMS` into `SUFFIX_NOT_ACRONYMS` as bare `'ret'`/`'vet'`. - Rewrote `parse_nicknames()` to use a per-match callback: suffix-shaped or period-terminated content is left in place (undelimited) for normal downstream parsing instead of being routed to `nickname_list`. Correctly strips internal periods (e.g. `"M.D"`) before the acronym-suffix check, matching `is_suffix()`'s existing behavior. `JEFFREY (JD) BRICKEN` still parses `JD` as a nickname by default (regression guard). - Fixed an unrelated, pre-existing bug: a missing comma between `'msc'` and `'mscmsm'` in `SUFFIX_ACRONYMS` caused Python's implicit string-literal concatenation to silently merge them into a bogus `'mscmscmsm'` entry. - Updated `docs/usage.rst` and `docs/customize.rst` to document the new behavior and the `SUFFIX_ACRONYMS_AMBIGUOUS`/`suffix_acronyms_ambiguous` constant. Known, documented limitation (not fixed, out of scope): `parse_full_name`'s no-comma suffix detection only recognizes a trailing run of suffix-shaped pieces, so a freed suffix-shaped word only lands in `suffix` when already in a trailing/comma position in the source string — this predates the PR and is unrelated to it. Test plan: `pytest -q` — 980 passed, 4 skipped, 22 xfailed, no failures.
1 parent 284d2d8 commit bb02739

9 files changed

Lines changed: 246 additions & 8 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ env/
2828

2929
# docs
3030
docs/_*
31+
docs/superpowers/

docs/customize.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Editable attributes of nameparser.config.CONSTANTS
4343
* :py:data:`~nameparser.config.FIRST_NAME_TITLES` - Titles that, when followed by a single name, that name is a first name, e.g. "King David".
4444
* :py:data:`~nameparser.config.SUFFIX_ACRONYMS` - Pieces that come at the end of the name that may or may not have periods separating the letters, e.g. "m.d.".
4545
* :py:data:`~nameparser.config.SUFFIX_NOT_ACRONYMS` - Pieces that come at the end of the name that never have periods separating the letters, e.g. "Jr.".
46+
* :py:data:`~nameparser.config.SUFFIX_ACRONYMS_AMBIGUOUS` - Acronym suffixes from ``SUFFIX_ACRONYMS`` that also plausibly work as a given-name nickname on their own, e.g. "JD", "Ed". When one of these appears alone in parenthesis or quotes (e.g. ``'JEFFREY (JD) BRICKEN'``), it's kept as a nickname rather than reclassified as a suffix, since that's the more common reading in ambiguous, delimiter-only context (see the "Nickname Handling" section in the usage guide).
4647
* :py:data:`~nameparser.config.conjunctions.CONJUNCTIONS` - Connectors like "and" that join the preceding piece to the following piece.
4748
* :py:data:`~nameparser.config.prefixes.PREFIXES` - Connectors like "del" and "bin" that join to the following piece but not the preceding, similar to titles but can appear anywhere in the name.
4849
* :py:data:`~nameparser.config.CAPITALIZATION_EXCEPTIONS` - Dictionary of pieces that do not capitalize the first letter, e.g. "Ph.D".

docs/release_log.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ Release Log
77
to 1.2.1 first (which includes a one-version compatibility shim), load and
88
re-pickle under 1.2.1, then upgrade to 1.3.0.
99

10+
- Fix suffix-shaped parenthesized/quoted content (e.g. ``"(Ret)"``, ``"(MBA)"``) being misclassified as a nickname instead of a suffix (closes #111)
11+
- Add ``suffix_acronyms_ambiguous`` to ``Constants`` for acronym suffixes that also read as given-name nicknames (e.g. ``"JD"``, ``"Ed"``), used when disambiguating parenthesized/quoted content (#111)
12+
- Fix missing comma between ``'msc'`` and ``'mscmsm'`` in ``suffix_acronyms``, which silently concatenated them into a bogus ``'mscmscmsm'`` entry (#111)
1013
- Add ``given_names`` (and ``given_names_list``) attribute as aggregate of first and middle names, mirroring ``surnames`` (closes #157)
1114
- Add ``suffix_delimiter`` to ``Constants`` and ``HumanName`` for parsing suffixes separated by arbitrary delimiters, e.g. ``"RN - CRNA"`` (#156)
1215
- Add ``initials_separator`` to ``Constants`` and ``HumanName`` to control spacing between consecutive initials within a name group (#171)

docs/usage.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,47 @@ available from the nickname attribute.
147147
nickname: 'John'
148148
]>
149149

150+
Exception: content that looks like a suffix (a member of
151+
:py:data:`~nameparser.config.SUFFIX_ACRONYMS` or
152+
:py:data:`~nameparser.config.SUFFIX_NOT_ACRONYMS`, or anything ending in a
153+
period) is treated as a suffix instead of a nickname, since that's usually
154+
what's meant, e.g. a retired military title or a professional designation
155+
written in parenthesis.
156+
157+
.. doctest:: nicknames
158+
:options: +NORMALIZE_WHITESPACE
159+
160+
>>> name = HumanName('Andrew Perkins (MBA)')
161+
>>> name
162+
<HumanName : [
163+
title: ''
164+
first: 'Andrew'
165+
middle: ''
166+
last: 'Perkins'
167+
suffix: 'MBA'
168+
nickname: ''
169+
]>
170+
171+
A few suffix acronyms, listed in
172+
:py:data:`~nameparser.config.SUFFIX_ACRONYMS_AMBIGUOUS`, also work as common
173+
given-name nicknames on their own (e.g. "JD", "Ed"). These stay nicknames
174+
when found alone in parenthesis or quotes, since that's the more common
175+
reading in that ambiguous context:
176+
177+
.. doctest:: nicknames
178+
:options: +NORMALIZE_WHITESPACE
179+
180+
>>> name = HumanName('JEFFREY (JD) BRICKEN')
181+
>>> name
182+
<HumanName : [
183+
title: ''
184+
first: 'JEFFREY'
185+
middle: ''
186+
last: 'BRICKEN'
187+
suffix: ''
188+
nickname: 'JD'
189+
]>
190+
150191
Change the output string with string formatting
151192
-----------------------------------------------
152193

nameparser/config/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from nameparser.config.conjunctions import CONJUNCTIONS
4343
from nameparser.config.suffixes import SUFFIX_ACRONYMS
4444
from nameparser.config.suffixes import SUFFIX_NOT_ACRONYMS
45+
from nameparser.config.suffixes import SUFFIX_ACRONYMS_AMBIGUOUS
4546
from nameparser.config.titles import TITLES
4647
from nameparser.config.titles import FIRST_NAME_TITLES
4748
from nameparser.config.regexes import EMPTY_REGEX, REGEXES
@@ -236,8 +237,10 @@ class Constants:
236237
:py:attr:`~titles.FIRST_NAME_TITLES` wrapped with :py:class:`SetManager`.
237238
:param set suffix_acronyms:
238239
:py:attr:`~suffixes.SUFFIX_ACRONYMS` wrapped with :py:class:`SetManager`.
239-
:param set suffix_not_acronyms:
240+
:param set suffix_not_acronyms:
240241
:py:attr:`~suffixes.SUFFIX_NOT_ACRONYMS` wrapped with :py:class:`SetManager`.
242+
:param set suffix_acronyms_ambiguous:
243+
:py:attr:`~suffixes.SUFFIX_ACRONYMS_AMBIGUOUS` wrapped with :py:class:`SetManager`.
241244
:param set conjunctions:
242245
:py:attr:`conjunctions` wrapped with :py:class:`SetManager`.
243246
:param set first_name_prefixes:
@@ -257,6 +260,7 @@ class Constants:
257260
first_name_titles: SetManager
258261
conjunctions: SetManager
259262
first_name_prefixes: SetManager
263+
suffix_acronyms_ambiguous: SetManager
260264
capitalization_exceptions: TupleManager[str]
261265
regexes: RegexTupleManager
262266
_pst: Set[str] | None
@@ -388,6 +392,7 @@ def __init__(self,
388392
prefixes: Iterable[str] = PREFIXES,
389393
suffix_acronyms: Iterable[str] = SUFFIX_ACRONYMS,
390394
suffix_not_acronyms: Iterable[str] = SUFFIX_NOT_ACRONYMS,
395+
suffix_acronyms_ambiguous: Iterable[str] = SUFFIX_ACRONYMS_AMBIGUOUS,
391396
titles: Iterable[str] = TITLES,
392397
first_name_titles: Iterable[str] = FIRST_NAME_TITLES,
393398
conjunctions: Iterable[str] = CONJUNCTIONS,
@@ -406,6 +411,7 @@ def __init__(self,
406411
self.first_name_titles = SetManager(first_name_titles)
407412
self.conjunctions = SetManager(conjunctions)
408413
self.first_name_prefixes = SetManager(first_name_prefixes)
414+
self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous)
409415
self.capitalization_exceptions = TupleManager(capitalization_exceptions)
410416
self.regexes = RegexTupleManager(regexes)
411417
self.patronymic_name_order = patronymic_name_order

nameparser/config/suffixes.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,44 @@
1313
'iii',
1414
'iv',
1515
'v',
16+
# Bare, not '(ret)'/'(vet)': moved here from literal parenthesized
17+
# entries in SUFFIX_ACRONYMS. parse_nicknames()'s handle_match() now
18+
# strips parens/quotes before this set is consulted, so the bare form
19+
# is correct -- do not re-add the parenthesized form, that would
20+
# silently reintroduce the #111 bug (parenthesized "(Ret)" matching
21+
# literally instead of going through nickname/suffix disambiguation).
22+
'ret',
23+
'vet',
1624
])
1725
"""
1826
1927
Post-nominal pieces that are not acronyms. The parser does not remove periods
2028
when matching against these pieces.
2129
30+
"""
31+
SUFFIX_ACRONYMS_AMBIGUOUS = set([
32+
# Suffix acronyms that also commonly work as given-name nicknames on
33+
# their own (e.g. "Ed", "JD"). Read only by HumanName.parse_nicknames()
34+
# when deciding whether parenthesized/quoted content is a nickname or a
35+
# suffix -- content matching one of these stays a nickname rather than
36+
# being reclassified as a suffix, since that's the more common reading
37+
# in ambiguous, delimiter-only context.
38+
#
39+
# When adding a new entry to SUFFIX_ACRONYMS, also add it here only if
40+
# the exact letter sequence could plausibly be someone's given name or
41+
# common nickname on its own (e.g. 'jd', 'ed'). Unambiguous
42+
# certifications/degrees (e.g. 'mba', 'cpa', 'phd') don't need an entry.
43+
'ed',
44+
'jd',
45+
])
46+
"""
47+
48+
Acronym suffixes from SUFFIX_ACRONYMS that also plausibly collide with a
49+
common given-name nickname. Not a partition of SUFFIX_ACRONYMS -- a small,
50+
standalone exception list consulted only by parse_nicknames().
51+
2252
"""
2353
SUFFIX_ACRONYMS = set([
24-
'(ret)',
25-
'(vet)',
2654
'8-vsb',
2755
'aas',
2856
'aba',
@@ -501,7 +529,7 @@
501529
'mra',
502530
'ms',
503531
'msa',
504-
'msc'
532+
'msc',
505533
'mscmsm',
506534
'msm',
507535
'mt',

nameparser/parser.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,11 @@ def fix_phd(self) -> None:
774774
def parse_nicknames(self) -> None:
775775
"""
776776
The content of parenthesis or quotes in the name will be added to the
777-
nicknames list. This happens before any other processing of the name.
777+
nicknames list, unless that content is suffix-shaped -- an unambiguous
778+
suffix_not_acronyms/suffix_acronyms member, or content ending in a
779+
period -- in which case it's left in place (undelimited) for normal
780+
downstream suffix/title/word parsing instead. This happens before any
781+
other processing of the name.
778782
779783
Single quotes cannot span white space characters and must border
780784
white space to allow for quotes in names like O'Connor and Kawai'ae'a.
@@ -788,10 +792,45 @@ def parse_nicknames(self) -> None:
788792
re_double_quotes = self.C.regexes.double_quotes
789793
re_parenthesis = self.C.regexes.parenthesis
790794

795+
def handle_match(m: 're.Match[str]') -> str:
796+
# Fall back to the whole match when the regex has no capturing
797+
# group (e.g. a custom override regex without one, like
798+
# EMPTY_REGEX) -- mirrors the old code's use of findall(), which
799+
# returns the whole match for group-less patterns.
800+
content = m.group(1) if m.lastindex else m.group(0)
801+
stripped = lc(content)
802+
# Inlined rather than calling self.is_suffix(content): is_suffix()
803+
# also rejects single-letter initials via is_an_initial(), which
804+
# isn't relevant here, and the suffix_acronyms_ambiguous exclusion
805+
# needs to be interleaved into the acronym branch specifically.
806+
# Acronym suffixes may have periods between every letter (e.g.
807+
# "M.D", "Ph.D") that aren't necessarily trailing, so -- exactly
808+
# like is_suffix() -- strip all periods before checking
809+
# suffix_acronyms/suffix_acronyms_ambiguous membership. Bare
810+
# `stripped` (lc() only strips leading/trailing periods) is still
811+
# used for suffix_not_acronyms, matching is_suffix()'s asymmetry.
812+
acronym_stripped = stripped.replace('.', '')
813+
is_unambiguous_suffix = (
814+
stripped in self.C.suffix_not_acronyms
815+
or (acronym_stripped in self.C.suffix_acronyms
816+
and acronym_stripped not in self.C.suffix_acronyms_ambiguous)
817+
)
818+
if is_unambiguous_suffix or content.endswith('.'):
819+
# Leave the bare content -- no delimiters -- so downstream
820+
# word-splitting/suffix-matching sees it exactly as if it had
821+
# never been wrapped in parens/quotes. is_suffix()/lc() only
822+
# strip periods, never parens/quotes, so returning m.group(0)
823+
# here (e.g. literal "(Ret)") would never match
824+
# suffix_not_acronyms ("ret").
825+
return content
826+
self.nickname_list.append(content)
827+
return ''
828+
829+
# Same handle_match for all three delimiters: suffix-shaped content
830+
# is rare in quotes but not impossible, and the logic is delimiter-
831+
# agnostic, so there's no reason to special-case parenthesis here.
791832
for _re in (re_quoted_word, re_double_quotes, re_parenthesis):
792-
if _re.search(self._full_name):
793-
self.nickname_list += [x for x in _re.findall(self._full_name)]
794-
self._full_name = _re.sub('', self._full_name)
833+
self._full_name = _re.sub(handle_match, self._full_name)
795834

796835
def squash_emoji(self) -> None:
797836
"""

tests/test_nicknames.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@ def test_nickname_and_last_name_with_title(self) -> None:
134134
self.m(hn.last, "Edmonds", hn)
135135
self.m(hn.nickname, "Rick", hn)
136136

137+
def test_ambiguous_suffix_acronym_in_parenthesis_stays_nickname(self) -> None:
138+
# JD is in SUFFIX_ACRONYMS_AMBIGUOUS: both a law-degree acronym and a
139+
# common given-name nickname. Existing behavior (nickname) must be
140+
# preserved -- see issue #111.
141+
hn = HumanName("JEFFREY (JD) BRICKEN")
142+
self.m(hn.nickname, "JD", hn)
143+
self.m(hn.suffix, "", hn)
144+
137145

138146
# class MaidenNameTestCase(HumanNameTestBase):
139147
#

tests/test_suffixes.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,114 @@ def test_suffix_delimiter_inverted_format_known_limitation(self) -> None:
274274
# This is a documented limitation — do not "fix" it without a broader solution.
275275
hn = HumanName("Doe, Mary - Kate, RN", suffix_delimiter=" - ")
276276
self.assertNotEqual(hn.first, "Mary - Kate")
277+
278+
def test_suffix_acronyms_ambiguous_is_customizable(self) -> None:
279+
from nameparser.config import Constants
280+
custom = Constants(suffix_acronyms_ambiguous=['xyz'])
281+
self.assertEqual(set(custom.suffix_acronyms_ambiguous), {'xyz'})
282+
# Constructing without the kwarg still works and uses the module default.
283+
default = Constants()
284+
self.assertIn('jd', default.suffix_acronyms_ambiguous)
285+
286+
def test_suffix_in_parenthesis_with_other_suffixes(self) -> None:
287+
hn = HumanName("Andrew Perkins, Jr., Col. (Ret)")
288+
self.m(hn.first, "Andrew", hn)
289+
self.m(hn.last, "Perkins", hn)
290+
self.assertIn("Ret", hn.suffix)
291+
self.m(hn.nickname, "", hn)
292+
293+
def test_suffix_in_parenthesis_mid_name(self) -> None:
294+
# "Jr." is suffix-shaped, so parse_nicknames() no longer treats it as
295+
# a nickname. But it isn't in trailing position, and parse_full_name's
296+
# suffix detection only recognizes a trailing run of suffix-shaped
297+
# pieces -- so it lands wherever normal parsing would put a bare
298+
# mid-name "Jr." token, exactly as if the parens were never there
299+
# (verified: HumanName("Lon Jr. Williams") parses identically).
300+
# Known limitation: making this land in `suffix` would require
301+
# changing parse_full_name's suffix detection, out of scope here --
302+
# issue #111 is specifically about the nickname misclassification.
303+
hn = HumanName("Lon (Jr.) Williams")
304+
self.m(hn.first, "Lon", hn)
305+
self.m(hn.middle, "Jr.", hn)
306+
self.m(hn.last, "Williams", hn)
307+
self.m(hn.suffix, "", hn)
308+
self.m(hn.nickname, "", hn)
309+
310+
def test_suffix_in_parenthesis_with_period(self) -> None:
311+
# Same known limitation as above: "Ret." is mid-name (no comma), so
312+
# it's outside the trailing run parse_full_name's suffix detection
313+
# requires. It parses exactly as bare "Col. Ret. Smith" would.
314+
hn = HumanName("Col. (Ret.) Smith")
315+
self.m(hn.title, "Col.", hn)
316+
self.m(hn.first, "Ret.", hn)
317+
self.m(hn.last, "Smith", hn)
318+
self.m(hn.suffix, "", hn)
319+
self.m(hn.nickname, "", hn)
320+
321+
def test_acronym_suffix_in_parenthesis(self) -> None:
322+
hn = HumanName("Andrew Perkins (MBA)")
323+
self.m(hn.first, "Andrew", hn)
324+
self.m(hn.last, "Perkins", hn)
325+
self.m(hn.suffix, "MBA", hn)
326+
self.m(hn.nickname, "", hn)
327+
328+
def test_acronym_suffix_with_internal_periods_in_parenthesis(self) -> None:
329+
# "M.D" has a non-trailing period between every letter -- unlike
330+
# is_suffix(), handle_match()'s suffix_acronyms check must also strip
331+
# internal periods (not just rely on the trailing content.endswith('.')
332+
# heuristic, which doesn't fire here since "M.D" has no trailing period).
333+
hn = HumanName("Andrew Perkins (M.D)")
334+
self.m(hn.first, "Andrew", hn)
335+
self.m(hn.last, "Perkins", hn)
336+
self.m(hn.suffix, "M.D", hn)
337+
self.m(hn.nickname, "", hn)
338+
339+
def test_period_terminated_content_in_parenthesis_not_forced_either_way(self) -> None:
340+
# "Mgr." isn't in any suffix list, but it ends in a period, so the
341+
# period heuristic (rule 2) excludes it from nickname_list. It flows
342+
# into normal parsing instead of being force-classified as a suffix.
343+
hn = HumanName("Andrew Perkins (Mgr.)")
344+
self.m(hn.nickname, "", hn)
345+
self.m(hn.suffix, "", hn)
346+
347+
def test_suffix_in_single_quotes(self) -> None:
348+
# handle_match() is shared across all three delimiter regexes, not
349+
# just parenthesis -- confirm suffix-shaped single-quoted content
350+
# routes the same way.
351+
hn = HumanName("Andrew Perkins 'MBA'")
352+
self.m(hn.first, "Andrew", hn)
353+
self.m(hn.last, "Perkins", hn)
354+
self.m(hn.suffix, "MBA", hn)
355+
self.m(hn.nickname, "", hn)
356+
357+
def test_suffix_in_double_quotes(self) -> None:
358+
hn = HumanName('Andrew Perkins "MBA"')
359+
self.m(hn.first, "Andrew", hn)
360+
self.m(hn.last, "Perkins", hn)
361+
self.m(hn.suffix, "MBA", hn)
362+
self.m(hn.nickname, "", hn)
363+
364+
def test_suffix_acronyms_ambiguous_custom_entry_stays_nickname(self) -> None:
365+
# A custom suffix_acronyms_ambiguous entry keeps a suffix_acronyms
366+
# member classified as a nickname instead of a suffix, confirming
367+
# the exception list -- not a hardcoded check -- drives the behavior.
368+
from nameparser.config import Constants
369+
C = Constants(
370+
suffix_acronyms=['xyz'],
371+
suffix_acronyms_ambiguous=['xyz'],
372+
)
373+
hn = HumanName("Andrew Perkins (XYZ)", constants=C)
374+
self.m(hn.nickname, "XYZ", hn)
375+
self.m(hn.suffix, "", hn)
376+
377+
def test_suffix_acronyms_ambiguous_removal_routes_to_suffix(self) -> None:
378+
# Removing 'jd' from a custom suffix_acronyms_ambiguous flips JD
379+
# from nickname to suffix. Uses a trailing-position name (unlike the
380+
# JEFFREY (JD) BRICKEN regression guard in test_nicknames.py) so
381+
# parse_full_name's trailing-run suffix detection actually picks it
382+
# up -- see the known mid-name limitation noted on the tests above.
383+
from nameparser.config import Constants
384+
C = Constants(suffix_acronyms_ambiguous=[])
385+
hn = HumanName("Andrew Perkins (JD)", constants=C)
386+
self.m(hn.nickname, "", hn)
387+
self.m(hn.suffix, "JD", hn)

0 commit comments

Comments
 (0)