Skip to content

Commit 94fc51f

Browse files
emmatypingilevkivskyi
authored andcommitted
Add support for partial stub packages (#5227)
This should conform to PEP 561 as laid out in https://www.python.org/dev/peps/pep-0561/#partial-stub-packages.
1 parent 4026cb8 commit 94fc51f

File tree

5 files changed

+43
-16
lines changed

5 files changed

+43
-16
lines changed

mypy/build.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,10 @@ def _get_site_packages_dirs(python_executable: Optional[str]) -> List[str]:
862862
stderr=subprocess.PIPE).decode())
863863

864864

865+
# Search paths are a two-tuple of path and whether to verify the module
866+
SearchPaths = List[Tuple[str, bool]]
867+
868+
865869
class FindModuleCache:
866870
"""Module finder with integrated cache.
867871
@@ -875,16 +879,16 @@ class FindModuleCache:
875879

876880
def __init__(self, fscache: Optional[FileSystemCache] = None) -> None:
877881
self.fscache = fscache or FileSystemCache()
878-
# Cache find_lib_path_dirs: (dir_chain, lib_path)
879-
self.dirs = {} # type: Dict[Tuple[str, Tuple[str, ...]], List[str]]
882+
# Cache find_lib_path_dirs: (dir_chain, lib_path) -> list of (package_path, should_verify)
883+
self.dirs = {} # type: Dict[Tuple[str, Tuple[str, ...]], SearchPaths]
880884
# Cache find_module: (id, lib_path, python_version) -> result.
881885
self.results = {} # type: Dict[Tuple[str, Tuple[str, ...], Optional[str]], Optional[str]]
882886

883887
def clear(self) -> None:
884888
self.results.clear()
885889
self.dirs.clear()
886890

887-
def find_lib_path_dirs(self, dir_chain: str, lib_path: Tuple[str, ...]) -> List[str]:
891+
def find_lib_path_dirs(self, dir_chain: str, lib_path: Tuple[str, ...]) -> SearchPaths:
888892
# Cache some repeated work within distinct find_module calls: finding which
889893
# elements of lib_path have even the subdirectory they'd need for the module
890894
# to exist. This is shared among different module ids when they differ only
@@ -894,13 +898,13 @@ def find_lib_path_dirs(self, dir_chain: str, lib_path: Tuple[str, ...]) -> List[
894898
self.dirs[key] = self._find_lib_path_dirs(dir_chain, lib_path)
895899
return self.dirs[key]
896900

897-
def _find_lib_path_dirs(self, dir_chain: str, lib_path: Tuple[str, ...]) -> List[str]:
901+
def _find_lib_path_dirs(self, dir_chain: str, lib_path: Tuple[str, ...]) -> SearchPaths:
898902
dirs = []
899903
for pathitem in lib_path:
900904
# e.g., '/usr/lib/python3.4/foo/bar'
901905
dir = os.path.normpath(os.path.join(pathitem, dir_chain))
902906
if self.fscache.isdir(dir):
903-
dirs.append(dir)
907+
dirs.append((dir, True))
904908
return dirs
905909

906910
def find_module(self, id: str, lib_path: Tuple[str, ...],
@@ -933,13 +937,26 @@ def _find_module(self, id: str, lib_path: Tuple[str, ...],
933937
typed_file = os.path.join(pkg_dir, components[0], 'py.typed')
934938
stub_dir = os.path.join(pkg_dir, stub_name)
935939
if fscache.isdir(stub_dir):
940+
stub_typed_file = os.path.join(stub_dir, 'py.typed')
936941
stub_components = [stub_name] + components[1:]
937942
path = os.path.join(pkg_dir, *stub_components[:-1])
938943
if fscache.isdir(path):
939-
third_party_stubs_dirs.append(path)
944+
if fscache.isfile(stub_typed_file):
945+
# Stub packages can have a py.typed file, which must include
946+
# 'partial\n' to make the package partial
947+
# Partial here means that mypy should look at the runtime
948+
# package if installed.
949+
if fscache.read(stub_typed_file).decode().strip() == 'partial':
950+
runtime_path = os.path.join(pkg_dir, dir_chain)
951+
third_party_inline_dirs.append((runtime_path, True))
952+
# if the package is partial, we don't verify the module, as
953+
# the partial stub package may not have a __init__.pyi
954+
third_party_stubs_dirs.append((path, False))
955+
else:
956+
third_party_stubs_dirs.append((path, True))
940957
elif fscache.isfile(typed_file):
941958
path = os.path.join(pkg_dir, dir_chain)
942-
third_party_inline_dirs.append(path)
959+
third_party_inline_dirs.append((path, True))
943960
candidate_base_dirs = self.find_lib_path_dirs(dir_chain, lib_path) + \
944961
third_party_stubs_dirs + third_party_inline_dirs
945962

@@ -949,20 +966,26 @@ def _find_module(self, id: str, lib_path: Tuple[str, ...],
949966
# Now just look for 'baz.pyi', 'baz/__init__.py', etc., inside those directories.
950967
seplast = os.sep + components[-1] # so e.g. '/baz'
951968
sepinit = os.sep + '__init__'
952-
for base_dir in candidate_base_dirs:
969+
for base_dir, verify in candidate_base_dirs:
953970
base_path = base_dir + seplast # so e.g. '/usr/lib/python3.4/foo/bar/baz'
954971
# Prefer package over module, i.e. baz/__init__.py* over baz.py*.
955972
for extension in PYTHON_EXTENSIONS:
956973
path = base_path + sepinit + extension
957974
path_stubs = base_path + '-stubs' + sepinit + extension
958-
if fscache.isfile_case(path) and verify_module(fscache, id, path):
975+
if fscache.isfile_case(path):
976+
if verify and not verify_module(fscache, id, path):
977+
continue
959978
return path
960-
elif fscache.isfile_case(path_stubs) and verify_module(fscache, id, path_stubs):
979+
elif fscache.isfile_case(path_stubs):
980+
if verify and not verify_module(fscache, id, path_stubs):
981+
continue
961982
return path_stubs
962983
# No package, look for module.
963984
for extension in PYTHON_EXTENSIONS:
964985
path = base_path + extension
965-
if fscache.isfile_case(path) and verify_module(fscache, id, path):
986+
if fscache.isfile_case(path):
987+
if verify and not verify_module(fscache, id, path):
988+
continue
966989
return path
967990
return None
968991

mypy/test/testpep561.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
SIMPLE_PROGRAM = """
1515
from typedpkg.sample import ex
16+
from typedpkg import dne
1617
a = ex([''])
1718
reveal_type(a)
1819
"""
@@ -79,10 +80,12 @@ def setUp(self) -> None:
7980
self.tempfile = os.path.join(self.temp_file_dir.name, 'simple.py')
8081
with open(self.tempfile, 'w+') as file:
8182
file.write(SIMPLE_PROGRAM)
83+
self.msg_dne = \
84+
"{}:3: error: Module 'typedpkg' has no attribute 'dne'\n".format(self.tempfile)
8285
self.msg_list = \
83-
"{}:4: error: Revealed type is 'builtins.list[builtins.str]'\n".format(self.tempfile)
86+
"{}:5: error: Revealed type is 'builtins.list[builtins.str]'\n".format(self.tempfile)
8487
self.msg_tuple = \
85-
"{}:4: error: Revealed type is 'builtins.tuple[builtins.str]'\n".format(self.tempfile)
88+
"{}:5: error: Revealed type is 'builtins.tuple[builtins.str]'\n".format(self.tempfile)
8689

8790
def tearDown(self) -> None:
8891
self.temp_file_dir.cleanup()
@@ -99,7 +102,7 @@ def test_typedpkg_stub_package(self) -> None:
99102
check_mypy_run(
100103
[self.tempfile],
101104
python_executable,
102-
expected_out=self.msg_list,
105+
expected_out=self.msg_dne + self.msg_list,
103106
venv_dir=venv_dir,
104107
)
105108

@@ -135,7 +138,7 @@ def test_typedpkg_stubs_python2(self) -> None:
135138
check_mypy_run(
136139
[self.tempfile],
137140
py2,
138-
expected_out=self.msg_list,
141+
expected_out=self.msg_dne + self.msg_list,
139142
venv_dir=venv_dir,
140143
)
141144

test-data/packages/typedpkg-stubs/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@
88
name='typedpkg-stubs',
99
author="The mypy team",
1010
version='0.1',
11-
package_data={'typedpkg-stubs': ['sample.pyi', '__init__.pyi']},
11+
package_data={'typedpkg-stubs': ['sample.pyi', '__init__.pyi', 'py.typed']},
1212
packages=['typedpkg-stubs'],
1313
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
partial

test-data/packages/typedpkg/typedpkg/dne.py

Whitespace-only changes.

0 commit comments

Comments
 (0)