Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Bridge the 1.x particle names through 2.x (#293)
config/prefixes.py returns as a shim -- a new file, no data, nothing of
its predecessor to preserve, which is why the rename went in ahead of it
as its own commit. Its module __getattr__ (PEP 562, the mechanism
nameparser/locales/__init__.py already uses) resolves each 1.x name to
its new constant and warns once naming the replacement path. The bridge
itself lives in config/_deprecated.py, which the remaining vocabulary
renames reuse as they land; the whole layer is deleted in 3.0 with the
rest of the v1 facade.

Its __getattr__ returns Any, PEP 484's convention for a module
__getattr__: mypy honors the assigned one, and returning object would
have typed every deprecated name as unusable for the callers still on
the old path -- an error about object rather than a word about
deprecation.

tests/v2/test_config_aliases.py pins the bridge: each alias resolves to
the identical object, the message names both the old and the new path
plus the removal release, an unknown attribute still raises
AttributeError, and dir() advertises the old names. Two more tests pin
the warning's attribution -- the recorded frame must be the caller's
line, including through a real `from nameparser.config.prefixes import
PREFIXES` -- since a wrong stacklevel is invisible from inside the
warning call and #337 is the scar from that regressing unnoticed. The
alias table is written out literally rather than imported from the shim,
so the assertions describe where the migration guide points instead of
merely proving the shim self-consistent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Loading branch information
derek73 and claude committed Aug 9, 2026
commit 9956e7dae6125f47604f1ca38ec3ad2819afe253
6 changes: 6 additions & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
Release Log
===========
* 2.2.0 - Unreleased

**Deprecations**

- Rename the particle vocabulary to the 2.0 terminology, so the data layer matches the ``Lexicon`` fields it feeds: ``nameparser.config.prefixes`` → :mod:`nameparser.config.particles`, ``PREFIXES`` → ``PARTICLES``, ``NON_FIRST_NAME_PREFIXES`` → ``NON_GIVEN_NAME_PARTICLES``. The old names still resolve and warn once, naming their new path, and go away in 3.0. The ``CONSTANTS`` attribute names 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
78 changes: 78 additions & 0 deletions nameparser/config/_deprecated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""The 1.x vocabulary names, served from their 2.2 homes.

The 2.0 API named its concepts for what they are -- particles, bound
given names, given-name titles, suffix words -- while the data modules
kept the 1.x names a little longer. #293 moves the data layer to match,
one vocabulary at a time: the particle sets have moved, and each
remaining rename reuses this bridge as it lands. A 1.x name resolves to
its 2.2 constant, warns once, and names the path to migrate to. The
whole layer goes away in 3.0 with the rest of the v1 facade.

Same PEP 562 mechanism as nameparser/locales/__init__.py, and the same
write-back for the same reason -- one lookup, then the name is an
ordinary module global.
"""
from __future__ import annotations

import importlib
import sys
import warnings
from collections.abc import Callable, Mapping
from typing import Any

_MESSAGE = (
"{module}.{old} is deprecated since 2.2 and will be removed in 3.0; "
"use {new_module}.{new} instead."
)


def alias_getattr(
module: str,
aliases: Mapping[str, tuple[str, str]],
) -> tuple[Callable[[str], Any], Callable[[], list[str]]]:
"""Build the ``__getattr__``/``__dir__`` pair for a module carrying
deprecated vocabulary names.

``aliases`` maps each old attribute name to the ``(module, name)``
it now lives at. Assign the result at module level::

__getattr__, __dir__ = alias_getattr(__name__, {...})

Typed ``Any`` rather than ``object`` because mypy honors an assigned
module ``__getattr__`` (PEP 484's convention for one): the package
ships ``py.typed``, and a return of ``object`` would type every
deprecated name as unusable for a caller still on the old path --
a type error about ``object`` instead of a word about deprecation.
"""

def __getattr__(name: str) -> Any: # noqa: ANN401
>
if target is None:
raise AttributeError(f"module {module!r} has no attribute {name!r}")
new_module, new_name = target
warnings.warn(
_MESSAGE.format(
module=module, old=name, new_module=new_module, new=new_name),
DeprecationWarning,
# 2: the frame that touched the name, which for
# `from nameparser.config.prefixes import PREFIXES` is the
# importing module -- the place that has to be edited
stacklevel=2,
)
value = getattr(importlib.import_module(new_module), new_name)
# write back, so the name is an ordinary global from here on and
# the warning fires once per name per process rather than once
# per read. A caller who ignores the first warning is not told
# again, which is the point: the message is advice to the
# author, not a runtime signal to the program. Benign race under
# free threading: two threads racing here resolve the same
# constant and assign the same value to the same name, so the
# last write wins and a duplicate warning is the only
# observable difference.
setattr(sys.modules[module], name, value)
return value

def __dir__() -> list[str]:
return sorted(set(vars(sys.modules[module])) | set(aliases))

return __getattr__, __dir__
13 changes: 13 additions & 0 deletions nameparser/config/prefixes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Deprecated alias module: the particle vocabulary moved to
:mod:`nameparser.config.particles` in 2.2 (#293), where the constant
names match the :class:`~nameparser.Lexicon` fields they feed. Reading
a name from here warns and returns the constant from its new home; this
module is deleted in 3.0.
"""
from nameparser.config._deprecated import alias_getattr

__getattr__, __dir__ = alias_getattr(__name__, {
"PREFIXES": ("nameparser.config.particles", "PARTICLES"),
"NON_FIRST_NAME_PREFIXES": (
"nameparser.config.particles", "NON_GIVEN_NAME_PARTICLES"),
})
126 changes: 126 additions & 0 deletions tests/v2/test_config_aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""The 1.x vocabulary names, served from their 2.2 homes (#293).

The alias table here is written out literally rather than imported from
the shim modules. Importing their table would make every assertion
below a tautology -- it would prove the bridge is self-consistent, not
that it points where the migration guide says it does.
"""
from __future__ import annotations

import importlib
import inspect
from collections.abc import Iterator

import pytest

#: (old module, old name, new module, new name), one row per alias.
ALIASES = [
("nameparser.config.prefixes", "PREFIXES",
"nameparser.config.particles", "PARTICLES"),
("nameparser.config.prefixes", "NON_FIRST_NAME_PREFIXES",
"nameparser.config.particles", "NON_GIVEN_NAME_PARTICLES"),
]


def _uncache(module: str, name: str) -> None:
"""Drop a resolved alias from the shim module's globals."""
importlib.import_module(module).__dict__.pop(name, None)


@pytest.fixture(autouse=True)
def _cold_aliases() -> Iterator[None]:
"""Serve every test in this file a cold bridge.

The bridge caches each resolved alias into the shim module's
globals, so the DeprecationWarning fires once per name per process
-- which is the contract, and which makes any test of that warning
order-dependent by construction: whoever touches the name first
consumes the only warning. Clearing before AND after means this
file neither inherits a warmed cache from an earlier test nor
leaves one behind for the rest of the suite.
"""
for module, name, _, _ in ALIASES:
_uncache(module, name)
yield
for module, name, _, _ in ALIASES:
_uncache(module, name)


@pytest.mark.parametrize(
("old_module", "old_name", "new_module", "new_name"),
ALIASES,
ids=[f"{m.rsplit('.', 1)[-1]}.{n}" for m, n, _, _ in ALIASES],
)
def test_old_name_warns_and_resolves_to_the_new_constant(
old_module: str, old_name: str, new_module: str, new_name: str,
) -> None:
expected = getattr(importlib.import_module(new_module), new_name)
with pytest.warns(DeprecationWarning) as record:
value = getattr(importlib.import_module(old_module), old_name)
assert value is expected
message = str(record[0].message)
# both paths: naming only the destination would let the message
# misidentify which name the caller actually has to edit
assert f"{old_module}.{old_name}" in message, message
assert f"{new_module}.{new_name}" in message, message
assert "3.0" in message, message


def test_warning_points_at_the_line_that_read_the_name() -> None:
"""A message nobody can trace back to their own code is advice that
cannot be acted on -- #337's scar is exactly this regressing
unnoticed, since a wrong ``stacklevel`` is invisible from inside the
warning call. Only the recorded frame shows it."""
module = importlib.import_module("nameparser.config.prefixes")
frame = inspect.currentframe()
assert frame is not None
with pytest.warns(DeprecationWarning) as record:
expected_lineno = frame.f_lineno + 1
module.PREFIXES # noqa: B018
assert (record[0].filename, record[0].lineno) == (__file__, expected_lineno)


def test_from_import_is_attributed_to_the_importing_module() -> None:
"""The form the ``stacklevel`` comment singles out, and the one most
callers use. ``from x import Y`` resolves the alias while the
importing module's frame is on top, so the report names the file
holding the import -- the line that has to be edited."""
code = compile("from nameparser.config.prefixes import PREFIXES\n",
"caller_module.py", "exec")
with pytest.warns(DeprecationWarning) as record:
exec(code, {"__name__": "caller_module"})
assert (record[0].filename, record[0].lineno) == ("caller_module.py", 1)


@pytest.mark.parametrize(
("old_module", "old_name"),
[(m, n) for m, n, _, _ in ALIASES],
ids=[f"{m.rsplit('.', 1)[-1]}.{n}" for m, n, _, _ in ALIASES],
)
def test_old_name_warns_once_then_becomes_a_plain_global(
old_module: str, old_name: str,
) -> None:
module = importlib.import_module(old_module)
with pytest.warns(DeprecationWarning):
first = getattr(module, old_name)
# the suite runs under filterwarnings=error, so a second warning
# here would raise rather than merely be recorded
second = getattr(module, old_name)
assert first is second


@pytest.mark.parametrize(
"old_module", sorted({m for m, _, _, _ in ALIASES}))
def test_unknown_attribute_still_raises(old_module: str) -> None:
module = importlib.import_module(old_module)
with pytest.raises(AttributeError, match="NOT_A_CONSTANT"):
module.NOT_A_CONSTANT # noqa: B018


@pytest.mark.parametrize(
("old_module", "old_name"),
[(m, n) for m, n, _, _ in ALIASES],
ids=[f"{m.rsplit('.', 1)[-1]}.{n}" for m, n, _, _ in ALIASES],
)
def test_dir_advertises_the_old_names(old_module: str, old_name: str) -> None:
assert old_name in dir(importlib.import_module(old_module))