Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions AGENTS.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions docs/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ accepts a plain set of lowercase words, keyword by field name (``titles``
above; ``particles``, ``suffix_words``, and the rest work the same
way) — see :doc:`modules` for the full field list.

The default word lists themselves — ``TITLES``, ``PARTICLES`` and the
rest of ``nameparser.config`` — are frozen, so a runtime addition
belongs on a :class:`~nameparser.Lexicon` as above, or on a private
``Constants`` if you are still parsing through ``HumanName``. Those
constants were renamed in 2.2 to match the field names used here; the
1.x names still import, with a ``DeprecationWarning``, until 3.0 — see
:doc:`migrate` for the mapping.

Vocabulary entries are matched one word at a time (``given_name_titles``
excepted), so a multi-word entry like ``titles={"grand moff"}`` can
never match; the constructor warns when it sees one
Expand Down
136 changes: 134 additions & 2 deletions docs/migrate.rst
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,134 @@ fields:
- Pair-valued; set it via ``dataclasses.replace(lexicon,
capitalization_exceptions={...})``, not ``add()``/``remove()``

And behavior/render scalars map onto :class:`~nameparser.Policy` (or a
The vocabulary that feeds both columns lives in ``nameparser.config``,
and in 2.2 its module and constant names moved to the vocabulary the
``Lexicon`` column speaks — particles, bound given names, given-name
titles, suffix words. Terminology only; one of the four kept 1.x's
*meaning* while its ``Lexicon`` counterpart marks the opposite set, so
read the caveat under the table before pairing them up. If you import
the default word lists directly — to read one, extend one, or copy one
into your own configuration — four vocabularies moved:

.. list-table::
:header-rows: 1
:widths: 50 50

* - 1.x name
- 2.2 name
* - ``nameparser.config.prefixes``
- :mod:`nameparser.config.particles`
* - ``prefixes.PREFIXES``
- ``particles.PARTICLES``
* - ``prefixes.NON_FIRST_NAME_PREFIXES``
- ``particles.NON_GIVEN_NAME_PARTICLES``
* - ``nameparser.config.bound_first_names``
- :mod:`nameparser.config.bound_given_names`
* - ``bound_first_names.BOUND_FIRST_NAMES``
- ``bound_given_names.BOUND_GIVEN_NAMES``
* - ``titles.FIRST_NAME_TITLES``
- ``titles.GIVEN_NAME_TITLES``
* - ``suffixes.SUFFIX_NOT_ACRONYMS``
- ``suffixes.SUFFIX_WORDS``

The caveat is on the third row. ``NON_GIVEN_NAME_PARTICLES`` is
``NON_FIRST_NAME_PREFIXES`` renamed and nothing else — same members,
same *never a given name* meaning. It is **not** the constant behind
``Lexicon.particles_ambiguous``, which is that field's complement, even
though the two now sound as though they belong together. Pairing this
table's third row with the field-mapping table above and concluding
that ``NON_GIVEN_NAME_PARTICLES`` is what ``particles_ambiguous``
holds is exactly the inversion the flip warning below exists to
prevent.

Every row still resolves, and the old names are removed in 3.0. The two
module rows are import paths and nothing more: importing
``nameparser.config.prefixes`` or ``nameparser.config.bound_first_names``
still works and says nothing, because the modules are now empty shims.
It is reading a *constant* that reports — by attribute access, by
``from ... import``, and by ``from ... import *`` alike. The read emits
a ``DeprecationWarning`` naming the module and constant to move to,
then returns the constant from its new home.

The warning fires once per line that reads a retired name, not once per
process, so a repeated read of the same import stays quiet while a
second import somewhere else in your code reports for itself. To find
your own uses, raise ``DeprecationWarning`` — which Python hides by
default outside ``__main__``, so an untouched run of a library that
reads these names on import shows nothing::

python -W error::DeprecationWarning -c "import yourapp"

That stops at the first one, with a traceback whose last frame outside
nameparser is the line to edit. Swap ``error`` for ``default`` to print
them all and keep going.

Only the data layer moved: the ``CONSTANTS`` attribute names in the
field-mapping table above are v1 facade surface and are unaffected,
so ``constants.prefixes``,
``constants.non_first_name_prefixes``, ``constants.bound_first_names``,
``constants.first_name_titles`` and ``constants.suffix_not_acronyms``
keep their 1.x spelling for as long as the facade exists.

Every vocabulary *set* in ``nameparser.config`` is also a ``frozenset``
as of 2.2 — the renamed ones and the rest. Every set, that is; the one
mapping constant is untouched, and there is a note on it below. The
freeze retires one 1.x idiom outright: ``TITLES.add("dean")`` — editing
a default word list in place — now raises ``AttributeError`` at the
line that writes it, rather than changing some parses and not others
some distance away.

It was never a dependable way to change a default, because the two
config layers read the module constants at different moments.
``Lexicon.default()`` is cached and reads them exactly once, at its
first call; a v1 ``Constants`` copies them at every construction; and
the shared ``CONSTANTS`` singleton is one such copy, taken at import.
An edit landing *after* the first parse therefore reached only a
freshly built ``Constants`` — neither ``parse()``, whose lexicon was
already built, nor the shared ``CONSTANTS``, which predated the edit.
An edit landing *before* any parse reached ``Lexicon.default()``, and
so ``parse()``, and a fresh ``Constants`` — but still never the shared
``CONSTANTS``. Whether an edit reached a given parse thus depended on
which config objects the program had already built, and one program
could hold two disagreeing defaults with nothing to say so.

``CAPITALIZATION_EXCEPTIONS`` is the constant the freeze left out. It
is a mapping rather than a set, and it is still a plain mutable
``dict`` — ``CAPITALIZATION_EXCEPTIONS["phd"] = "PhD"`` runs on 2.2 and
raises nothing. Everything just said about split defaults still applies
to it, unchanged and measured on 2.2: an edit after the first parse
reaches a freshly built ``Constants``, and neither
``Lexicon.default()`` nor the shared ``CONSTANTS``. The advice below is
the same advice — configure the object, with
``constants.capitalization_exceptions["phd"] = "PhD"`` on a private
``Constants``, or ``dataclasses.replace(lexicon,
capitalization_exceptions={...})`` for the 2.0 API.

Configure the objects instead, which both APIs have always supported
and neither the freeze nor the rename affects. For ``HumanName``, build
a private ``Constants`` and pass it::

from nameparser import HumanName
from nameparser.config import Constants

constants = Constants()
constants.titles.add("dean")
name = HumanName("Dean Smith", constants=constants)

For the 2.0 API, extend the default lexicon and hand it to a parser::

from nameparser import Lexicon, Parser

parser = Parser(lexicon=Lexicon.default().add(titles={"dean"}))
name = parser.parse("Dean Smith")

Mutating the shared ``CONSTANTS`` singleton still works and still
reaches every ``HumanName`` that reads it, but it warns: it is
deprecated along with the rest of the v1 facade and goes away in 3.0.
Prefer a private ``Constants`` in new code. See :doc:`customize` for
the full set of knobs on each.

Behavior and render scalars map onto :class:`~nameparser.Policy` (or a
rendering argument, where the 2.0 equivalent isn't config at all):

.. list-table::
Expand Down Expand Up @@ -279,7 +406,12 @@ handing the parser a regex.
**complementary** sets, not the same set under a new name.
``non_first_name_prefixes`` lists particles that are *never* read as
a given name; ``particles_ambiguous`` lists the particles that
*may* be read as one. Translating a customization means flipping
*may* be read as one. The same holds for the config constant behind
it: ``particles.NON_GIVEN_NAME_PARTICLES`` (1.x
``prefixes.NON_FIRST_NAME_PREFIXES``) marks the never-given set, so
it is the complement of ``particles_ambiguous`` too, however much
the 2.2 names now suggest otherwise. Translating a customization
means flipping
the set: ``particles_ambiguous = lexicon.particles -
constants.non_first_name_prefixes``. Copying
``non_first_name_prefixes`` straight into ``particles_ambiguous``
Expand Down
4 changes: 2 additions & 2 deletions docs/modules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,9 @@ HumanName.config Defaults
:members:
.. automodule:: nameparser.config.suffixes
:members:
.. automodule:: nameparser.config.prefixes
.. automodule:: nameparser.config.particles
:members:
.. automodule:: nameparser.config.bound_first_names
.. automodule:: nameparser.config.bound_given_names
:members:
.. automodule:: nameparser.config.conjunctions
:members:
Expand Down
47 changes: 47 additions & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
Release Log
===========
* 2.2.0 - Unreleased

nameparser 2.2 finishes the 2.0 rename at the layer it never
reached. The word lists in ``nameparser.config`` were still named
for v1's fields — prefixes, first names — while the
``Lexicon`` they feed has spoken of particles and given names since
2.0. They now agree. The lists are also frozen, which retires
editing one in place as a way to change a default and replaces it
with configuring a ``Lexicon`` or a private ``Constants``.

Nothing moved between vocabularies and no parse changes: over the
751 names of the differential corpora, every one of the seven
fields is identical to 2.1 through both the 2.0 and the 1.x API.
What breaks is code that *writes* to a default word list, and code
that imports one by its 1.x name has until 3.0.

**Breaking Changes**

- Change every vocabulary set in ``nameparser.config`` to a ``frozenset``: ``TITLES``, ``GIVEN_NAME_TITLES``, ``SUFFIX_WORDS``, ``SUFFIX_ACRONYMS``, ``SUFFIX_ACRONYMS_AMBIGUOUS``, ``GLUED_HONORIFICS``, ``PARTICLES``, ``NON_GIVEN_NAME_PARTICLES``, ``BOUND_GIVEN_NAMES``, ``CONJUNCTIONS`` and ``MAIDEN_MARKERS`` (``KOREAN_SURNAMES`` already was one). Editing one in place -- ``TITLES.add("dean")``, the old way of changing a global default -- now raises ``AttributeError: 'frozenset' object has no attribute 'add'`` at the line that writes it. It was never a reliable way to change a default: whether an edit reached a given parse depended on which config objects had already been built, so one program could hold two disagreeing defaults with nothing to say so. To change the defaults for ``HumanName``, build a private ``Constants`` and pass it (``c = Constants(); c.titles.add("dean"); HumanName(name, constants=c)``); mutating the shared ``CONSTANTS`` still works, but warns and goes away in 3.0. For the 2.0 API, build a lexicon and pass it to a parser (``Parser(lexicon=Lexicon.default().add(titles={"dean"}))``). Neither is affected by this change. ``CAPITALIZATION_EXCEPTIONS`` is a mapping, not a set, and is unchanged. See :doc:`migrate` and :doc:`customize` (#293)

**Deprecations**

- Rename the four vocabularies whose 1.x names described the fields they feed in v1's words, so the data layer matches the ``Lexicon``:

.. list-table::
:header-rows: 1
:widths: 50 50

* - 1.x name
- 2.2 name
* - ``nameparser.config.prefixes``
- :mod:`nameparser.config.particles`
* - ``prefixes.PREFIXES``
- ``particles.PARTICLES``
* - ``prefixes.NON_FIRST_NAME_PREFIXES``
- ``particles.NON_GIVEN_NAME_PARTICLES``
* - ``nameparser.config.bound_first_names``
- :mod:`nameparser.config.bound_given_names`
* - ``bound_first_names.BOUND_FIRST_NAMES``
- ``bound_given_names.BOUND_GIVEN_NAMES``
* - ``titles.FIRST_NAME_TITLES``
- ``titles.GIVEN_NAME_TITLES``
* - ``suffixes.SUFFIX_NOT_ACRONYMS``
- ``suffixes.SUFFIX_WORDS``

Every row above still resolves and is removed in 3.0. The two module rows are import paths: importing them still works and says nothing, since both modules are now empty shims. Reading a *constant* -- by attribute access, by ``from ... import``, or by ``from ... import *`` -- emits a ``DeprecationWarning`` naming the module and constant to move to, once per line that reads it rather than once per process, so every place you have to edit is reported rather than only whichever one ran first. ``python -W error::DeprecationWarning -c "import yourapp"`` surfaces them; Python hides ``DeprecationWarning`` outside ``__main__``. Two of the four kept their module, so only the constant moved there. ``SUFFIX_NOT_ACRONYMS`` was also inaccurate as well as dated — ``esq`` is in ``SUFFIX_ACRONYMS`` too. The ``CONSTANTS`` attribute names (``prefixes``, ``non_first_name_prefixes``, ``bound_first_names``, ``first_name_titles``, ``suffix_not_acronyms``) are v1 facade surface and are unchanged. See :doc:`migrate` (#293)

* 2.1.0 - August 7, 2026

nameparser 2.1 makes East Asian names work without configuration.
Expand Down
4 changes: 2 additions & 2 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ name to its full vocabulary set:
- adjacent suffixes
- ``suffix``
- ``John Smith PhD MD`` → ``PhD, MD``
* - :mod:`Bound given names <nameparser.config.bound_first_names>`
* - :mod:`Bound given names <nameparser.config.bound_given_names>`
- the following word
- ``given``
- ``abdul salam ahmed`` → ``abdul salam``
* - :mod:`Particles <nameparser.config.prefixes>`
* - :mod:`Particles <nameparser.config.particles>`
- the following surname
- ``family``
- ``Juan de la Vega`` → ``de la Vega``
Expand Down
46 changes: 21 additions & 25 deletions nameparser/_config_shim.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,28 +664,28 @@ def _raise_readonly(name: str) -> None:
)


def _default_vocab() -> dict[str, set[str]]:
def _default_vocab() -> dict[str, frozenset[str]]:
# v1 data modules stay the single vocabulary source through 2.x
# (same rule as Lexicon.default()).
from nameparser.config.bound_first_names import BOUND_FIRST_NAMES
from nameparser.config.bound_given_names import BOUND_GIVEN_NAMES
from nameparser.config.conjunctions import CONJUNCTIONS
from nameparser.config.prefixes import (
NON_FIRST_NAME_PREFIXES, PREFIXES,
from nameparser.config.particles import (
NON_GIVEN_NAME_PARTICLES, PARTICLES,
)
from nameparser.config.suffixes import (
SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_NOT_ACRONYMS,
SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_WORDS,
)
from nameparser.config.titles import FIRST_NAME_TITLES, TITLES
from nameparser.config.titles import GIVEN_NAME_TITLES, TITLES
return {
"prefixes": PREFIXES,
"prefixes": PARTICLES,
"suffix_acronyms": SUFFIX_ACRONYMS,
"suffix_not_acronyms": SUFFIX_NOT_ACRONYMS,
"suffix_not_acronyms": SUFFIX_WORDS,
"suffix_acronyms_ambiguous": SUFFIX_ACRONYMS_AMBIGUOUS,
"titles": TITLES,
"first_name_titles": FIRST_NAME_TITLES,
"first_name_titles": GIVEN_NAME_TITLES,
"conjunctions": CONJUNCTIONS,
"bound_first_names": BOUND_FIRST_NAMES,
"non_first_name_prefixes": NON_FIRST_NAME_PREFIXES,
"bound_first_names": BOUND_GIVEN_NAMES,
"non_first_name_prefixes": NON_GIVEN_NAME_PARTICLES,
}


Expand Down Expand Up @@ -1038,9 +1038,9 @@ def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:
particles=particles,
# complement translation: v1 marks the never-given subset;
# v2 marks the may-be-given subset. The trailing union keeps
# a config v1 accepted: prefixes.py asserts its own data has
# no word in both non_first_name_prefixes and
# bound_first_names, but nothing stops a caller adding one at
# a config v1 accepted: particles.py asserts its own data has
# no word in both NON_GIVEN_NAME_PARTICLES and
# BOUND_GIVEN_NAMES, but nothing stops a caller adding one at
# runtime, and v1 then lets the bound rule win (leading "dos
# Santos Silva" parses first="dos Santos"). Treating such a
# word as may-be-given reproduces that rather than raising.
Expand All @@ -1062,23 +1062,19 @@ def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:
bound_given_names=bound,
# v1 Constants has no manager for these (#274 is 2.0
# behavior); the data module is the only source
maiden_markers=frozenset(MAIDEN_MARKERS),
maiden_markers=MAIDEN_MARKERS,
# likewise no v1 manager: the unspaced-name segmentation
# vocabulary is 2.0 behavior (#271), so it rides in the
# snapshot only -- v1's Constants surface stays frozen.
# Unwrapped where maiden_markers above is wrapped: this
# module is born frozen (#293), so no wrap
surnames=KOREAN_SURNAMES,
# likewise no v1 manager: the glued-honorific tail set is
# 2.1 behavior (#308), so it rides in the snapshot only.
# Wrapped, unlike surnames above: suffixes.py is still a
# mutable v1 module, not born-frozen like surnames.py
# (#293). Intersect with the word set: Lexicon enforces
# tails <= suffix_words, and v1 semantics are that deleting
# a suffix word turns the behavior off -- a lingering tail
# simply stops mattering, the same rule ambiguous_acronyms
# gets against suffix_acronyms above.
honorific_tails=frozenset(GLUED_HONORIFICS) & suffix_words,
# Intersect with the word set: Lexicon enforces tails <=
# suffix_words, and v1 semantics are that deleting a suffix
# word turns the behavior off -- a lingering tail simply
# stops mattering, the same rule ambiguous_acronyms gets
# against suffix_acronyms above.
honorific_tails=GLUED_HONORIFICS & suffix_words,
# TupleManager is dict[str, object] (v1 parity: values were
# never statically str-typed); every real entry is a str,
# same assumption _DelimiterManager's sentinel lookup makes
Expand Down
2 changes: 1 addition & 1 deletion nameparser/_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ def _split_last(self) -> tuple[list[str], list[str]]:
# v1 parser.py _split_last, verbatim: vocabulary lookup at ACCESS
# time (so assigned last names split too), with the all-particle
# guard (a family name is assumed not to consist entirely of
# particles, e.g. surname "Do" which also appears in PREFIXES)
# particles, e.g. surname "Do" which also appears in PARTICLES)
words = " ".join(self.last_list).split()
i = 0
while i < len(words) and self._is_particle(words[i]):
Expand Down
Loading