Skip to content

Commit d46e167

Browse files
authored
Merge pull request #199 from derek73/worktree-maiden-name-bucket
Add maiden name field with delimiter routing (#22)
2 parents 67fcc8e + 15357b5 commit d46e167

10 files changed

Lines changed: 442 additions & 162 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ Each module defines a plain Python set of known name pieces:
9292
`HumanName` is the single public class. Assigning to `full_name` (or instantiating with a string) triggers `parse_full_name()`.
9393

9494
Parse flow:
95-
1. `pre_process()` — strips nicknames (parenthesis/quotes) and emoji, fixes "Ph.D." variant spellings
95+
1. `pre_process()` — strips nicknames/maiden names (parenthesis/quotes, routed to `nickname_list`/`maiden_list` per `Constants.nickname_delimiters`/`maiden_delimiters`) and emoji, fixes "Ph.D." variant spellings
9696
2. Split on commas → 1 part (no comma), 2 parts (suffix-comma or lastname-comma), 3+ parts
9797
3. `parse_pieces()` — splits on spaces, detects dotted abbreviations like "Lt.Gov." and adds them to constants dynamically
9898
4. `join_on_conjunctions()` — merges pieces adjacent to conjunctions into single tokens (e.g. `['Secretary', 'of', 'State']``['Secretary of State']`); also joins prefix particles to the following lastname token
@@ -110,11 +110,11 @@ Each named attribute (`title`, `first`, etc.) is a `@property` that joins its co
110110
2. Add `x: str | None = None` to `HumanName.__init__` signature after related kwargs
111111
3. Add `self.x = x if x is not None else self.C.x` in body — use `is not None`, not `or`, to allow falsy values like `""`
112112
4. conftest auto-restores scalar CONSTANTS between tests, but tests that *set* CONSTANTS mid-run still need their own try/finally
113-
5. Update `docs/customize.rst`'s constants list (and `docs/usage.rst` if it affects a documented example) — don't wait for the release checklist, these `.rst` docs aren't covered by CI so a stale one can ship silently.
113+
5. Update `docs/customize.rst`'s constants list (and `docs/usage.rst` if it affects a documented example) — don't wait for the release checklist, these `.rst` docs aren't covered by CI so a stale one can ship silently. Check `AGENTS.md` itself too (Extension Patterns, Gotchas, the Architecture section) for now-stale attribute/test names or descriptions — it has the same blind spot as the `.rst` docs and the release checklist only catches it at release time.
114114

115-
**Adding a new mutable/collection `Constants` attribute** (a `SetManager`/`TupleManager`-backed group, e.g. `extra_nickname_delimiters`): add it to `_COLLECTION_CONFIG_ATTRS` in `tests/conftest.py`, or tests that mutate the global `CONSTANTS` copy will leak state into later tests. Contents must be deep-copyable (the snapshot uses `copy.deepcopy`) — already true for the existing manager types.
115+
**Adding a new mutable/collection `Constants` attribute** (a `SetManager`/`TupleManager`-backed group, e.g. `nickname_delimiters`/`maiden_delimiters`): add it to `_COLLECTION_CONFIG_ATTRS` in `tests/conftest.py`, or tests that mutate the global `CONSTANTS` copy will leak state into later tests. Contents must be deep-copyable (the snapshot uses `copy.deepcopy`) — already true for the existing manager types.
116116

117-
Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_deepcopy_roundtrip`/`test_extra_nickname_delimiters_deepcopy_roundtrip` in `tests/test_constants.py`), not just reliance on conftest's autouse snapshot/restore exercising it incidentally. `TupleManager`/`RegexTupleManager.__getattr__` answer *any* unknown attribute lookup — including dunder probes like `__deepcopy__` — so a new manager subtype or a `__getattr__` tweak can silently break `copy.deepcopy` (this bit `RegexTupleManager` before the dunder-lookup guard was added). A direct test on the new attribute's own manager instance catches that where the conftest fixture, which never asserts on the copy, would not.
117+
Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_deepcopy_roundtrip`/`test_nickname_delimiters_deepcopy_roundtrip` in `tests/test_constants.py`), not just reliance on conftest's autouse snapshot/restore exercising it incidentally. `TupleManager`/`RegexTupleManager.__getattr__` answer *any* unknown attribute lookup — including dunder probes like `__deepcopy__` — so a new manager subtype or a `__getattr__` tweak can silently break `copy.deepcopy` (this bit `RegexTupleManager` before the dunder-lookup guard was added). A direct test on the new attribute's own manager instance catches that where the conftest fixture, which never asserts on the copy, would not. As with the scalar-attribute pattern above, also check `AGENTS.md` itself for now-stale references when you touch this.
118118

119119
**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc``PREFIXES``TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu``PREFIXES``first_name_prefixes` (position-dependent: leading token → first-name join, mid-name → last-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `first_name_prefixes`).
120120

@@ -140,6 +140,10 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_
140140

141141
**`is_suffix()`'s period-stripping is asymmetric** — it does `lc(piece).replace('.', '')` (strips *all* periods) before checking `suffix_acronyms`, but only `lc(piece)` (leading/trailing only) before checking `suffix_not_acronyms`. Code that reimplements this check instead of calling `is_suffix()` must mirror both branches or it will misclassify acronym suffixes with internal-only periods (e.g. `"M.D"` with no trailing dot) — bit `parse_nicknames()`'s `handle_match()` in PR #189.
142142

143+
**`nickname_delimiters`/`maiden_delimiters` built-ins are string sentinels, not compiled patterns** — the three default keys (`quoted_word`, `double_quotes`, `parenthesis`) store the *name* of a `Constants.regexes` entry (a plain `str`), resolved via `getattr(self.C.regexes, name)` at parse time in `parse_nicknames()` — not the compiled `re.Pattern` itself. This is what lets `CONSTANTS.regexes.parenthesis = ...` keep affecting nickname/maiden parsing after construction, same as before this mechanism existed. Routing a built-in between buckets must be a `pop()` + assign (`maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')`) to carry that string sentinel over — copying `CONSTANTS.regexes['parenthesis']` directly into the new bucket instead would freeze it as a snapshot and silently stop tracking further `regexes` overrides. A key added by a caller for a *custom* delimiter is a real compiled pattern, distinguished at parse time via `isinstance(raw_pattern, re.Pattern)`. (#22)
144+
145+
**`TupleManager.__setattr__`/`__delattr__` guard dunder names too, not just `__getattr__`** — constructing a subscripted generic, e.g. `TupleManager[re.Pattern[str] | str]({...})` (needed so mypy sees the right value type instead of inferring one from the dict literal), makes `typing`'s `GenericAlias.__call__` set `__orig_class__` on the new instance right after `__init__` returns. Before this guard existed, `__setattr__` was a bare `dict.__setitem__` alias, so that assignment silently inserted a bogus `'__orig_class__'` entry into the dict itself, corrupting `.values()`/iteration for *every* `TupleManager`/`RegexTupleManager` instance, not just the one being constructed — this bit `nickname_delimiters`'s construction (#22) before the guard was added. Same fix shape as the `__getattr__` dunder guard above: fall back to `object.__setattr__`/`object.__delattr__` for dunder names, dict-backed storage for everything else.
146+
143147
**`_join_first_name_prefix` guard must exclude trailing suffixes** — suffix tokens are still in `pieces` when the helper runs (suffix detection happens in the assignment loop, later). The `reserve_last` guard must count `if not self.is_suffix(p)` to avoid treating a trailing suffix like "Jr." as a last-name slot; otherwise `"abdul salam jr"``last='jr'`.
144148

145149
**Doctests** — docstring examples in `nameparser/*.py` run under `uv run pytest` (`--doctest-modules`; `testpaths` is `tests` + `nameparser` only). The `.rst` doctests in `docs/` (`usage.rst`, `customize.rst`) are **not** run by pytest or CI (CI does `sphinx-build -b html`, not `-b doctest`), so verify `.rst` examples manually: `python3 -c "import doctest; print(doctest.testfile('docs/usage.rst', module_relative=False, optionflags=doctest.NORMALIZE_WHITESPACE))"`. Note `customize.rst` has pre-existing failures under `-b doctest` (CONSTANTS state leaks across examples — no per-example reset like `tests/conftest.py` provides — plus non-deterministic `SetManager` repr).

docs/customize.rst

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,17 @@ remove punctuation to normalize them for comparison.
5656
Adding Custom Nickname Delimiters
5757
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5858

59-
:py:meth:`~nameparser.parser.HumanName.parse_nicknames` recognizes three
60-
built-in delimiters -- ``quoted_word``, ``double_quotes`` and
61-
``parenthesis`` -- read from :py:attr:`~nameparser.config.Constants.regexes`,
62-
so overriding e.g. ``CONSTANTS.regexes.parenthesis`` still works exactly as
63-
before. To recognize an *additional* delimiter without overriding one of the
64-
built-ins, add a pattern to
65-
:py:obj:`~nameparser.config.Constants.extra_nickname_delimiters` (empty by
66-
default) under any key, then re-run
59+
:py:meth:`~nameparser.parser.HumanName.parse_nicknames` recognizes delimiters
60+
through two per-bucket collections:
61+
:py:obj:`~nameparser.config.Constants.nickname_delimiters` (default: the
62+
three built-ins -- ``quoted_word``, ``double_quotes`` and ``parenthesis``,
63+
each resolved live from :py:attr:`~nameparser.config.Constants.regexes`, so
64+
overriding e.g. ``CONSTANTS.regexes.parenthesis`` still works exactly as
65+
before) and :py:obj:`~nameparser.config.Constants.maiden_delimiters` (empty
66+
by default -- see "Routing to Maiden Name" below).
67+
68+
To recognize an *additional* delimiter, add a compiled pattern to
69+
``nickname_delimiters`` under any key, then re-run
6770
:py:meth:`~nameparser.parser.HumanName.parse_full_name` to pick it up:
6871

6972
.. doctest::
@@ -73,11 +76,52 @@ default) under any key, then re-run
7376
>>> hn = HumanName("Benjamin {Ben} Franklin", constants=None)
7477
>>> hn.nickname
7578
''
76-
>>> hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
79+
>>> hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U)
7780
>>> hn.parse_full_name()
7881
>>> hn.nickname
7982
'Ben'
8083

84+
Routing to Maiden Name
85+
~~~~~~~~~~~~~~~~~~~~~~~
86+
87+
Parenthesized (or otherwise delimited) alternate/maiden surnames --
88+
``"Baker (Johnson), Jenny"`` -- go to ``nickname`` by default, same as any
89+
other delimited content. To route a delimiter to the first-class ``maiden``
90+
field instead, move its key from ``nickname_delimiters`` to
91+
``maiden_delimiters`` on a ``Constants`` instance (a plain ``dict.pop()`` +
92+
assign -- this preserves the live link back to ``regexes`` for the three
93+
built-ins) *before* parsing a name with it, the same way you'd configure
94+
``patronymic_name_order`` or ``middle_name_as_last``:
95+
96+
.. doctest::
97+
98+
>>> from nameparser import HumanName
99+
>>> from nameparser.config import Constants
100+
>>> C = Constants()
101+
>>> C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis')
102+
>>> hn = HumanName("Baker (Johnson), Jenny", constants=C)
103+
>>> hn.first, hn.last, hn.maiden
104+
('Jenny', 'Baker', 'Johnson')
105+
106+
This also strips the parenthesized maiden name from the no-comma written
107+
form, since routing happens before positional parsing:
108+
109+
.. doctest::
110+
111+
>>> hn = HumanName("Jenny Baker (Johnson)", constants=C)
112+
>>> hn.first, hn.last, hn.maiden
113+
('Jenny', 'Baker', 'Johnson')
114+
115+
Routing an already-active built-in delimiter on an *existing* ``HumanName``
116+
instance and calling ``parse_full_name()`` again will not work: only the
117+
``full_name`` setter resets the working copy of the name string back to the
118+
original input, so re-parsing in place has nothing left for the moved
119+
delimiter to match if it already matched during the first parse. Configure
120+
the ``Constants`` first, as above.
121+
122+
``maiden`` is not included in the default :py:obj:`~nameparser.config.Constants.string_format`,
123+
so ``str(hn)`` is unaffected unless you add ``{maiden}`` to your own format.
124+
81125
Other editable attributes
82126
~~~~~~~~~~~~~~~~~~~~~~~~~~
83127

@@ -283,6 +327,7 @@ constant so that "Hon" can be parsed as a first name.
283327
last: 'Solo'
284328
suffix: ''
285329
nickname: ''
330+
maiden: ''
286331
]>
287332
>>> from nameparser.config import CONSTANTS
288333
>>> CONSTANTS.titles.remove('hon')
@@ -296,6 +341,7 @@ constant so that "Hon" can be parsed as a first name.
296341
last: 'Solo'
297342
suffix: ''
298343
nickname: ''
344+
maiden: ''
299345
]>
300346

301347

@@ -336,6 +382,7 @@ making them lower case and removing periods.
336382
last: 'Johns'
337383
suffix: ''
338384
nickname: ''
385+
maiden: ''
339386
]>
340387

341388

@@ -363,6 +410,7 @@ the config on one instance could modify the behavior of another instance.
363410
last: 'Johns'
364411
suffix: ''
365412
nickname: ''
413+
maiden: ''
366414
]>
367415

368416

@@ -390,6 +438,7 @@ reference to the module-level config values with the behavior described above.
390438
last: 'Johns'
391439
suffix: ''
392440
nickname: ''
441+
maiden: ''
393442
]>
394443
>>> other_instance.has_own_config
395444
True
@@ -461,6 +510,7 @@ directly to the attribute.
461510
last: 'Doe'
462511
suffix: 'Md.'
463512
nickname: ''
513+
maiden: ''
464514
]>
465515

466516

docs/release_log.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ 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+
- Add a first-class ``maiden`` field and ``maiden_delimiters`` to ``Constants``, so a delimiter (e.g. parenthesis) can be routed to ``maiden`` instead of ``nickname`` for alternate/maiden surnames, e.g. ``"Baker (Johnson), Jenny"`` (closes #22)
1011
- Fix suffix-shaped parenthesized/quoted content (e.g. ``"(Ret)"``, ``"(MBA)"``) being misclassified as a nickname instead of a suffix (closes #111)
1112
- 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-
- Add ``extra_nickname_delimiters`` to ``Constants`` for registering additional nickname-delimiter regex patterns at runtime, without subclassing (closes #110, #112)
13+
- Add ``nickname_delimiters`` to ``Constants`` for registering additional nickname-delimiter regex patterns at runtime, without subclassing (closes #110, #112)
1314
- Fix missing comma between ``'msc'`` and ``'mscmsm'`` in ``suffix_acronyms``, which silently concatenated them into a bogus ``'mscmscmsm'`` entry (#111)
1415
- Add ``given_names`` (and ``given_names_list``) attribute as aggregate of first and middle names, mirroring ``surnames`` (closes #157)
1516
- Add ``suffix_delimiter`` to ``Constants`` and ``HumanName`` for parsing suffixes separated by arbitrary delimiters, e.g. ``"RN - CRNA"`` (#156)

docs/usage.rst

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Requires Python 3.10+.
4242
last: 'Velasquez y Garcia'
4343
suffix: 'Jr.'
4444
nickname: ''
45+
maiden: ''
4546
]>
4647
>>> name.middle = "Jason Alexander"
4748
>>> name.middle
@@ -54,15 +55,16 @@ Requires Python 3.10+.
5455
last: 'Velasquez y Garcia'
5556
suffix: 'Jr.'
5657
nickname: ''
58+
maiden: ''
5759
]>
5860
>>> name.middle = ["custom","values"]
5961
>>> name.middle
6062
'custom values'
6163
>>> name.full_name = 'Doe-Ray, Jonathan "John" A. Harris'
6264
>>> name.as_dict()
63-
{'last': 'Doe-Ray', 'suffix': '', 'title': '', 'middle': 'A. Harris', 'nickname': 'John', 'first': 'Jonathan'}
65+
{'title': '', 'first': 'Jonathan', 'middle': 'A. Harris', 'last': 'Doe-Ray', 'suffix': '', 'nickname': 'John', 'maiden': ''}
6466
>>> name.as_dict(False) # add False to hide keys with empty values
65-
{'middle': 'A. Harris', 'nickname': 'John', 'last': 'Doe-Ray', 'first': 'Jonathan'}
67+
{'first': 'Jonathan', 'middle': 'A. Harris', 'last': 'Doe-Ray', 'nickname': 'John'}
6668
>>> name = HumanName("Dr. Juan Q. Xavier de la Vega III")
6769
>>> name2 = HumanName("de la vega, dr. juan Q. xavier III")
6870
>>> name == name2
@@ -71,7 +73,7 @@ Requires Python 3.10+.
7173
5
7274
>>> list(name)
7375
['Dr.', 'Juan', 'Q. Xavier', 'de la Vega', 'III']
74-
>>> name[1:-2]
76+
>>> name[1:-3]
7577
['Juan', 'Q. Xavier', 'de la Vega']
7678

7779

@@ -145,6 +147,7 @@ available from the nickname attribute.
145147
last: 'Smith'
146148
suffix: ''
147149
nickname: 'John'
150+
maiden: ''
148151
]>
149152

150153
Exception: content that looks like a suffix (a member of
@@ -166,6 +169,7 @@ written in parenthesis.
166169
last: 'Perkins'
167170
suffix: 'MBA'
168171
nickname: ''
172+
maiden: ''
169173
]>
170174

171175
A few suffix acronyms, listed in
@@ -186,6 +190,7 @@ reading in that ambiguous context:
186190
last: 'BRICKEN'
187191
suffix: ''
188192
nickname: 'JD'
193+
maiden: ''
189194
]>
190195

191196
Leading Period-Abbreviation Titles
@@ -211,6 +216,7 @@ word appearing after the first name is left as a middle name.
211216
last: 'Smith'
212217
suffix: ''
213218
nickname: ''
219+
maiden: ''
214220
]>
215221
>>> name = HumanName("J. Smith")
216222
>>> name.first

0 commit comments

Comments
 (0)