Skip to content

Commit 4c3f800

Browse files
authored
Use __getattr__ to mark partial stub packages (#5231)
There is a problem with annotating large frameworks -- they are large. Therefore it is hard to produce good stubs in a single pass. A possible better workflow would be to allow indicating that a given (sub-)package is incomplete. I propose to use `__getattr__` for the role of such indicator. A motivation is that currently adding a `__getattr__` to `package/__init__.pyi` already makes `from package import mod` work, but `import package.mod` still fails (plus simplicity of implementation). Here are the rules that I propose: * One can declare a (sub-)package as incomplete by adding a `__getattr__` to its `__init__.pyi` * If the return type of this function is `types.ModuleType` or `Any`, we assume that all imports from this (sub-)package succeed. * Incomplete package can contain a complete subpackage: ``` # file a/__init__.pyi from types import ModuleType def __getattr__(attr: str) -> ModuleType: ... # file a/b/__init__.pyi # empty (i.e. complete package) # file main.py import a.d # OK import a.b.c # Error module not found ``` Note: these rules apply only to stubs (i.e. `.pyi` files). I add several tests to illustrate this behaviour. This PR shouldn't give any significant performance penalty because the added parsing/loading only happens when an error would be reported (for our internal workflow the penalty will be zero because of the flags we use). This PR will allow gradually adding stub modules to a large framework package, without generating loads of false positives for user code. Note: PEP 561 introduces the notion of a partial stub package, implemented in #5227. I think however this is a bit different use case that I don't want to mix with this one for two reasons: * Partial packages in PEP 561 are mainly focused on interaction between stubs and inline/runtime packages. * The proposed feature may be also used in typeshed, not only for installed stub packages.
1 parent 6d8d50c commit 4c3f800

File tree

4 files changed

+189
-7
lines changed

4 files changed

+189
-7
lines changed

mypy/build.py

+49-6
Original file line numberDiff line numberDiff line change
@@ -1761,6 +1761,11 @@ def __init__(self,
17611761
caller_line: int = 0,
17621762
ancestor_for: 'Optional[State]' = None,
17631763
root_source: bool = False,
1764+
# If `temporary` is True, this State is being created to just
1765+
# quickly parse/load the tree, without an intention to further
1766+
# process it. With this flag, any changes to external state as well
1767+
# as error reporting should be avoided.
1768+
temporary: bool = False,
17641769
) -> None:
17651770
assert id or path or source is not None, "Neither id, path nor source given"
17661771
self.manager = manager
@@ -1782,9 +1787,10 @@ def __init__(self,
17821787
try:
17831788
path, follow_imports = find_module_and_diagnose(
17841789
manager, id, self.options, caller_state, caller_line,
1785-
ancestor_for, root_source)
1790+
ancestor_for, root_source, skip_diagnose=temporary)
17861791
except ModuleNotFound:
1787-
manager.missing_modules.add(id)
1792+
if not temporary:
1793+
manager.missing_modules.add(id)
17881794
raise
17891795
if follow_imports == 'silent':
17901796
self.ignore_all = True
@@ -2265,16 +2271,21 @@ def find_module_and_diagnose(manager: BuildManager,
22652271
caller_state: 'Optional[State]' = None,
22662272
caller_line: int = 0,
22672273
ancestor_for: 'Optional[State]' = None,
2268-
root_source: bool = False) -> Tuple[str, str]:
2274+
root_source: bool = False,
2275+
skip_diagnose: bool = False) -> Tuple[str, str]:
22692276
"""Find a module by name, respecting follow_imports and producing diagnostics.
22702277
2278+
If the module is not found, then the ModuleNotFound exception is raised.
2279+
22712280
Args:
22722281
id: module to find
22732282
options: the options for the module being loaded
22742283
caller_state: the state of the importing module, if applicable
22752284
caller_line: the line number of the import
22762285
ancestor_for: the child module this is an ancestor of, if applicable
22772286
root_source: whether this source was specified on the command line
2287+
skip_diagnose: skip any error diagnosis and reporting (but ModuleNotFound is
2288+
still raised if the module is missing)
22782289
22792290
The specified value of follow_imports for a module can be overridden
22802291
if the module is specified on the command line or if it is a stub,
@@ -2306,8 +2317,9 @@ def find_module_and_diagnose(manager: BuildManager,
23062317
and not options.follow_imports_for_stubs) # except when they aren't
23072318
or id == 'builtins'): # Builtins is always normal
23082319
follow_imports = 'normal'
2309-
2310-
if follow_imports == 'silent':
2320+
if skip_diagnose:
2321+
pass
2322+
elif follow_imports == 'silent':
23112323
# Still import it, but silence non-blocker errors.
23122324
manager.log("Silencing %s (%s)" % (path, id))
23132325
elif follow_imports == 'skip' or follow_imports == 'error':
@@ -2327,8 +2339,10 @@ def find_module_and_diagnose(manager: BuildManager,
23272339
# Could not find a module. Typically the reason is a
23282340
# misspelled module name, missing stub, module not in
23292341
# search path or the module has not been installed.
2342+
if skip_diagnose:
2343+
raise ModuleNotFound
23302344
if caller_state:
2331-
if not options.ignore_missing_imports:
2345+
if not (options.ignore_missing_imports or in_partial_package(id, manager)):
23322346
module_not_found(manager, caller_line, caller_state, id)
23332347
raise ModuleNotFound
23342348
else:
@@ -2338,6 +2352,35 @@ def find_module_and_diagnose(manager: BuildManager,
23382352
raise CompileError(["mypy: can't find module '%s'" % id])
23392353

23402354

2355+
def in_partial_package(id: str, manager: BuildManager) -> bool:
2356+
"""Check if a missing module can potentially be a part of a package.
2357+
2358+
This checks if there is any existing parent __init__.pyi stub that
2359+
defines a module-level __getattr__ (a.k.a. partial stub package).
2360+
"""
2361+
while '.' in id:
2362+
parent, _ = id.rsplit('.', 1)
2363+
if parent in manager.modules:
2364+
parent_mod = manager.modules[parent] # type: Optional[MypyFile]
2365+
else:
2366+
# Parent is not in build, try quickly if we can find it.
2367+
try:
2368+
parent_st = State(id=parent, path=None, source=None, manager=manager,
2369+
temporary=True)
2370+
except (ModuleNotFound, CompileError):
2371+
parent_mod = None
2372+
else:
2373+
parent_mod = parent_st.tree
2374+
if parent_mod is not None:
2375+
if parent_mod.is_partial_stub_package:
2376+
return True
2377+
else:
2378+
# Bail out soon, complete subpackage found
2379+
return False
2380+
id = parent
2381+
return False
2382+
2383+
23412384
def module_not_found(manager: BuildManager, line: int, caller_state: State,
23422385
target: str) -> None:
23432386
errors = manager.errors

mypy/nodes.py

+6
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ class MypyFile(SymbolNode):
215215
is_stub = False
216216
# Is this loaded from the cache and thus missing the actual body of the file?
217217
is_cache_skeleton = False
218+
# Does this represent an __init__.pyi stub with a module __getattr__
219+
# (i.e. a partial stub package), for such packages we suppress any missing
220+
# module errors in addition to missing attribute errors.
221+
is_partial_stub_package = False
218222

219223
def __init__(self,
220224
defs: List[Statement],
@@ -252,6 +256,7 @@ def serialize(self) -> JsonDict:
252256
'names': self.names.serialize(self._fullname),
253257
'is_stub': self.is_stub,
254258
'path': self.path,
259+
'is_partial_stub_package': self.is_partial_stub_package,
255260
}
256261

257262
@classmethod
@@ -263,6 +268,7 @@ def deserialize(cls, data: JsonDict) -> 'MypyFile':
263268
tree.names = SymbolTable.deserialize(data['names'])
264269
tree.is_stub = data['is_stub']
265270
tree.path = data['path']
271+
tree.is_partial_stub_package = data['is_partial_stub_package']
266272
tree.is_cache_skeleton = True
267273
return tree
268274

mypy/semanal_pass1.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
TryStmt, OverloadedFuncDef, Lvalue, Context, ImportedName, LDEF, GDEF, MDEF, UNBOUND_IMPORTED,
2727
MODULE_REF, implicit_module_attrs
2828
)
29-
from mypy.types import Type, UnboundType, UnionType, AnyType, TypeOfAny, NoneTyp
29+
from mypy.types import Type, UnboundType, UnionType, AnyType, TypeOfAny, NoneTyp, CallableType
3030
from mypy.semanal import SemanticAnalyzerPass2, infer_reachability_of_if_statement
3131
from mypy.semanal_shared import create_indirect_imported_name
3232
from mypy.options import Options
@@ -154,6 +154,17 @@ def visit_func_def(self, func: FuncDef) -> None:
154154
func.is_conditional = sem.block_depth[-1] > 0
155155
func._fullname = sem.qualified_name(func.name())
156156
at_module = sem.is_module_scope()
157+
if (at_module and func.name() == '__getattr__' and
158+
self.sem.cur_mod_node.is_package_init_file() and self.sem.cur_mod_node.is_stub):
159+
if isinstance(func.type, CallableType):
160+
ret = func.type.ret_type
161+
if isinstance(ret, UnboundType) and not ret.args:
162+
sym = self.sem.lookup_qualified(ret.name, func, suppress_errors=True)
163+
# We only interpret a package as partial if the __getattr__ return type
164+
# is either types.ModuleType of Any.
165+
if sym and sym.node and sym.node.fullname() in ('types.ModuleType',
166+
'typing.Any'):
167+
self.sem.cur_mod_node.is_partial_stub_package = True
157168
if at_module and func.name() in sem.globals:
158169
# Already defined in this module.
159170
original_sym = sem.globals[func.name()]

test-data/unit/check-modules.test

+122
Original file line numberDiff line numberDiff line change
@@ -2173,6 +2173,128 @@ from c import x
21732173
x = str()
21742174
y = int()
21752175

2176+
[case testModuleGetattrInit1]
2177+
from a import b
2178+
2179+
x = b.f()
2180+
[file a/__init__.pyi]
2181+
from typing import Any
2182+
def __getattr__(attr: str) -> Any: ...
2183+
[builtins fixtures/module.pyi]
2184+
[out]
2185+
2186+
[case testModuleGetattrInit2]
2187+
import a.b
2188+
2189+
x = a.b.f()
2190+
[file a/__init__.pyi]
2191+
from typing import Any
2192+
def __getattr__(attr: str) -> Any: ...
2193+
[builtins fixtures/module.pyi]
2194+
[out]
2195+
2196+
[case testModuleGetattrInit3]
2197+
import a.b
2198+
2199+
x = a.b.f()
2200+
[file a/__init__.py]
2201+
from typing import Any
2202+
def __getattr__(attr: str) -> Any: ...
2203+
[builtins fixtures/module.pyi]
2204+
[out]
2205+
main:1: error: Cannot find module named 'a.b'
2206+
main:1: note: (Perhaps setting MYPYPATH or using the "--ignore-missing-imports" flag would help)
2207+
2208+
[case testModuleGetattrInit4]
2209+
import a.b.c
2210+
2211+
x = a.b.c.f()
2212+
[file a/__init__.pyi]
2213+
from typing import Any
2214+
def __getattr__(attr: str) -> Any: ...
2215+
[builtins fixtures/module.pyi]
2216+
[out]
2217+
2218+
[case testModuleGetattrInit5]
2219+
from a.b import f
2220+
2221+
x = f()
2222+
[file a/__init__.pyi]
2223+
from typing import Any
2224+
def __getattr__(attr: str) -> Any: ...
2225+
[builtins fixtures/module.pyi]
2226+
[out]
2227+
2228+
[case testModuleGetattrInit5a]
2229+
from a.b import f
2230+
2231+
x = f()
2232+
[file a/__init__.pyi]
2233+
from types import ModuleType
2234+
def __getattr__(attr: str) -> ModuleType: ...
2235+
[builtins fixtures/module.pyi]
2236+
[out]
2237+
2238+
2239+
[case testModuleGetattrInit5b]
2240+
from a.b import f
2241+
2242+
x = f()
2243+
[file a/__init__.pyi]
2244+
def __getattr__(attr: str) -> int: ...
2245+
[builtins fixtures/module.pyi]
2246+
[out]
2247+
main:1: error: Cannot find module named 'a.b'
2248+
main:1: note: (Perhaps setting MYPYPATH or using the "--ignore-missing-imports" flag would help)
2249+
2250+
[case testModuleGetattrInit8]
2251+
import a.b.c.d
2252+
2253+
x = a.b.c.d.f()
2254+
[file a/__init__.pyi]
2255+
from typing import Any
2256+
def __getattr__(attr: str) -> Any: ...
2257+
[file a/b/__init__.pyi]
2258+
# empty (i.e. complete subpackage)
2259+
[builtins fixtures/module.pyi]
2260+
[out]
2261+
main:1: error: Cannot find module named 'a.b.c'
2262+
main:1: note: (Perhaps setting MYPYPATH or using the "--ignore-missing-imports" flag would help)
2263+
main:1: error: Cannot find module named 'a.b.c.d'
2264+
2265+
[case testModuleGetattrInit8a]
2266+
import a.b.c # Error
2267+
import a.d # OK
2268+
[file a/__init__.pyi]
2269+
from typing import Any
2270+
def __getattr__(attr: str) -> Any: ...
2271+
[file a/b/__init__.pyi]
2272+
# empty (i.e. complete subpackage)
2273+
[builtins fixtures/module.pyi]
2274+
[out]
2275+
main:1: error: Cannot find module named 'a.b.c'
2276+
main:1: note: (Perhaps setting MYPYPATH or using the "--ignore-missing-imports" flag would help)
2277+
2278+
[case testModuleGetattrInit10]
2279+
# flags: --config-file tmp/mypy.ini
2280+
import a.b.c # silenced
2281+
import a.b.d # error
2282+
2283+
[file a/__init__.pyi]
2284+
from typing import Any
2285+
def __getattr__(attr: str) -> Any: ...
2286+
[file a/b/__init__.pyi]
2287+
# empty (i.e. complete subpackage)
2288+
2289+
[file mypy.ini]
2290+
[[mypy]
2291+
[[mypy-a.b.c]
2292+
ignore_missing_imports = True
2293+
[builtins fixtures/module.pyi]
2294+
[out]
2295+
main:3: error: Cannot find module named 'a.b.d'
2296+
main:3: note: (Perhaps setting MYPYPATH or using the "--ignore-missing-imports" flag would help)
2297+
21762298
[case testIndirectFromImportWithinCycleUsedAsBaseClass-skip]
21772299
-- TODO: Fails because of missing ImportedName handling in mypy.typeanal
21782300
import a

0 commit comments

Comments
 (0)