Commit 02767a9
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
- test-data
- packages/untypedpkg_w_typed_sub
- untypedpkg_w_typed_sub
- typed_sub
- unit
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
108 | 108 | | |
109 | 109 | | |
110 | 110 | | |
| 111 | + | |
111 | 112 | | |
112 | 113 | | |
113 | 114 | | |
| |||
118 | 119 | | |
119 | 120 | | |
120 | 121 | | |
| 122 | + | |
| 123 | + | |
121 | 124 | | |
122 | 125 | | |
123 | 126 | | |
| |||
169 | 172 | | |
170 | 173 | | |
171 | 174 | | |
172 | | - | |
| 175 | + | |
173 | 176 | | |
174 | 177 | | |
175 | 178 | | |
| |||
3185 | 3188 | | |
3186 | 3189 | | |
3187 | 3190 | | |
| 3191 | + | |
| 3192 | + | |
| 3193 | + | |
| 3194 | + | |
| 3195 | + | |
| 3196 | + | |
| 3197 | + | |
| 3198 | + | |
| 3199 | + | |
| 3200 | + | |
| 3201 | + | |
| 3202 | + | |
| 3203 | + | |
| 3204 | + | |
| 3205 | + | |
| 3206 | + | |
| 3207 | + | |
| 3208 | + | |
| 3209 | + | |
| 3210 | + | |
| 3211 | + | |
| 3212 | + | |
| 3213 | + | |
| 3214 | + | |
| 3215 | + | |
| 3216 | + | |
| 3217 | + | |
| 3218 | + | |
3188 | 3219 | | |
3189 | 3220 | | |
3190 | 3221 | | |
| |||
3311 | 3342 | | |
3312 | 3343 | | |
3313 | 3344 | | |
| 3345 | + | |
3314 | 3346 | | |
3315 | 3347 | | |
3316 | 3348 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
196 | 196 | | |
197 | 197 | | |
198 | 198 | | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
199 | 202 | | |
200 | 203 | | |
201 | 204 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
Whitespace-only changes.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
248 | 248 | | |
249 | 249 | | |
250 | 250 | | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
0 commit comments