diff --git a/AGENTS.md b/AGENTS.md index ff2bebc..9e419fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Each module defines a plain Python set of known name pieces: `HumanName` is the single public class. Assigning to `full_name` (or instantiating with a string) triggers `parse_full_name()`. Parse flow: -1. `pre_process()` — strips nicknames (parenthesis/quotes) and emoji, fixes "Ph.D." variant spellings +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 2. Split on commas → 1 part (no comma), 2 parts (suffix-comma or lastname-comma), 3+ parts 3. `parse_pieces()` — splits on spaces, detects dotted abbreviations like "Lt.Gov." and adds them to constants dynamically 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 2. Add `x: str | None = None` to `HumanName.__init__` signature after related kwargs 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 `""` 4. conftest auto-restores scalar CONSTANTS between tests, but tests that *set* CONSTANTS mid-run still need their own try/finally -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. +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. -**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. +**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. -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. +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. **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`). @@ -140,6 +140,10 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **`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. +**`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) + +**`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. + **`_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'`. **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). diff --git a/docs/customize.rst b/docs/customize.rst index 45d93b1..265e6c7 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -56,14 +56,17 @@ remove punctuation to normalize them for comparison. Adding Custom Nickname Delimiters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -:py:meth:`~nameparser.parser.HumanName.parse_nicknames` recognizes three -built-in delimiters -- ``quoted_word``, ``double_quotes`` and -``parenthesis`` -- read from :py:attr:`~nameparser.config.Constants.regexes`, -so overriding e.g. ``CONSTANTS.regexes.parenthesis`` still works exactly as -before. To recognize an *additional* delimiter without overriding one of the -built-ins, add a pattern to -:py:obj:`~nameparser.config.Constants.extra_nickname_delimiters` (empty by -default) under any key, then re-run +:py:meth:`~nameparser.parser.HumanName.parse_nicknames` recognizes delimiters +through two per-bucket collections: +:py:obj:`~nameparser.config.Constants.nickname_delimiters` (default: the +three built-ins -- ``quoted_word``, ``double_quotes`` and ``parenthesis``, +each resolved live from :py:attr:`~nameparser.config.Constants.regexes`, so +overriding e.g. ``CONSTANTS.regexes.parenthesis`` still works exactly as +before) and :py:obj:`~nameparser.config.Constants.maiden_delimiters` (empty +by default -- see "Routing to Maiden Name" below). + +To recognize an *additional* delimiter, add a compiled pattern to +``nickname_delimiters`` under any key, then re-run :py:meth:`~nameparser.parser.HumanName.parse_full_name` to pick it up: .. doctest:: @@ -73,11 +76,52 @@ default) under any key, then re-run >>> hn = HumanName("Benjamin {Ben} Franklin", constants=None) >>> hn.nickname '' - >>> hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) + >>> hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) >>> hn.parse_full_name() >>> hn.nickname 'Ben' +Routing to Maiden Name +~~~~~~~~~~~~~~~~~~~~~~~ + +Parenthesized (or otherwise delimited) alternate/maiden surnames -- +``"Baker (Johnson), Jenny"`` -- go to ``nickname`` by default, same as any +other delimited content. To route a delimiter to the first-class ``maiden`` +field instead, move its key from ``nickname_delimiters`` to +``maiden_delimiters`` on a ``Constants`` instance (a plain ``dict.pop()`` + +assign -- this preserves the live link back to ``regexes`` for the three +built-ins) *before* parsing a name with it, the same way you'd configure +``patronymic_name_order`` or ``middle_name_as_last``: + +.. doctest:: + + >>> from nameparser import HumanName + >>> from nameparser.config import Constants + >>> C = Constants() + >>> C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') + >>> hn = HumanName("Baker (Johnson), Jenny", constants=C) + >>> hn.first, hn.last, hn.maiden + ('Jenny', 'Baker', 'Johnson') + +This also strips the parenthesized maiden name from the no-comma written +form, since routing happens before positional parsing: + +.. doctest:: + + >>> hn = HumanName("Jenny Baker (Johnson)", constants=C) + >>> hn.first, hn.last, hn.maiden + ('Jenny', 'Baker', 'Johnson') + +Routing an already-active built-in delimiter on an *existing* ``HumanName`` +instance and calling ``parse_full_name()`` again will not work: only the +``full_name`` setter resets the working copy of the name string back to the +original input, so re-parsing in place has nothing left for the moved +delimiter to match if it already matched during the first parse. Configure +the ``Constants`` first, as above. + +``maiden`` is not included in the default :py:obj:`~nameparser.config.Constants.string_format`, +so ``str(hn)`` is unaffected unless you add ``{maiden}`` to your own format. + Other editable attributes ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -283,6 +327,7 @@ constant so that "Hon" can be parsed as a first name. last: 'Solo' suffix: '' nickname: '' + maiden: '' ]> >>> from nameparser.config import CONSTANTS >>> CONSTANTS.titles.remove('hon') @@ -296,6 +341,7 @@ constant so that "Hon" can be parsed as a first name. last: 'Solo' suffix: '' nickname: '' + maiden: '' ]> @@ -336,6 +382,7 @@ making them lower case and removing periods. last: 'Johns' suffix: '' nickname: '' + maiden: '' ]> @@ -363,6 +410,7 @@ the config on one instance could modify the behavior of another instance. last: 'Johns' suffix: '' nickname: '' + maiden: '' ]> @@ -390,6 +438,7 @@ reference to the module-level config values with the behavior described above. last: 'Johns' suffix: '' nickname: '' + maiden: '' ]> >>> other_instance.has_own_config True @@ -461,6 +510,7 @@ directly to the attribute. last: 'Doe' suffix: 'Md.' nickname: '' + maiden: '' ]> diff --git a/docs/release_log.rst b/docs/release_log.rst index 985bb39..ac5f5be 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -7,9 +7,10 @@ Release Log to 1.2.1 first (which includes a one-version compatibility shim), load and re-pickle under 1.2.1, then upgrade to 1.3.0. + - 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) - Fix suffix-shaped parenthesized/quoted content (e.g. ``"(Ret)"``, ``"(MBA)"``) being misclassified as a nickname instead of a suffix (closes #111) - 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) - - Add ``extra_nickname_delimiters`` to ``Constants`` for registering additional nickname-delimiter regex patterns at runtime, without subclassing (closes #110, #112) + - Add ``nickname_delimiters`` to ``Constants`` for registering additional nickname-delimiter regex patterns at runtime, without subclassing (closes #110, #112) - Fix missing comma between ``'msc'`` and ``'mscmsm'`` in ``suffix_acronyms``, which silently concatenated them into a bogus ``'mscmscmsm'`` entry (#111) - Add ``given_names`` (and ``given_names_list``) attribute as aggregate of first and middle names, mirroring ``surnames`` (closes #157) - Add ``suffix_delimiter`` to ``Constants`` and ``HumanName`` for parsing suffixes separated by arbitrary delimiters, e.g. ``"RN - CRNA"`` (#156) diff --git a/docs/usage.rst b/docs/usage.rst index b06513f..a40130b 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -42,6 +42,7 @@ Requires Python 3.10+. last: 'Velasquez y Garcia' suffix: 'Jr.' nickname: '' + maiden: '' ]> >>> name.middle = "Jason Alexander" >>> name.middle @@ -54,15 +55,16 @@ Requires Python 3.10+. last: 'Velasquez y Garcia' suffix: 'Jr.' nickname: '' + maiden: '' ]> >>> name.middle = ["custom","values"] >>> name.middle 'custom values' >>> name.full_name = 'Doe-Ray, Jonathan "John" A. Harris' >>> name.as_dict() - {'last': 'Doe-Ray', 'suffix': '', 'title': '', 'middle': 'A. Harris', 'nickname': 'John', 'first': 'Jonathan'} + {'title': '', 'first': 'Jonathan', 'middle': 'A. Harris', 'last': 'Doe-Ray', 'suffix': '', 'nickname': 'John', 'maiden': ''} >>> name.as_dict(False) # add False to hide keys with empty values - {'middle': 'A. Harris', 'nickname': 'John', 'last': 'Doe-Ray', 'first': 'Jonathan'} + {'first': 'Jonathan', 'middle': 'A. Harris', 'last': 'Doe-Ray', 'nickname': 'John'} >>> name = HumanName("Dr. Juan Q. Xavier de la Vega III") >>> name2 = HumanName("de la vega, dr. juan Q. xavier III") >>> name == name2 @@ -71,7 +73,7 @@ Requires Python 3.10+. 5 >>> list(name) ['Dr.', 'Juan', 'Q. Xavier', 'de la Vega', 'III'] - >>> name[1:-2] + >>> name[1:-3] ['Juan', 'Q. Xavier', 'de la Vega'] @@ -145,6 +147,7 @@ available from the nickname attribute. last: 'Smith' suffix: '' nickname: 'John' + maiden: '' ]> Exception: content that looks like a suffix (a member of @@ -166,6 +169,7 @@ written in parenthesis. last: 'Perkins' suffix: 'MBA' nickname: '' + maiden: '' ]> A few suffix acronyms, listed in @@ -186,6 +190,7 @@ reading in that ambiguous context: last: 'BRICKEN' suffix: '' nickname: 'JD' + maiden: '' ]> Leading Period-Abbreviation Titles @@ -211,6 +216,7 @@ word appearing after the first name is left as a middle name. last: 'Smith' suffix: '' nickname: '' + maiden: '' ]> >>> name = HumanName("J. Smith") >>> name.first diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index ee7d3f2..560c1bd 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -156,8 +156,27 @@ def __getattr__(self, attr: str) -> T | None: raise AttributeError(attr) return self.get(attr) - __setattr__ = dict.__setitem__ - __delattr__ = dict.__delitem__ + def __setattr__(self, attr: str, value: T) -> None: + # Dunder names are Python's protocol probes, not config keys -- same + # rationale as __getattr__ above. Concretely: constructing a + # subscripted generic, e.g. TupleManager[re.Pattern[str] | str](...), + # makes typing's GenericAlias.__call__ set `__orig_class__` on the new + # instance right after __init__ returns. Without this guard that + # assignment falls through to dict.__setitem__ and silently inserts a + # bogus '__orig_class__' entry into the dict itself, corrupting + # .values()/iteration. Fall back to normal object attribute storage + # for dunders; everything else keeps the dict-backed dot-notation + # behavior this class exists for. + if attr.startswith("__") and attr.endswith("__"): + object.__setattr__(self, attr, value) + else: + self[attr] = value + + def __delattr__(self, attr: str) -> None: + if attr.startswith("__") and attr.endswith("__"): + object.__delattr__(self, attr) + else: + del self[attr] def __getstate__(self) -> Mapping[str, T]: return dict(self) @@ -251,6 +270,16 @@ class Constants: :type regexes: tuple or dict :param regexes: :py:attr:`regexes` wrapped with :py:class:`TupleManager`. + + :py:attr:`nickname_delimiters` and :py:attr:`maiden_delimiters` are not + constructor arguments -- they're always set in ``__init__`` (see the + comment there for the string-sentinel-vs-compiled-pattern mechanism) -- + but are documented here since they're the two `Constants` attributes a + caller is most likely to want to look up: per-bucket + :py:class:`TupleManager` collections that :py:meth:`~nameparser.parser.HumanName.parse_nicknames` + consults to route delimited content into ``nickname``/``maiden``. See + the "Adding Custom Nickname Delimiters" and "Routing to Maiden Name" + sections of the customization docs. """ prefixes = _CachedUnionMember() @@ -263,7 +292,8 @@ class Constants: suffix_acronyms_ambiguous: SetManager capitalization_exceptions: TupleManager[str] regexes: RegexTupleManager - extra_nickname_delimiters: TupleManager[re.Pattern[str]] + nickname_delimiters: TupleManager[re.Pattern[str] | str] + maiden_delimiters: TupleManager[re.Pattern[str] | str] _pst: Set[str] | None string_format = "{title} {first} {middle} {last} {suffix} ({nickname})" @@ -451,13 +481,30 @@ def __init__(self, self.suffix_acronyms_ambiguous = SetManager(suffix_acronyms_ambiguous) self.capitalization_exceptions = TupleManager(capitalization_exceptions) self.regexes = RegexTupleManager(regexes) - # Named, appendable group of *additional* delimiter patterns that - # parse_nicknames() iterates after its three built-in delimiters - # (quoted_word/double_quotes/parenthesis, read live from self.regexes - # so overriding those keeps working as before). Empty by default; add - # a pattern here (and re-parse) to recognize a new delimiter without - # needing to override parse_nicknames() itself. See issue #112. - self.extra_nickname_delimiters = TupleManager() + # Per-bucket delimiter collections that parse_nicknames() consults to + # route delimited content into nickname_list / maiden_list. Each value + # is either a compiled re.Pattern (a custom delimiter a caller adds -- + # the old extra_nickname_delimiters use case, see issue #112) or the + # string name of a self.regexes entry to resolve live at parse time. + # The latter is how the three built-ins (quoted_word, double_quotes, + # parenthesis) stay linked to self.regexes, so overriding e.g. + # self.regexes.parenthesis keeps affecting nickname/maiden parsing + # exactly as before. Move a key between the two dicts + # (`maiden_delimiters['parenthesis'] = + # nickname_delimiters.pop('parenthesis')`) to change which bucket it + # routes to without losing that live link. maiden_delimiters starts + # empty -- maiden is off until a caller routes a delimiter to it. + # See issue #22. + # Only seed a built-in name if it's actually present in self.regexes -- + # a caller who overrides regexes with a minimal custom set (dropping + # e.g. "parenthesis" entirely) shouldn't end up with a dangling + # string sentinel that parse_nicknames() would treat as a mistake. + # See parse_nicknames()'s fail-loud check on an unresolvable sentinel. + self.nickname_delimiters = TupleManager[re.Pattern[str] | str]({ + name: name for name in ('quoted_word', 'double_quotes', 'parenthesis') + if name in self.regexes + }) + self.maiden_delimiters = TupleManager[re.Pattern[str] | str]() self.patronymic_name_order = patronymic_name_order self.middle_name_as_last = middle_name_as_last diff --git a/nameparser/parser.py b/nameparser/parser.py index ccc1d83..1f277d5 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -1,5 +1,5 @@ import re -from collections.abc import Iterable, Iterator +from collections.abc import Callable, Iterable, Iterator from operator import itemgetter from itertools import groupby @@ -43,6 +43,7 @@ class HumanName: * :py:attr:`last` * :py:attr:`suffix` * :py:attr:`nickname` + * :py:attr:`maiden` * :py:attr:`surnames` * :py:attr:`given_names` @@ -63,6 +64,7 @@ class HumanName: :param str title: The title or prenominal :param str suffix: The suffix or postnominal :param str nickname: Nicknames + :param str maiden: Maiden name """ C = CONSTANTS @@ -79,7 +81,7 @@ class HumanName: """ _count = 0 - _members = ['title', 'first', 'middle', 'last', 'suffix', 'nickname'] + _members = ['title', 'first', 'middle', 'last', 'suffix', 'nickname', 'maiden'] unparsable = True _full_name = '' @@ -89,6 +91,7 @@ class HumanName: last_list: list[str] suffix_list: list[str] nickname_list: list[str] + maiden_list: list[str] _had_comma: bool def __init__( @@ -107,6 +110,7 @@ def __init__( title: str | list[str] | None = None, suffix: str | list[str] | None = None, nickname: str | list[str] | None = None, + maiden: str | list[str] | None = None, ) -> None: self.C = constants if type(self.C) is not type(CONSTANTS): @@ -119,13 +123,14 @@ def __init__( self.initials_separator = initials_separator if initials_separator is not None else self.C.initials_separator self.suffix_delimiter = suffix_delimiter if suffix_delimiter is not None else self.C.suffix_delimiter self._had_comma = False - if (first or middle or last or title or suffix or nickname): + if (first or middle or last or title or suffix or nickname or maiden): self.first = first self.middle = middle self.last = last self.title = title self.suffix = suffix self.nickname = nickname + self.maiden = maiden self.unparsable = False else: # full_name setter triggers the parse @@ -203,7 +208,7 @@ def __repr__(self) -> str: if self.unparsable: _string = "<%(class)s : [ Unparsable ] >" % {'class': self.__class__.__name__, } else: - _string = "<%(class)s : [\n\ttitle: %(title)r \n\tfirst: %(first)r \n\tmiddle: %(middle)r \n\tlast: %(last)r \n\tsuffix: %(suffix)r\n\tnickname: %(nickname)r\n]>" % { + _string = "<%(class)s : [\n\ttitle: %(title)r \n\tfirst: %(first)r \n\tmiddle: %(middle)r \n\tlast: %(last)r \n\tsuffix: %(suffix)r\n\tnickname: %(nickname)r\n\tmaiden: %(maiden)r\n]>" % { 'class': self.__class__.__name__, 'title': self.title or '', 'first': self.first or '', @@ -211,6 +216,7 @@ def __repr__(self) -> str: 'last': self.last or '', 'suffix': self.suffix or '', 'nickname': self.nickname or '', + 'maiden': self.maiden or '', } return _string @@ -225,7 +231,7 @@ def as_dict(self, include_empty: bool = True) -> dict[str, str]: >>> name = HumanName("Bob Dole") >>> name.as_dict() - {'title': '', 'first': 'Bob', 'middle': '', 'last': 'Dole', 'suffix': '', 'nickname': ''} + {'title': '', 'first': 'Bob', 'middle': '', 'last': 'Dole', 'suffix': '', 'nickname': '', 'maiden': ''} >>> name.as_dict(False) {'first': 'Bob', 'last': 'Dole'} @@ -404,6 +410,20 @@ def nickname(self) -> str: def nickname(self, value: str | list[str] | None) -> None: self._set_list('nickname', value) + @property + def maiden(self) -> str: + """ + The person's maiden (alternate/prior) last name. Empty unless a + delimiter has been routed to it via + :py:attr:`~nameparser.config.Constants.maiden_delimiters` -- see the + "Routing to Maiden Name" section of the customization docs. + """ + return " ".join(self.maiden_list) or self.C.empty_attribute_default + + @maiden.setter + def maiden(self, value: str | list[str] | None) -> None: + self._set_list('maiden', value) + @property def surnames_list(self) -> list[str]: """ @@ -833,75 +853,103 @@ def fix_phd(self) -> None: def parse_nicknames(self) -> None: """ - The content of parenthesis or quotes in the name will be added to the - nicknames list, unless that content is suffix-shaped -- an unambiguous - suffix_not_acronyms/suffix_acronyms member, or content ending in a - period -- in which case it's left in place (undelimited) for normal - downstream suffix/title/word parsing instead. This happens before any - other processing of the name. + Delimited content in the name is routed to either the nickname or + maiden bucket, based on which of + :py:attr:`~nameparser.config.Constants.nickname_delimiters` / + :py:attr:`~nameparser.config.Constants.maiden_delimiters` the matching + delimiter belongs to -- unless that content is suffix-shaped -- an + unambiguous suffix_not_acronyms/suffix_acronyms member, or content + ending in a period -- in which case it's left in place (undelimited) + for normal downstream suffix/title/word parsing instead. This happens + before any other processing of the name. Single quotes cannot span white space characters and must border white space to allow for quotes in names like O'Connor and Kawai'ae'a. Double quotes and parenthesis can span white space. - Loops through the built-in `quoted_word`, `double_quotes` and - `parenthesis` patterns in :py:attr:`~nameparser.config.Constants.regexes`, - followed by any patterns added to - :py:attr:`~nameparser.config.Constants.extra_nickname_delimiters` -- - see the "Adding Custom Nickname Delimiters" section of the - customization docs. - """ - - def handle_match(m: 're.Match[str]') -> str: - # Fall back to the whole match when the regex has no capturing - # group (e.g. a custom override regex without one, like - # EMPTY_REGEX) -- mirrors the old code's use of findall(), which - # returns the whole match for group-less patterns. - content = m.group(1) if m.lastindex else m.group(0) - stripped = lc(content) - # Inlined rather than calling self.is_suffix(content): is_suffix() - # also rejects single-letter initials via is_an_initial(), which - # isn't relevant here, and the suffix_acronyms_ambiguous exclusion - # needs to be interleaved into the acronym branch specifically. - # Acronym suffixes may have periods between every letter (e.g. - # "M.D", "Ph.D") that aren't necessarily trailing, so -- exactly - # like is_suffix() -- strip all periods before checking - # suffix_acronyms/suffix_acronyms_ambiguous membership. Bare - # `stripped` (lc() only strips leading/trailing periods) is still - # used for suffix_not_acronyms, matching is_suffix()'s asymmetry. - acronym_stripped = stripped.replace('.', '') - is_unambiguous_suffix = ( - stripped in self.C.suffix_not_acronyms - or (acronym_stripped in self.C.suffix_acronyms - and acronym_stripped not in self.C.suffix_acronyms_ambiguous) - ) - if is_unambiguous_suffix or content.endswith('.'): - # Leave the bare content -- no delimiters -- so downstream - # word-splitting/suffix-matching sees it exactly as if it had - # never been wrapped in parens/quotes. is_suffix()/lc() only - # strip periods, never parens/quotes, so returning m.group(0) - # here (e.g. literal "(Ret)") would never match - # suffix_not_acronyms ("ret"). - return content - self.nickname_list.append(content) - return '' - - # Same handle_match for every delimiter: suffix-shaped content - # is rare in quotes but not impossible, and the logic is delimiter- - # agnostic, so there's no reason to special-case parenthesis here. - # The three built-ins are read live from self.C.regexes (not copied), - # so overriding e.g. self.C.regexes.parenthesis keeps working as - # before; extra_nickname_delimiters is iterated afterward so callers - # can add new delimiter patterns at runtime without needing to - # override parse_nicknames() itself -- see issue #112. - delimiters = ( - self.C.regexes.quoted_word, - self.C.regexes.double_quotes, - self.C.regexes.parenthesis, - *self.C.extra_nickname_delimiters.values(), - ) - for _re in delimiters: - self._full_name = _re.sub(handle_match, self._full_name) + By default, ``nickname_delimiters`` holds the three built-in + delimiters (``quoted_word``, ``double_quotes`` and ``parenthesis``, + resolved live from :py:attr:`~nameparser.config.Constants.regexes` so + overriding e.g. ``CONSTANTS.regexes.parenthesis`` keeps affecting + nickname parsing) and ``maiden_delimiters`` is empty. Move a key + between the two dicts, e.g. + ``maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')``, + to route it to ``maiden`` instead, or add a new compiled pattern under + any key to recognize an additional delimiter -- see the "Adding + Custom Nickname Delimiters" and "Routing to Maiden Name" sections of + the customization docs. + """ + + def handle_match(target_list: list[str]) -> Callable[['re.Match[str]'], str]: + def _handle(m: 're.Match[str]') -> str: + # Fall back to the whole match when the regex has no capturing + # group (e.g. a custom override regex without one, like + # EMPTY_REGEX) -- mirrors the old code's use of findall(), which + # returns the whole match for group-less patterns. + content = m.group(1) if m.lastindex else m.group(0) + stripped = lc(content) + # Inlined rather than calling self.is_suffix(content): is_suffix() + # also rejects single-letter initials via is_an_initial(), which + # isn't relevant here, and the suffix_acronyms_ambiguous exclusion + # needs to be interleaved into the acronym branch specifically. + # Acronym suffixes may have periods between every letter (e.g. + # "M.D", "Ph.D") that aren't necessarily trailing, so -- exactly + # like is_suffix() -- strip all periods before checking + # suffix_acronyms/suffix_acronyms_ambiguous membership. Bare + # `stripped` (lc() only strips leading/trailing periods) is still + # used for suffix_not_acronyms, matching is_suffix()'s asymmetry. + acronym_stripped = stripped.replace('.', '') + is_unambiguous_suffix = ( + stripped in self.C.suffix_not_acronyms + or (acronym_stripped in self.C.suffix_acronyms + and acronym_stripped not in self.C.suffix_acronyms_ambiguous) + ) + if is_unambiguous_suffix or content.endswith('.'): + # Leave the bare content -- no delimiters -- so downstream + # word-splitting/suffix-matching sees it exactly as if it had + # never been wrapped in parens/quotes. is_suffix()/lc() only + # strip periods, never parens/quotes, so returning m.group(0) + # here (e.g. literal "(Ret)") would never match + # suffix_not_acronyms ("ret"). + return content + target_list.append(content) + return '' + return _handle + + # Same handle_match for every delimiter: suffix-shaped content is rare + # in quotes but not impossible, and the logic is delimiter-agnostic, + # so there's no reason to special-case parenthesis here. A string + # delimiter value names a Constants.regexes entry, resolved via + # getattr() so overriding e.g. self.C.regexes.parenthesis keeps + # working; anything else is already a compiled pattern, added + # directly by a caller. Unlike a caller directly querying + # self.C.regexes (where RegexTupleManager's EMPTY_REGEX default for + # an unknown attribute is harmless -- the caller sees the pattern and + # can react to it), a bad string here is an internal cross-reference + # the delimiter dict itself is responsible for keeping valid. + # EMPTY_REGEX matches the empty string at every position, so + # silently falling back to it would not just skip the delimiter -- + # handle_match() would fire on every zero-width match and append '' + # into the bucket's list repeatedly, producing a truthy + # whitespace-only nickname/maiden while leaving the real delimited + # content (e.g. literal parentheses) unstripped. Fail loudly instead. + for bucket, delimiters in ( + ('nickname', self.C.nickname_delimiters), + ('maiden', self.C.maiden_delimiters), + ): + target_list = getattr(self, bucket + '_list') + _handle_match = handle_match(target_list) + for raw_pattern in delimiters.values(): + if isinstance(raw_pattern, re.Pattern): + _re = raw_pattern + elif raw_pattern in self.C.regexes: + _re = getattr(self.C.regexes, raw_pattern) + else: + raise ValueError( + f"{bucket}_delimiters references unknown regexes key {raw_pattern!r}. " + f"Known regexes keys: {sorted(self.C.regexes)}" + ) + self._full_name = _re.sub(_handle_match, self._full_name) def squash_emoji(self) -> None: """ @@ -943,6 +991,7 @@ def parse_full_name(self) -> None: self.last_list = [] self.suffix_list = [] self.nickname_list = [] + self.maiden_list = [] self.unparsable = True self.pre_process() diff --git a/tests/conftest.py b/tests/conftest.py index c9d5493..1074459 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,7 +35,8 @@ "first_name_prefixes", "capitalization_exceptions", "regexes", - "extra_nickname_delimiters", + "nickname_delimiters", + "maiden_delimiters", ) diff --git a/tests/test_constants.py b/tests/test_constants.py index fe86758..31d10f8 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -61,16 +61,16 @@ def test_can_change_global_constants(self) -> None: # No manual cleanup needed: the autouse fixture in conftest.py snapshots # and restores the global CONSTANTS collections around every test. - def test_can_add_global_extra_nickname_delimiter(self) -> None: + def test_can_add_global_nickname_delimiter(self) -> None: # https://github.com/derek73/python-nameparser/issues/112 hn = HumanName("") - hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') + hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') hn2 = HumanName("Benjamin {Ben} Franklin") self.assertEqual(hn2.has_own_config, False) self.m(hn2.nickname, "Ben", hn2) # No manual cleanup needed: the autouse fixture in conftest.py snapshots # and restores the global CONSTANTS collections (including - # extra_nickname_delimiters) around every test. + # nickname_delimiters) around every test. def test_remove_multiple_arguments(self) -> None: hn = HumanName("Ms Hon Solo", constants=None) @@ -141,7 +141,7 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None: c.titles.add('customtitle') c.prefixes.add('customprefix') c.titles.remove('hon') - c.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') + c.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') # Safe: round-tripping a Constants the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(c)) @@ -155,8 +155,8 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None: # The collections must also keep their manager type, not just contents. self.assertEqual(type(restored.titles), SetManager) self.assertEqual(type(restored.prefixes), SetManager) - self.assertIn('curly_braces', restored.extra_nickname_delimiters) - self.assertEqual(type(restored.extra_nickname_delimiters), TupleManager) + self.assertIn('curly_braces', restored.nickname_delimiters) + self.assertEqual(type(restored.nickname_delimiters), TupleManager) def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None: """An instance-level scalar override must survive a pickle round-trip.""" @@ -200,25 +200,77 @@ def test_regexes_deepcopy_roundtrip(self) -> None: # The EMPTY_REGEX default still applies to genuinely unknown keys. self.assertEqual(dup.does_not_exist, EMPTY_REGEX) - def test_extra_nickname_delimiters_deepcopy_roundtrip(self) -> None: - """copy.deepcopy of extra_nickname_delimiters must round-trip. + def test_nickname_delimiters_deepcopy_roundtrip(self) -> None: + """copy.deepcopy of nickname_delimiters must round-trip. - Mirrors test_regexes_deepcopy_roundtrip: extra_nickname_delimiters is a + Mirrors test_regexes_deepcopy_roundtrip: nickname_delimiters is a plain TupleManager (not RegexTupleManager), but shares the same - __getattr__/__reduce__ machinery, so it's exercised here directly - rather than only incidentally via conftest's autouse snapshot/restore. + __getattr__/__reduce__ machinery. """ c = Constants() - c.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') + c.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') - dup = copy.deepcopy(c.extra_nickname_delimiters) + dup = copy.deepcopy(c.nickname_delimiters) self.assertEqual(type(dup), TupleManager) - self.assertEqual(dict(dup), dict(c.extra_nickname_delimiters)) + self.assertEqual(dict(dup), dict(c.nickname_delimiters)) # Plain TupleManager has no EMPTY_REGEX fallback: unknown keys are None. self.assertIsNone(dup.does_not_exist) self.assertIsNotNone(dup.curly_braces) + def test_maiden_delimiters_deepcopy_roundtrip(self) -> None: + """copy.deepcopy of maiden_delimiters (empty by default) must round-trip.""" + c = Constants() + + dup = copy.deepcopy(c.maiden_delimiters) + + self.assertEqual(type(dup), TupleManager) + self.assertEqual(dict(dup), {}) + self.assertIsNone(dup.does_not_exist) + + def test_nickname_delimiters_default_builtins_resolve_live(self) -> None: + # The three built-ins are stored as the *name* of a regexes entry + # (a plain string), not a copied pattern, so overriding + # self.C.regexes.parenthesis etc. keeps affecting nickname parsing -- + # see test_overriding_builtin_regex_still_affects_nickname_parsing in + # test_nicknames.py. + c = Constants() + self.assertEqual(dict(c.nickname_delimiters), { + 'quoted_word': 'quoted_word', + 'double_quotes': 'double_quotes', + 'parenthesis': 'parenthesis', + }) + self.assertEqual(dict(c.maiden_delimiters), {}) + + def test_extra_nickname_delimiters_removed(self) -> None: + c = Constants() + self.assertFalse(hasattr(c, 'extra_nickname_delimiters')) + + def test_tuplemanager_setattr_delattr_ignore_dunder_names(self) -> None: + """Regression test for the bug that motivated nickname_delimiters' + __setattr__/__delattr__ dunder guard. + + Constructing a subscripted generic, e.g. + TupleManager[re.Pattern[str] | str]({...}), makes typing's + GenericAlias.__call__ set __orig_class__ on the new instance right + after __init__ returns. Before the 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 instance -- exactly what + parse_nicknames() iterates over. This bit nickname_delimiters' + construction (#22) before the guard was added. + """ + tm = TupleManager[re.Pattern[str] | str]({'a': re.compile('x')}) + self.assertNotIn('__orig_class__', tm) + self.assertEqual(dict(tm), {'a': re.compile('x')}) + # Dunder assignment/deletion still work as normal object attributes, + # just routed around dict-backed storage. + tm.__custom_dunder__ = 'probe' # type: ignore[attr-defined] + self.assertEqual(tm.__custom_dunder__, 'probe') # type: ignore[attr-defined] + self.assertNotIn('__custom_dunder__', tm) + del tm.__custom_dunder__ # type: ignore[attr-defined] + self.assertFalse(hasattr(tm, '__custom_dunder__')) + def test_regextuplemanager_ignores_dunder_lookups(self) -> None: """Unknown dunder names report as absent, not as the EMPTY_REGEX default. diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index acf64ca..2f82e9f 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -3,6 +3,7 @@ import pytest from nameparser import HumanName +from nameparser.config import Constants from tests.base import HumanNameTestBase @@ -21,7 +22,7 @@ def test_add_custom_nickname_delimiter(self) -> None: hn = HumanName("Benjamin {Ben} Franklin", constants=None) # curly braces aren't a recognized delimiter by default self.m(hn.nickname, "", hn) - hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') + hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') hn.parse_full_name() self.m(hn.first, "Benjamin", hn) self.m(hn.last, "Franklin", hn) @@ -29,10 +30,10 @@ def test_add_custom_nickname_delimiter(self) -> None: def test_remove_custom_nickname_delimiter(self) -> None: hn = HumanName("Benjamin {Ben} Franklin", constants=None) - hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') + hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') hn.parse_full_name() self.m(hn.nickname, "Ben", hn) - del hn.C.extra_nickname_delimiters['curly_braces'] + del hn.C.nickname_delimiters['curly_braces'] hn.parse_full_name() self.m(hn.nickname, "", hn) @@ -40,8 +41,8 @@ def test_multiple_custom_nickname_delimiters_together(self) -> None: # Two extras registered at once must both be recognized in a single # parse, independent of insertion order. hn = HumanName("Benjamin {Ben} Franklin", constants=None) - hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') - hn.C.extra_nickname_delimiters['angle_brackets'] = re.compile(r'<(.*?)>') + hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') + hn.C.nickname_delimiters['angle_brackets'] = re.compile(r'<(.*?)>') hn.parse_full_name() self.m(hn.first, "Benjamin", hn) self.m(hn.last, "Franklin", hn) @@ -49,8 +50,9 @@ def test_multiple_custom_nickname_delimiters_together(self) -> None: def test_overriding_builtin_regex_still_affects_nickname_parsing(self) -> None: # The pre-existing customization path (overriding self.C.regexes - # directly, documented since before #112) must keep working now that - # parse_nicknames() also consults extra_nickname_delimiters. + # directly) must keep working: nickname_delimiters' three built-in + # entries resolve self.C.regexes. live at parse time rather than + # storing a snapshotted pattern. hn = HumanName("Benjamin [Ben] Franklin", constants=None) self.m(hn.nickname, "", hn) hn.C.regexes['parenthesis'] = re.compile(r'\[(.*?)\]') @@ -187,54 +189,122 @@ def test_ambiguous_suffix_acronym_in_parenthesis_stays_nickname(self) -> None: self.m(hn.nickname, "JD", hn) self.m(hn.suffix, "", hn) - def test_ambiguous_suffix_acronym_in_extra_delimiter_stays_nickname(self) -> None: + def test_ambiguous_suffix_acronym_in_custom_delimiter_stays_nickname(self) -> None: # Same suffix-vs-nickname disambiguation as above, but through a - # custom delimiter added via extra_nickname_delimiters -- confirms + # custom delimiter added via nickname_delimiters -- confirms # handle_match() is applied uniformly regardless of which delimiter # matched, not just the three built-ins. hn = HumanName("JEFFREY {JD} BRICKEN", constants=None) - hn.C.extra_nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') + hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') hn.parse_full_name() self.m(hn.nickname, "JD", hn) self.m(hn.suffix, "", hn) -# class MaidenNameTestCase(HumanNameTestBase): -# -# def test_parenthesis_and_quotes_together(self): -# hn = HumanName("Jennifer 'Jen' Jones (Duff)") -# self.m(hn.first, "Jennifer", hn) -# self.m(hn.last, "Jones", hn) -# self.m(hn.nickname, "Jen", hn) -# self.m(hn.maiden, "Duff", hn) -# -# def test_maiden_name_with_nee(self): -# # https://en.wiktionary.org/wiki/née -# hn = HumanName("Mary Toogood nee Johnson") -# self.m(hn.first, "Mary", hn) -# self.m(hn.last, "Toogood", hn) -# self.m(hn.maiden, "Johnson", hn) -# -# def test_maiden_name_with_accented_nee(self): -# # https://en.wiktionary.org/wiki/née -# hn = HumanName("Mary Toogood née Johnson") -# self.m(hn.first, "Mary", hn) -# self.m(hn.last, "Toogood", hn) -# self.m(hn.maiden, "Johnson", hn) -# -# def test_maiden_name_with_nee_and_comma(self): -# # https://en.wiktionary.org/wiki/née -# hn = HumanName("Mary Toogood, née Johnson") -# self.m(hn.first, "Mary", hn) -# self.m(hn.last, "Toogood", hn) -# self.m(hn.maiden, "Johnson", hn) -# -# def test_maiden_name_with_nee_with_parenthesis(self): -# hn = HumanName("Mary Toogood (nee Johnson)") -# self.m(hn.first, "Mary", hn) -# self.m(hn.last, "Toogood", hn) -# self.m(hn.maiden, "Johnson", hn) -# -# def test_maiden_name_with_parenthesis(self): -# hn = HumanName("Mary Toogood (Johnson)") -# self.m(hn.first, "Mary", hn) +class MaidenNameTestCase(HumanNameTestBase): + def test_maiden_assignment_and_property(self) -> None: + hn = HumanName("Jenny Baker") + hn.maiden = "Johnson" + self.m(hn.maiden, "Johnson", hn) + + def test_maiden_defaults_empty(self) -> None: + hn = HumanName("Jenny Baker") + self.m(hn.maiden, "", hn) + + def test_maiden_key_always_in_as_dict(self) -> None: + hn = HumanName("Bob Dole") + self.assertEqual(hn.as_dict()['maiden'], hn.C.empty_attribute_default) + self.assertNotIn('maiden', hn.as_dict(False)) + + def test_maiden_appears_in_as_dict_when_populated(self) -> None: + hn = HumanName("Jenny Baker") + hn.maiden = "Johnson" + self.assertEqual(hn.as_dict()['maiden'], "Johnson") + self.assertEqual(hn.as_dict(False)['maiden'], "Johnson") + + def test_maiden_appears_in_slice(self) -> None: + hn = HumanName("Jenny Baker") + hn.maiden = "Johnson" + self.assertIn("Johnson", hn[:]) + + def test_maiden_via_constructor_kwarg(self) -> None: + hn = HumanName(first="Jenny", last="Baker", maiden="Johnson") + self.m(hn.first, "Jenny", hn) + self.m(hn.last, "Baker", hn) + self.m(hn.maiden, "Johnson", hn) + self.assertFalse(hn.unparsable) + + def test_maiden_name_in_parenthesis_with_comma(self) -> None: + C = Constants() + C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') + hn = HumanName("Baker (Johnson), Jenny", constants=C) + self.m(hn.first, "Jenny", hn) + self.m(hn.last, "Baker", hn) + self.m(hn.maiden, "Johnson", hn) + + def test_maiden_name_in_parenthesis_no_comma(self) -> None: + C = Constants() + C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') + hn = HumanName("Jenny Baker (Johnson)", constants=C) + self.m(hn.first, "Jenny", hn) + self.m(hn.last, "Baker", hn) + self.m(hn.maiden, "Johnson", hn) + + def test_quotes_still_nickname_when_parens_routed_to_maiden(self) -> None: + C = Constants() + C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') + hn = HumanName('Jenny "JJ" Baker (Johnson)', constants=C) + self.m(hn.first, "Jenny", hn) + self.m(hn.last, "Baker", hn) + self.m(hn.nickname, "JJ", hn) + self.m(hn.maiden, "Johnson", hn) + + def test_maiden_off_by_default_parenthesis_still_routes_to_nickname(self) -> None: + hn = HumanName("Baker (Johnson), Jenny") + self.m(hn.first, "Jenny", hn) + self.m(hn.last, "Baker", hn) + self.m(hn.nickname, "Johnson", hn) + self.m(hn.maiden, "", hn) + + def test_suffix_shaped_content_in_maiden_bucket_stays_in_place(self) -> None: + # The #189 suffix-shaped carve-out in handle_match() applies + # regardless of which bucket the delimiter routes to. + C = Constants() + C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') + hn = HumanName("Baker (Jr.), Jenny", constants=C) + self.m(hn.first, "Jenny", hn) + self.m(hn.last, "Baker", hn) + self.m(hn.suffix, "Jr.", hn) + self.m(hn.maiden, "", hn) + + def test_maiden_appears_in_as_dict_via_routing(self) -> None: + C = Constants() + C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') + hn = HumanName("Baker (Johnson), Jenny", constants=C) + self.assertEqual(hn.as_dict()['maiden'], "Johnson") + + def test_unresolvable_string_sentinel_raises(self) -> None: + # A string value in nickname_delimiters/maiden_delimiters that + # doesn't name a real regexes key used to silently fall back to + # EMPTY_REGEX, which matches at every position and corrupts parsing + # (appends '' into the bucket repeatedly, and leaves the intended + # delimiter's content unstripped elsewhere in the name). It must + # raise instead. + C = Constants() + C.nickname_delimiters['typo'] = 'parenthesus' + with pytest.raises(ValueError): + HumanName("Jenny (Johnson) Baker", constants=C) + + def test_routing_same_delimiter_to_both_buckets_nickname_wins(self) -> None: + # Misuse case: assigning the same key into both dicts instead of + # moving it with pop() (as the docs instruct). nickname_delimiters is + # processed first in parse_nicknames()'s bucket loop, so it consumes + # the match via re.sub() before maiden_delimiters ever sees it -- + # maiden stays empty. Pinning this precedence so it doesn't silently + # change if the bucket processing order is ever reordered. + C = Constants() + C.maiden_delimiters['parenthesis'] = C.regexes['parenthesis'] + hn = HumanName("Baker (Johnson), Jenny", constants=C) + self.m(hn.nickname, "Johnson", hn) + self.m(hn.maiden, "", hn) + diff --git a/tests/test_python_api.py b/tests/test_python_api.py index a8acb76..2dbf504 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -218,8 +218,8 @@ def test_comparison_case_insensitive(self) -> None: def test_slice(self) -> None: hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") self.m(list(hn), ['Dr.', 'John', 'P.', 'Doe-Ray', 'CLU, CFP, LUTC'], hn) - self.m(hn[1:], ['John', 'P.', 'Doe-Ray', 'CLU, CFP, LUTC', hn.C.empty_attribute_default], hn) - self.m(hn[1:-2], ['John', 'P.', 'Doe-Ray'], hn) + self.m(hn[1:], ['John', 'P.', 'Doe-Ray', 'CLU, CFP, LUTC', hn.C.empty_attribute_default, hn.C.empty_attribute_default], hn) + self.m(hn[1:-3], ['John', 'P.', 'Doe-Ray'], hn) def test_getitem(self) -> None: hn = HumanName("Dr. John A. Kenneth Doe, Jr.")