Skip to content

Commit 02767a9

Browse files
committed
Synthesize __getattr__ on untyped parents of typed subpackages
Fixes #16149. When mypy follows a third-party `import pkg.typed` and `pkg` itself has no `py.typed`, `pkg` is loaded as an empty namespace package -- its `__init__.py` is never read. Subsequent `pkg.name` access from any other file then raises `attr-defined`, even when `__init__.py` re-exports `name` at runtime. The motivating case: numba ships `numba/typed/py.typed` but not `numba/py.typed`. Any source that does `import numba.typed` makes every `@numba.jit(...)` site elsewhere fail with Module has no attribute "jit" The existing workarounds (`follow_imports = "skip"` or per-module `follow_untyped_imports = True`) both require the user to know the shape of every dependency they pull in. Call chain for an example repro of the bug: 1. `foo.py` does `import numba.typed`. 2. `FindModuleCache._find_module("numba.typed")` calls `_find_module_non_stub_helper`, which walks the components and finds `numba/typed/py.typed` at iteration 1 -- so the helper returns the parent path `(pkg_dir/numba, False)` (the typed sub is reachable). 3. Back in `_find_module`, line 496 calls `_update_ns_ancestors(["numba", "typed"], (pkg_dir/numba, False))`. The loop's first iteration sets `ns_ancestors["numba"] = pkg_dir/numba` -- even though `numba/__init__.py` exists, i.e. `numba` is a regular package, not a namespace package. 4. Loading `numba.typed` requires its parent `numba` as an ancestor (`State.add_ancestors`). mypy calls `find_module("numba")`. 5. `_find_module_non_stub_helper("numba", pkg_dir)` returns `FOUND_WITHOUT_TYPE_HINTS` (no `numba/py.typed`). That should be the final answer -- but at line 595 the fallback `ancestor = self.ns_ancestors.get("numba")` hits the entry written in step 3 and returns the directory path instead. `numba` is now "found" with a real path. 6. mypy parses `numba/__init__.py` (silenced, because it was found by following imports into site-packages). The line `from numba.core import jit` triggers `find_module("numba.core")` -> `FOUND_WITHOUT_TYPE_HINTS`. Because the parent is being processed under silenced follow-imports, the resulting `ModuleNotFound` doesn't surface and `numba.core` is never analyzed; `jit` never enters `numba`'s symbol table. 7. `main.py` does `import numba`. Same find result as step 5 (cached). The symbol table from step 6 is reused -- it lacks `jit`. The access `numba.jit` raises `attr-defined`. My initial fix (not the one in this commit) had been to avoid adding a package to ns_ancestors in step 3 above, but that has the downside that usages of `import numba.typed` become `Any`, compared with the `__getattr__` fix. Instead, this more robust fix injects a module-level `__getattr__: (str) -> Any` into the parent's symbol table, which allows the typed submodule to stay typed. This synthesized annotation is added when: 1. the State isn't a stub, has no definitions, and no other gettattr 2. the module was previously only found as a parent module of a py.typed 3. self.path is a directory (treated as a namespace package), but there's actually a `__init__.py[i]` file there. The synthetic annotation is added with `module_public=False` and `module_hidden=True` so direct user accesses like `pkg.__getattr__` and `from pkg import __getattr__` fall through to `types.ModuleType.__getattr__` from typeshed -- the same answer a plain untyped module would give. mypy's internal __getattr__-fallback path reads `tree.names["__getattr__"]` directly and bypasses both flags, so the synthetic is still consulted for `pkg.X` lookups. `lookup_module_name` in semanal keeps its existing priority order: a real submodule already loaded into `self.modules` wins first, and only unresolved attributes fall back to `__getattr__`. So: - `numba.jit` -- resolves through `__getattr__` to `Any` instead of raising `attr-defined`. - `numba.typed` resolves to the typed submodule. - Direct binding forms (`from numba.typed import X`, `from numba import typed`, `import numba.typed as ts`) remain typed. The helper needs `find_module_cache.ns_ancestors` and two cached FS reads via `fscache`, both owned by `BuildManager`, so doing the work in `State` reuses `self.manager` natively. Since `semantic_analysis_pass1` runs once per module and uses a number of early exits. When analyzing the State per-file --timing-stats the change very minimal (20us). (Claude 4.7 used brainstorming different iterations of this fix.)
1 parent 938dbe2 commit 02767a9

8 files changed

Lines changed: 110 additions & 1 deletion

File tree

mypy/build.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
)
109109
from mypy.messages import MessageBuilder
110110
from mypy.nodes import (
111+
GDEF,
111112
Decorator,
112113
FileRawData,
113114
FuncDef,
@@ -118,6 +119,8 @@
118119
MypyFile,
119120
OverloadedFuncDef,
120121
SymbolTable,
122+
SymbolTableNode,
123+
Var,
121124
)
122125
from mypy.options import OPTIONS_AFFECTING_CACHE_NO_PLATFORM
123126
from mypy.partially_defined import PossiblyUndefinedVariableVisitor
@@ -169,7 +172,7 @@
169172
from mypy.renaming import LimitedVariableRenameVisitor, VariableRenameVisitor
170173
from mypy.stats import dump_type_stats
171174
from mypy.stubinfo import stub_distribution_name
172-
from mypy.types import Type, instance_cache
175+
from mypy.types import AnyType, Type, TypeOfAny, instance_cache
173176
from mypy.typestate import reset_global_state, type_state
174177
from mypy.util import json_dumps, json_loads
175178
from mypy.version import __version__
@@ -3185,6 +3188,34 @@ def parse_file_inner(self, source: str | None, raw_data: FileRawData | None = No
31853188
)
31863189
self.time_spent_us += time_spent_us(t0)
31873190

3191+
def _maybe_inject_synthetic_getattr_for_typed_subpackage(self) -> None:
3192+
"""Give an untyped parent of a typed subpackage a fallback __getattr__.
3193+
3194+
Without this, attribute access on the parent raises ``attr-defined``
3195+
for any name that's defined in the parent's ``__init__.py`` -- because
3196+
that file is never read (the ns_ancestors fallback bound this State to
3197+
the package directory).
3198+
"""
3199+
tree = self.tree
3200+
if tree is None or tree.is_stub or tree.defs or "__getattr__" in tree.names:
3201+
return
3202+
if self.id not in self.manager.find_module_cache.ns_ancestors:
3203+
return
3204+
# If path is a directory and `__init__.py` exists, we know the file
3205+
# wasn't read. If `--follow-untyped-imports` was used, path would be the
3206+
# `__init__.py` file itself, in which we don't want to interfere.
3207+
if not self.path or not self.manager.fscache.isdir(self.path):
3208+
return
3209+
if not (
3210+
self.manager.fscache.isfile(os_path_join(self.path, "__init__.py"))
3211+
or self.manager.fscache.isfile(os_path_join(self.path, "__init__.pyi"))
3212+
):
3213+
return
3214+
var = Var("__getattr__", type=AnyType(TypeOfAny.from_unimported_type))
3215+
tree.names["__getattr__"] = SymbolTableNode(
3216+
GDEF, var, module_public=False, module_hidden=True
3217+
)
3218+
31883219
def parse_file(self, *, temporary: bool = False, raw_data: FileRawData | None = None) -> None:
31893220
"""Parse file and run first pass of semantic analysis.
31903221
@@ -3311,6 +3342,7 @@ def semantic_analysis_pass1(self) -> None:
33113342
analyzer.visit_file(self.tree, self.xpath, self.id, options)
33123343
# TODO: Do this while constructing the AST?
33133344
self.tree.names = SymbolTable()
3345+
self._maybe_inject_synthetic_getattr_for_typed_subpackage()
33143346
if not self.tree.is_stub:
33153347
if not self.options.allow_redefinition:
33163348
# Perform some low-key variable renaming when assignments can't

mypy/modulefinder.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@ def __init__(
196196
self.initial_components: dict[tuple[str, ...], dict[str, list[str]]] = {}
197197
# Cache find_module: id -> result
198198
self.results: dict[str, ModuleSearchResult] = {}
199+
# Ancestor packages reached only because a descendant ships py.typed.
200+
# Maps id -> directory; consulted as a fallback in `_find_module` so
201+
# those parents are findable too.
199202
self.ns_ancestors: dict[str, str] = {}
200203
self.options = options
201204
custom_typeshed_dir = None
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[project]
2+
name = 'untypedpkg_w_typed_sub'
3+
version = '0.1'
4+
description = 'test'
5+
6+
[tool.hatch.build]
7+
include = ["**/*.py", "**/*.pyi", "**/py.typed"]
8+
9+
[build-system]
10+
requires = ["hatchling==1.18"]
11+
build-backend = "hatchling.build"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from untypedpkg_w_typed_sub.sibling import re_exported
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
def re_exported() -> int: ...
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
EXAMPLE_CONST: int = 42

test-data/packages/untypedpkg_w_typed_sub/untypedpkg_w_typed_sub/typed_sub/py.typed

Whitespace-only changes.

test-data/unit/pep561.test

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,63 @@ import typedpkg_ns.b # type: ignore
248248
import typedpkg_ns.a
249249
[out]
250250
[out2]
251+
252+
[case testUntypedParentWithTypedSubDoesNotBreakReExports]
253+
# pkgs: untypedpkg_w_typed_sub
254+
# Regression: untyped parent with a typed subpackage. The parent gets a
255+
# synthetic __getattr__ so unresolved attrs return Any (no spurious
256+
# attr-defined), but real submodules still win in attribute lookup so
257+
# dotted access through the parent stays precisely typed.
258+
import other
259+
from untypedpkg_w_typed_sub import re_exported # type: ignore[import-untyped]
260+
reveal_type(re_exported)
261+
[file other.py]
262+
import untypedpkg_w_typed_sub.typed_sub # type: ignore[import-untyped]
263+
import untypedpkg_w_typed_sub.typed_sub as ts # type: ignore[import-untyped]
264+
from untypedpkg_w_typed_sub import typed_sub # type: ignore[import-untyped]
265+
from untypedpkg_w_typed_sub.typed_sub import EXAMPLE_CONST # type: ignore[import-untyped]
266+
reveal_type(untypedpkg_w_typed_sub.typed_sub.EXAMPLE_CONST)
267+
reveal_type(ts.EXAMPLE_CONST)
268+
reveal_type(typed_sub.EXAMPLE_CONST)
269+
reveal_type(EXAMPLE_CONST)
270+
[out]
271+
other.py:5: note: Revealed type is "int"
272+
other.py:6: note: Revealed type is "int"
273+
other.py:7: note: Revealed type is "int"
274+
other.py:8: note: Revealed type is "int"
275+
testUntypedParentWithTypedSubDoesNotBreakReExports.py:8: note: Revealed type is "Any"
276+
277+
[case testUntypedParentWithTypedSubFollowUntypedImports]
278+
# pkgs: untypedpkg_w_typed_sub
279+
# flags: --follow-untyped-imports
280+
# When the user opts into precise untyped-import analysis, mypy parses the
281+
# parent's __init__.py for real -- so the re-export gets its precise type
282+
# (not Any), and unknown attributes correctly raise attr-defined. The
283+
# synthetic __getattr__ fix stays dormant in this mode.
284+
import untypedpkg_w_typed_sub
285+
import untypedpkg_w_typed_sub.typed_sub
286+
from untypedpkg_w_typed_sub import re_exported
287+
reveal_type(re_exported)
288+
reveal_type(untypedpkg_w_typed_sub.typed_sub.EXAMPLE_CONST)
289+
untypedpkg_w_typed_sub.nonexistent
290+
[out]
291+
testUntypedParentWithTypedSubFollowUntypedImports.py:10: note: Revealed type is "def () -> int"
292+
testUntypedParentWithTypedSubFollowUntypedImports.py:11: note: Revealed type is "int"
293+
testUntypedParentWithTypedSubFollowUntypedImports.py:12: error: Module has no attribute "nonexistent"
294+
295+
[case testUntypedParentWithTypedSubGetattrIsHidden]
296+
# pkgs: untypedpkg_w_typed_sub
297+
# The synthetic __getattr__ must not leak as a user-visible attribute or
298+
# satisfy `from pkg import __getattr__`. Direct access to `pkg.__getattr__`
299+
# should fall through to `types.ModuleType.__getattr__` -- the same answer
300+
# a plain untyped module would give.
301+
import untypedpkg_w_typed_sub.typed_sub # type: ignore[import-untyped]
302+
import untypedpkg_w_typed_sub # type: ignore[import-untyped]
303+
reveal_type(untypedpkg_w_typed_sub.__getattr__)
304+
from untypedpkg_w_typed_sub import __getattr__ as g # type: ignore[import-untyped]
305+
reveal_type(g)
306+
[out]
307+
testUntypedParentWithTypedSubGetattrIsHidden.py:8: note: Revealed type is "def (str) -> Any"
308+
testUntypedParentWithTypedSubGetattrIsHidden.py:9: error: Module "untypedpkg_w_typed_sub" does not explicitly export attribute "__getattr__"
309+
testUntypedParentWithTypedSubGetattrIsHidden.py:9: note: Error code "attr-defined" not covered by "type: ignore[import-untyped]" comment
310+
testUntypedParentWithTypedSubGetattrIsHidden.py:10: note: Revealed type is "Any"

0 commit comments

Comments
 (0)