Skip to content

Commit 2004ae0

Browse files
authored
Search sys.path for PEP-561 compliant packages (#11143)
Closes #5701 This replaces the old hand crafted search code that was more fragile.
1 parent b07018c commit 2004ae0

File tree

10 files changed

+80
-123
lines changed

10 files changed

+80
-123
lines changed

mypy/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from mypy import util
1717
from mypy.modulefinder import (
1818
BuildSource, FindModuleCache, SearchPaths,
19-
get_site_packages_dirs, mypy_path,
19+
get_search_dirs, mypy_path,
2020
)
2121
from mypy.find_sources import create_source_list, InvalidSourceList
2222
from mypy.fscache import FileSystemCache
@@ -1043,10 +1043,10 @@ def set_strict_flags() -> None:
10431043
# Set target.
10441044
if special_opts.modules + special_opts.packages:
10451045
options.build_type = BuildType.MODULE
1046-
egg_dirs, site_packages = get_site_packages_dirs(options.python_executable)
1046+
search_dirs = get_search_dirs(options.python_executable)
10471047
search_paths = SearchPaths((os.getcwd(),),
10481048
tuple(mypy_path() + options.mypy_path),
1049-
tuple(egg_dirs + site_packages),
1049+
tuple(search_dirs),
10501050
())
10511051
targets = []
10521052
# TODO: use the same cache that the BuildManager will

mypy/modulefinder.py

Lines changed: 21 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
else:
2020
import tomli as tomllib
2121

22-
from typing import Dict, Iterator, List, NamedTuple, Optional, Set, Tuple, Union
22+
from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union
2323
from typing_extensions import Final, TypeAlias as _TypeAlias
2424

2525
from mypy.fscache import FileSystemCache
@@ -330,6 +330,9 @@ def _find_module_non_stub_helper(self, components: List[str],
330330
elif not plausible_match and (self.fscache.isdir(dir_path)
331331
or self.fscache.isfile(dir_path + ".py")):
332332
plausible_match = True
333+
# If this is not a directory then we can't traverse further into it
334+
if not self.fscache.isdir(dir_path):
335+
break
333336
if is_legacy_bundled_package(components[0], self.python_major_ver):
334337
if (len(components) == 1
335338
or (self.find_module(components[0]) is
@@ -724,97 +727,32 @@ def default_lib_path(data_dir: str,
724727

725728

726729
@functools.lru_cache(maxsize=None)
727-
def get_prefixes(python_executable: Optional[str]) -> Tuple[str, str]:
728-
"""Get the sys.base_prefix and sys.prefix for the given python.
729-
730-
This runs a subprocess call to get the prefix paths of the given Python executable.
731-
To avoid repeatedly calling a subprocess (which can be slow!) we
732-
lru_cache the results.
733-
"""
734-
if python_executable is None:
735-
return '', ''
736-
elif python_executable == sys.executable:
737-
# Use running Python's package dirs
738-
return pyinfo.getprefixes()
739-
else:
740-
# Use subprocess to get the package directory of given Python
741-
# executable
742-
return ast.literal_eval(
743-
subprocess.check_output([python_executable, pyinfo.__file__, 'getprefixes'],
744-
stderr=subprocess.PIPE).decode())
745-
746-
747-
@functools.lru_cache(maxsize=None)
748-
def get_site_packages_dirs(python_executable: Optional[str]) -> Tuple[List[str], List[str]]:
730+
def get_search_dirs(python_executable: Optional[str]) -> List[str]:
749731
"""Find package directories for given python.
750732
751-
This runs a subprocess call, which generates a list of the egg directories, and the site
752-
package directories. To avoid repeatedly calling a subprocess (which can be slow!) we
733+
This runs a subprocess call, which generates a list of the directories in sys.path.
734+
To avoid repeatedly calling a subprocess (which can be slow!) we
753735
lru_cache the results.
754736
"""
755737

756738
if python_executable is None:
757-
return [], []
739+
return []
758740
elif python_executable == sys.executable:
759741
# Use running Python's package dirs
760-
site_packages = pyinfo.getsitepackages()
742+
sys_path = pyinfo.getsearchdirs()
761743
else:
762744
# Use subprocess to get the package directory of given Python
763745
# executable
764746
try:
765-
site_packages = ast.literal_eval(
766-
subprocess.check_output([python_executable, pyinfo.__file__, 'getsitepackages'],
747+
sys_path = ast.literal_eval(
748+
subprocess.check_output([python_executable, pyinfo.__file__, 'getsearchdirs'],
767749
stderr=subprocess.PIPE).decode())
768750
except OSError as err:
769751
reason = os.strerror(err.errno)
770752
raise CompileError(
771753
[f"mypy: Invalid python executable '{python_executable}': {reason}"]
772754
) from err
773-
return expand_site_packages(site_packages)
774-
775-
776-
def expand_site_packages(site_packages: List[str]) -> Tuple[List[str], List[str]]:
777-
"""Expands .pth imports in site-packages directories"""
778-
egg_dirs: List[str] = []
779-
for dir in site_packages:
780-
if not os.path.isdir(dir):
781-
continue
782-
pth_filenames = sorted(name for name in os.listdir(dir) if name.endswith(".pth"))
783-
for pth_filename in pth_filenames:
784-
egg_dirs.extend(_parse_pth_file(dir, pth_filename))
785-
786-
return egg_dirs, site_packages
787-
788-
789-
def _parse_pth_file(dir: str, pth_filename: str) -> Iterator[str]:
790-
"""
791-
Mimics a subset of .pth import hook from Lib/site.py
792-
See https://github.com/python/cpython/blob/3.5/Lib/site.py#L146-L185
793-
"""
794-
795-
pth_file = os.path.join(dir, pth_filename)
796-
try:
797-
f = open(pth_file)
798-
except OSError:
799-
return
800-
with f:
801-
for line in f.readlines():
802-
if line.startswith("#"):
803-
# Skip comment lines
804-
continue
805-
if line.startswith(("import ", "import\t")):
806-
# import statements in .pth files are not supported
807-
continue
808-
809-
yield _make_abspath(line.rstrip(), dir)
810-
811-
812-
def _make_abspath(path: str, root: str) -> str:
813-
"""Take a path and make it absolute relative to root if not already absolute."""
814-
if os.path.isabs(path):
815-
return os.path.normpath(path)
816-
else:
817-
return os.path.join(root, os.path.normpath(path))
755+
return sys_path
818756

819757

820758
def add_py2_mypypath_entries(mypypath: List[str]) -> List[str]:
@@ -903,27 +841,21 @@ def compute_search_paths(sources: List[BuildSource],
903841
if options.python_version[0] == 2:
904842
mypypath = add_py2_mypypath_entries(mypypath)
905843

906-
egg_dirs, site_packages = get_site_packages_dirs(options.python_executable)
907-
base_prefix, prefix = get_prefixes(options.python_executable)
908-
is_venv = base_prefix != prefix
909-
for site_dir in site_packages:
910-
assert site_dir not in lib_path
911-
if (site_dir in mypypath or
912-
any(p.startswith(site_dir + os.path.sep) for p in mypypath) or
913-
os.path.altsep and any(p.startswith(site_dir + os.path.altsep) for p in mypypath)):
914-
print(f"{site_dir} is in the MYPYPATH. Please remove it.", file=sys.stderr)
844+
search_dirs = get_search_dirs(options.python_executable)
845+
for search_dir in search_dirs:
846+
assert search_dir not in lib_path
847+
if (search_dir in mypypath or
848+
any(p.startswith(search_dir + os.path.sep) for p in mypypath) or
849+
(os.path.altsep
850+
and any(p.startswith(search_dir + os.path.altsep) for p in mypypath))):
851+
print(f"{search_dir} is in the MYPYPATH. Please remove it.", file=sys.stderr)
915852
print("See https://mypy.readthedocs.io/en/stable/running_mypy.html"
916853
"#how-mypy-handles-imports for more info", file=sys.stderr)
917854
sys.exit(1)
918-
elif site_dir in python_path and (is_venv and not site_dir.startswith(prefix)):
919-
print("{} is in the PYTHONPATH. Please change directory"
920-
" so it is not.".format(site_dir),
921-
file=sys.stderr)
922-
sys.exit(1)
923855

924856
return SearchPaths(python_path=tuple(reversed(python_path)),
925857
mypy_path=tuple(mypypath),
926-
package_path=tuple(egg_dirs + site_packages),
858+
package_path=tuple(search_dirs),
927859
typeshed_path=tuple(lib_path))
928860

929861

mypy/pyinfo.py

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,39 @@
66
library found in Python 2. This file is run each mypy run, so it should be kept as fast as
77
possible.
88
"""
9-
import site
9+
import os
1010
import sys
11+
import sysconfig
1112

1213
if __name__ == '__main__':
1314
sys.path = sys.path[1:] # we don't want to pick up mypy.types
1415

1516
MYPY = False
1617
if MYPY:
17-
from typing import List, Tuple
18+
from typing import List
1819

1920

20-
def getprefixes():
21-
# type: () -> Tuple[str, str]
22-
return getattr(sys, "base_prefix", sys.prefix), sys.prefix
23-
24-
25-
def getsitepackages():
21+
def getsearchdirs():
2622
# type: () -> List[str]
27-
res = []
28-
if hasattr(site, 'getsitepackages'):
29-
res.extend(site.getsitepackages())
30-
31-
if hasattr(site, 'getusersitepackages') and site.ENABLE_USER_SITE:
32-
res.insert(0, site.getusersitepackages())
33-
else:
34-
from distutils.sysconfig import get_python_lib
35-
res = [get_python_lib()]
36-
return res
23+
# Do not include things from the standard library
24+
# because those should come from typeshed.
25+
stdlib_zip = os.path.join(
26+
sys.base_exec_prefix,
27+
getattr(sys, "platlibdir", "lib"),
28+
"python{}{}.zip".format(sys.version_info.major, sys.version_info.minor)
29+
)
30+
stdlib = sysconfig.get_path("stdlib")
31+
stdlib_ext = os.path.join(stdlib, "lib-dynload")
32+
cwd = os.path.abspath(os.getcwd())
33+
excludes = set([cwd, stdlib_zip, stdlib, stdlib_ext])
34+
35+
abs_sys_path = (os.path.abspath(p) for p in sys.path)
36+
return [p for p in abs_sys_path if p not in excludes]
3737

3838

3939
if __name__ == '__main__':
40-
if sys.argv[-1] == 'getsitepackages':
41-
print(repr(getsitepackages()))
42-
elif sys.argv[-1] == 'getprefixes':
43-
print(repr(getprefixes()))
40+
if sys.argv[-1] == 'getsearchdirs':
41+
print(repr(getsearchdirs()))
4442
else:
4543
print("ERROR: incorrect argument to pyinfo.py.", file=sys.stderr)
4644
sys.exit(1)

mypy/test/testcmdline.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ def test_python_cmdline(testcase: DataDrivenTestCase, step: int) -> None:
6565
fixed = [python3_path, '-m', 'mypy']
6666
env = os.environ.copy()
6767
env.pop('COLUMNS', None)
68+
extra_path = os.path.join(os.path.abspath(test_temp_dir), 'pypath')
6869
env['PYTHONPATH'] = PREFIX
70+
if os.path.isdir(extra_path):
71+
env['PYTHONPATH'] += os.pathsep + extra_path
6972
process = subprocess.Popen(fixed + args,
7073
stdout=subprocess.PIPE,
7174
stderr=subprocess.PIPE,

mypy/test/testmodulefinder.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
FindModuleCache,
66
SearchPaths,
77
ModuleNotFoundReason,
8-
expand_site_packages
98
)
109

1110
from mypy.test.helpers import Suite, assert_equal
@@ -149,12 +148,17 @@ def setUp(self) -> None:
149148
"modulefinder-site-packages",
150149
))
151150

152-
egg_dirs, site_packages = expand_site_packages([self.package_dir])
151+
package_paths = (
152+
os.path.join(self.package_dir, "baz"),
153+
os.path.join(self.package_dir, "..", "not-a-directory"),
154+
os.path.join(self.package_dir, "..", "modulefinder-src"),
155+
self.package_dir,
156+
)
153157

154158
self.search_paths = SearchPaths(
155159
python_path=(),
156160
mypy_path=(os.path.join(data_path, "pkg1"),),
157-
package_path=tuple(egg_dirs + site_packages),
161+
package_path=tuple(package_paths),
158162
typeshed_path=(),
159163
)
160164
options = Options()

test-data/packages/modulefinder-site-packages/baz.pth

Lines changed: 0 additions & 1 deletion
This file was deleted.

test-data/packages/modulefinder-site-packages/dne.pth

Lines changed: 0 additions & 1 deletion
This file was deleted.

test-data/packages/modulefinder-site-packages/ignored.pth

Lines changed: 0 additions & 3 deletions
This file was deleted.

test-data/packages/modulefinder-site-packages/neighbor.pth

Lines changed: 0 additions & 1 deletion
This file was deleted.

test-data/unit/cmdline.test

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,32 @@ main.py:6: error: Unsupported operand types for + ("int" and "str")
365365
main.py:7: error: Module has no attribute "y"
366366
main.py:8: error: Unsupported operand types for + (Module and "int")
367367

368+
[case testConfigFollowImportsSysPath]
369+
# cmd: mypy main.py
370+
[file main.py]
371+
from a import x
372+
x + 0
373+
x + '' # E
374+
import a
375+
a.x + 0
376+
a.x + '' # E
377+
a.y # E
378+
a + 0 # E
379+
[file mypy.ini]
380+
\[mypy]
381+
follow_imports = normal
382+
no_silence_site_packages = True
383+
[file pypath/a/__init__.py]
384+
x = 0
385+
x += '' # Error reported here
386+
[file pypath/a/py.typed]
387+
[out]
388+
pypath/a/__init__.py:2: error: Unsupported operand types for + ("int" and "str")
389+
main.py:3: error: Unsupported operand types for + ("int" and "str")
390+
main.py:6: error: Unsupported operand types for + ("int" and "str")
391+
main.py:7: error: Module has no attribute "y"
392+
main.py:8: error: Unsupported operand types for + (Module and "int")
393+
368394
[case testConfigFollowImportsSilent]
369395
# cmd: mypy main.py
370396
[file main.py]

0 commit comments

Comments
 (0)