Skip to content

Commit 1dc1c54

Browse files
derek73claude
andcommitted
Keep missing-attribute checking on the modules that survive (#293)
alias_getattr returns a __getattr__ typed -> Any, and mypy honors a module __getattr__ assigned by tuple-unpacking. Assigning it plainly therefore tells mypy that these modules answer EVERY attribute, which switches off missing-attribute checking for the whole file. On prefixes.py and bound_first_names.py that costs little -- they hold no live constants. On titles.py and suffixes.py, which callers still import from, it was a real loss. Same probe, master vs branch: from nameparser.config.suffixes import SUFFIX_ACRONYM # typo from nameparser.config.titles import TITLE # typo master (18b0e49): 2 errors, with spelling suggestions before this commit: Success: no issues found The same bogus name against nameparser.config.particles, which has no __getattr__, errored on both, so the probe discriminates. nameparser ships py.typed, so this reached downstream callers. Hiding the assignment in the else of an `if TYPE_CHECKING` guard that declares the retired names restores it. All four alias-bearing modules get the split, not just the two with live constants: the retired names are still a supported import path, and typing one Any hands a caller who is on that path an unchecked value -- silently, in THEIR code. After the split every typo in the probe errors, including PREFIX and BOUND_FIRST_NAME on the two shims, and reveal_type on all five retired names is frozenset[str] where it was Any. Runtime is untouched, and measured so: each retired name still resolves is-identical to its 2.2 constant, still warns, is still in dir(), and star imports still bind exactly the live and retired names. The `# noqa: F822` on the four __all__ lists is now dead -- the declarations bind the names for ruff too. Removing it is not just tidying: with the suppression gone, deleting the TYPE_CHECKING branch in 3.0 without deleting __all__ becomes an error instead of nothing. Verified by `ruff check --ignore-noqa`, which reported all five F822s before the split and none after; the ANN401 and E402 suppressions still report there and stay. Two follow-ons the suite found rather than I did. The retired-name scan rejected the docstring example, which had spelled a real retired name in the one file that serves no vocabulary -- the same trap the stacklevel comment already documents -- so the example uses a placeholder. And the star-import test derived "live constants" from the name shape alone, which now also matches the imported TYPE_CHECKING; it tests the value type as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 35d43da commit 1dc1c54

6 files changed

Lines changed: 97 additions & 29 deletions

File tree

‎nameparser/config/_deprecated.py‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,35 @@ def alias_getattr(
5555
deprecated vocabulary names.
5656
5757
``aliases`` maps each old attribute name to the ``(module, name)``
58-
it now lives at. Assign the result at module level::
58+
it now lives at. Assign the result at module level, in the ``else``
59+
of a ``TYPE_CHECKING`` guard that declares the same names::
5960
60-
__getattr__, __dir__ = alias_getattr(__name__, {...})
61+
if TYPE_CHECKING:
62+
OLD_NAME: frozenset[str] # 1.x alias, removed in 3.0 (#293)
63+
else:
64+
__getattr__, __dir__ = alias_getattr(__name__, {...})
6165
62-
Typed ``Any`` rather than ``object`` because mypy honors an assigned
63-
module ``__getattr__`` (PEP 484's convention for one): the package
64-
ships ``py.typed``, and a return of ``object`` would type every
65-
deprecated name as unusable for a caller still on the old path --
66-
a type error about ``object`` instead of a word about deprecation.
66+
(a placeholder rather than a real retired name, for the reason the
67+
``stacklevel`` comment below gives)
68+
69+
The guard is load-bearing. mypy honors an assigned module
70+
``__getattr__`` (PEP 484's convention for one) and thereafter
71+
answers EVERY missing attribute of that module from its return
72+
type, so a bare assignment turns off missing-attribute checking for
73+
the whole module. On titles.py and suffixes.py, which keep their
74+
live constants and are still imported from, that cost real
75+
checking: ``from nameparser.config.titles import TITLE`` type-
76+
checked clean. Keeping the assignment out of the type checker's
77+
view restores it, and the declarations in the other branch type
78+
each retired name as the ``frozenset[str]`` it is rather than
79+
``Any``. The package ships ``py.typed``, so both reach callers.
80+
Runtime is untouched -- ``TYPE_CHECKING`` is False, so only the
81+
``else`` ever runs -- and the two branches delete together in 3.0.
82+
83+
Which leaves the ``Any`` return below typing nothing outside this
84+
module: mypy reads no module ``__getattr__`` for the alias-bearing
85+
modules any more, and does not analyze the ``else`` branch it is
86+
assigned in. It stays ``Any`` as what ``getattr`` itself returns.
6787
"""
6888

6989
def __getattr__(name: str) -> Any: # noqa: ANN401

‎nameparser/config/bound_first_names.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44
Reading a name from here warns and returns the constant from its new
55
home; this module is deleted in 3.0.
66
"""
7+
from typing import TYPE_CHECKING
8+
79
from nameparser.config._deprecated import alias_getattr
810

9-
__getattr__, __dir__ = alias_getattr(__name__, {
10-
"BOUND_FIRST_NAMES": (
11-
"nameparser.config.bound_given_names", "BOUND_GIVEN_NAMES"),
12-
})
11+
# Declared for the type checker, served by __getattr__ at runtime --
12+
# see the note in prefixes.py and alias_getattr's docstring.
13+
if TYPE_CHECKING:
14+
BOUND_FIRST_NAMES: frozenset[str]
15+
else:
16+
__getattr__, __dir__ = alias_getattr(__name__, {
17+
"BOUND_FIRST_NAMES": (
18+
"nameparser.config.bound_given_names", "BOUND_GIVEN_NAMES"),
19+
})
1320

1421
# Star imports read __all__ and never the module __getattr__ -- see the
1522
# note in prefixes.py for what that cost before this line existed.
16-
__all__ = ["BOUND_FIRST_NAMES"] # noqa: F822
23+
__all__ = ["BOUND_FIRST_NAMES"]

‎nameparser/config/prefixes.py‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,22 @@
44
a name from here warns and returns the constant from its new home; this
55
module is deleted in 3.0.
66
"""
7+
from typing import TYPE_CHECKING
8+
79
from nameparser.config._deprecated import alias_getattr
810

9-
__getattr__, __dir__ = alias_getattr(__name__, {
10-
"PREFIXES": ("nameparser.config.particles", "PARTICLES"),
11-
"NON_FIRST_NAME_PREFIXES": (
12-
"nameparser.config.particles", "NON_GIVEN_NAME_PARTICLES"),
13-
})
11+
# Declared for the type checker, served by __getattr__ at runtime; see
12+
# alias_getattr's docstring for why the assignment has to be hidden
13+
# from mypy. Both branches go in 3.0.
14+
if TYPE_CHECKING:
15+
PREFIXES: frozenset[str]
16+
NON_FIRST_NAME_PREFIXES: frozenset[str]
17+
else:
18+
__getattr__, __dir__ = alias_getattr(__name__, {
19+
"PREFIXES": ("nameparser.config.particles", "PARTICLES"),
20+
"NON_FIRST_NAME_PREFIXES": (
21+
"nameparser.config.particles", "NON_GIVEN_NAME_PARTICLES"),
22+
})
1423

1524
# `from nameparser.config.prefixes import *` consults __all__ and NOTHING
1625
# else -- a module __getattr__ is invisible to it (PEP 562 defines the
@@ -21,4 +30,9 @@
2130
# line -- plus `alias_getattr` bound into the caller's namespace. Listing
2231
# the retired names here routes each through __getattr__, so a star
2332
# import warns per name exactly as an attribute read does.
24-
__all__ = ["NON_FIRST_NAME_PREFIXES", "PREFIXES"] # noqa: F822
33+
#
34+
# No F822 suppression: the retired names are declared above, in the
35+
# TYPE_CHECKING branch, so ruff sees them bound. Deleting that branch
36+
# in 3.0 without deleting this list is then an error rather than a
37+
# silently-suppressed one.
38+
__all__ = ["NON_FIRST_NAME_PREFIXES", "PREFIXES"]

‎nameparser/config/suffixes.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -873,11 +873,20 @@
873873
# own globals: a module __getattr__ runs only once the body has finished
874874
# and the module is in sys.modules, so the lookup resolves rather than
875875
# recursing.
876+
from typing import TYPE_CHECKING # noqa: E402
877+
876878
from nameparser.config._deprecated import alias_getattr # noqa: E402
877879

878-
__getattr__, __dir__ = alias_getattr(__name__, {
879-
"SUFFIX_NOT_ACRONYMS": ("nameparser.config.suffixes", "SUFFIX_WORDS"),
880-
})
880+
# Declared for the type checker, served by __getattr__ at runtime --
881+
# the split is what keeps mypy checking this module's LIVE names; see
882+
# the note in titles.py and alias_getattr's docstring.
883+
if TYPE_CHECKING:
884+
SUFFIX_NOT_ACRONYMS: frozenset[str]
885+
else:
886+
__getattr__, __dir__ = alias_getattr(__name__, {
887+
"SUFFIX_NOT_ACRONYMS": (
888+
"nameparser.config.suffixes", "SUFFIX_WORDS"),
889+
})
881890

882891
# Star imports read __all__ and never the module __getattr__ -- see the
883892
# note in prefixes.py. Live constants listed alongside the retired name
@@ -889,7 +898,7 @@
889898
# name goes last because autodoc does not document it (it is not a
890899
# module global, so autodoc's getattr-free member scan never sees it)
891900
# and it therefore has no position to preserve.
892-
__all__ = [ # noqa: F822
901+
__all__ = [
893902
"SUFFIX_WORDS",
894903
"GLUED_HONORIFICS",
895904
"SUFFIX_ACRONYMS_AMBIGUOUS",

‎nameparser/config/titles.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -801,16 +801,27 @@
801801
# aliases a name to one of this module's own globals: a module
802802
# __getattr__ runs only once the body has finished and the module is in
803803
# sys.modules, so the lookup resolves rather than recursing.
804+
from typing import TYPE_CHECKING # noqa: E402
805+
804806
from nameparser.config._deprecated import alias_getattr # noqa: E402
805807

806-
__getattr__, __dir__ = alias_getattr(__name__, {
807-
"FIRST_NAME_TITLES": ("nameparser.config.titles", "GIVEN_NAME_TITLES"),
808-
})
808+
# Declared for the type checker, served by __getattr__ at runtime. The
809+
# split is what keeps mypy checking this module's LIVE names: an
810+
# assigned module __getattr__ answers every missing attribute, so a
811+
# plain assignment here made `from ... import TITLE` type-check clean.
812+
# See alias_getattr's docstring. Both branches go in 3.0.
813+
if TYPE_CHECKING:
814+
FIRST_NAME_TITLES: frozenset[str]
815+
else:
816+
__getattr__, __dir__ = alias_getattr(__name__, {
817+
"FIRST_NAME_TITLES": (
818+
"nameparser.config.titles", "GIVEN_NAME_TITLES"),
819+
})
809820

810821
# Star imports read __all__ and never the module __getattr__ -- see the
811822
# note in prefixes.py. This module keeps its live constants, so they are
812823
# listed too: without __all__ a star import bound them and dropped the
813824
# retired name silently; with a PARTIAL __all__ it would bind the
814825
# retired name and drop the live ones instead.
815826
# Source order, not alphabetical -- see the note in suffixes.py.
816-
__all__ = ["GIVEN_NAME_TITLES", "TITLES", "FIRST_NAME_TITLES"] # noqa: F822
827+
__all__ = ["GIVEN_NAME_TITLES", "TITLES", "FIRST_NAME_TITLES"]

‎tests/v2/test_config_aliases.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,16 @@ def test_star_import_binds_exactly_the_live_and_retired_names(
157157
"""
158158
module = importlib.import_module(old_module)
159159
retired = {n for m, n, _, _ in ALIASES if m == old_module}
160-
# a retired name is served by __getattr__ and never written into the
161-
# module, so vars() holds the live constants and nothing else
162-
live = {n for n in vars(module) if n.isupper() and not n.startswith("_")}
160+
# A retired name is served by __getattr__ and never written into the
161+
# module, so vars() holds the live constants -- plus whatever else
162+
# the file imported. The type test is what separates the two:
163+
# `TYPE_CHECKING`, imported to hide the __getattr__ assignment from
164+
# mypy, has the name shape of a constant and is not vocabulary.
165+
# Every live constant in these four modules is a frozenset (the 2.2
166+
# freeze), so a new one still has to reach __all__ or fail here.
167+
live = {n for n, v in vars(module).items()
168+
if n.isupper() and not n.startswith("_")
169+
and isinstance(v, frozenset)}
163170

164171
namespace: dict[str, object] = {}
165172
with pytest.warns(DeprecationWarning) as record:

0 commit comments

Comments
 (0)