Skip to content

Commit 80aee58

Browse files
emmatypingJukkaL
authored andcommitted
Implement PEP 561 (#4693)
This makes it possible to use inline types from installed packages and use installed stubs according to PEP 561. Adds `--python-executable` and `--no-site-packages` command-line options. PEP 561: https://www.python.org/dev/peps/pep-0561/ Fixes #2625, #1190, #965.
1 parent bba51e0 commit 80aee58

24 files changed

+573
-65
lines changed

docs/source/command_line.rst

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,11 +366,27 @@ Here are some more useful flags:
366366
updates the cache, but regular incremental mode ignores cache files
367367
written by quick mode.
368368

369+
- ``--python-executable EXECUTABLE`` will have mypy collect type information
370+
from `PEP 561`_ compliant packages installed for the Python executable
371+
``EXECUTABLE``. If not provided, mypy will use PEP 561 compliant packages
372+
installed for the Python executable running mypy. See
373+
:ref:`installed-packages` for more on making PEP 561 compliant packages.
374+
This flag will attempt to set ``--python-version`` if not already set.
375+
369376
- ``--python-version X.Y`` will make mypy typecheck your code as if it were
370377
run under Python version X.Y. Without this option, mypy will default to using
371378
whatever version of Python is running mypy. Note that the ``-2`` and
372379
``--py2`` flags are aliases for ``--python-version 2.7``. See
373-
:ref:`version_and_platform_checks` for more about this feature.
380+
:ref:`version_and_platform_checks` for more about this feature. This flag
381+
will attempt to find a Python executable of the corresponding version to
382+
search for `PEP 561`_ compliant packages. If you'd like to disable this,
383+
see ``--no-site-packages`` below.
384+
385+
- ``--no-site-packages`` will disable searching for `PEP 561`_ compliant
386+
packages. This will also disable searching for a usable Python executable.
387+
Use this flag if mypy cannot find a Python executable for the version of
388+
Python being checked, and you don't need to use PEP 561 typed packages.
389+
Otherwise, use ``--python-executable``.
374390

375391
- ``--platform PLATFORM`` will make mypy typecheck your code as if it were
376392
run under the the given operating system. Without this option, mypy will
@@ -459,6 +475,8 @@ For the remaining flags you can read the full ``mypy -h`` output.
459475

460476
Command line flags are liable to change between releases.
461477

478+
.. _PEP 561: https://www.python.org/dev/peps/pep-0561/
479+
462480
.. _integrating-mypy:
463481

464482
Integrating mypy into another Python application

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Mypy is a static type checker for Python.
3030
command_line
3131
config_file
3232
python36
33+
installed_packages
3334
faq
3435
cheat_sheet
3536
cheat_sheet_py3

docs/source/installed_packages.rst

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
.. _installed-packages:
2+
3+
Using Installed Packages
4+
========================
5+
6+
`PEP 561 <https://www.python.org/dev/peps/pep-0561/>`_ specifies how to mark
7+
a package as supporting type checking. Below is a summary of how to create
8+
PEP 561 compatible packages and have mypy use them in type checking.
9+
10+
Using PEP 561 compatible packages with mypy
11+
*******************************************
12+
13+
Generally, you do not need to do anything to use installed packages that
14+
support typing for the Python executable used to run mypy. Note that most
15+
packages do not support typing. Packages that do support typing should be
16+
automatically picked up by mypy and used for type checking.
17+
18+
By default, mypy searches for packages installed for the Python executable
19+
running mypy. It is highly unlikely you want this situation if you have
20+
installed typed packages in another Python's package directory.
21+
22+
Generally, you can use the ``--python-version`` flag and mypy will try to find
23+
the correct package directory. If that fails, you can use the
24+
``--python-executable`` flag to point to the exact executable, and mypy will
25+
find packages installed for that Python executable.
26+
27+
Note that mypy does not support some more advanced import features, such as zip
28+
imports, namespace packages, and custom import hooks.
29+
30+
If you do not want to use typed packages, use the ``--no-site-packages`` flag
31+
to disable searching.
32+
33+
Making PEP 561 compatible packages
34+
**********************************
35+
36+
PEP 561 notes three main ways to distribute type information. The first is a
37+
package that has only inline type annotations in the code itself. The second is
38+
a package that ships stub files with type information alongside the runtime
39+
code. The third method, also known as a "stub only package" is a package that
40+
ships type information for a package separately as stub files.
41+
42+
If you would like to publish a library package to a package repository (e.g.
43+
PyPI) for either internal or external use in type checking, packages that
44+
supply type information via type comments or annotations in the code should put
45+
a ``py.typed`` in their package directory. For example, with a directory
46+
structure as follows:
47+
48+
.. code-block:: text
49+
50+
setup.py
51+
package_a/
52+
__init__.py
53+
lib.py
54+
py.typed
55+
56+
the setup.py might look like:
57+
58+
.. code-block:: python
59+
60+
from distutils.core import setup
61+
62+
setup(
63+
name="SuperPackageA",
64+
author="Me",
65+
version="0.1",
66+
package_data={"package_a": ["py.typed"]},
67+
packages=["package_a"]
68+
)
69+
70+
Some packages have a mix of stub files and runtime files. These packages also
71+
require a ``py.typed`` file. An example can be seen below:
72+
73+
.. code-block:: text
74+
75+
setup.py
76+
package_b/
77+
__init__.py
78+
lib.py
79+
lib.pyi
80+
py.typed
81+
82+
the setup.py might look like:
83+
84+
.. code-block:: python
85+
86+
from distutils.core import setup
87+
88+
setup(
89+
name="SuperPackageB",
90+
author="Me",
91+
version="0.1",
92+
package_data={"package_b": ["py.typed", "lib.pyi"]},
93+
packages=["package_b"]
94+
)
95+
96+
In this example, both ``lib.py`` and ``lib.pyi`` exist. At runtime, the Python
97+
interpeter will use ``lib.py``, but mypy will use ``lib.pyi`` instead.
98+
99+
If the package is stub-only (not imported at runtime), the package should have
100+
a prefix of the runtime package name and a suffix of ``-stubs``.
101+
A ``py.typed`` file is not needed for stub-only packages. For example, if we
102+
had stubs for ``package_c``, we might do the following:
103+
104+
.. code-block:: text
105+
106+
setup.py
107+
package_c-stubs/
108+
__init__.pyi
109+
lib.pyi
110+
111+
the setup.py might look like:
112+
113+
.. code-block:: python
114+
115+
from distutils.core import setup
116+
117+
setup(
118+
name="SuperPackageC",
119+
author="Me",
120+
version="0.1",
121+
package_data={"package_c-stubs": ["__init__.pyi", "lib.pyi"]},
122+
packages=["package_c-stubs"]
123+
)

mypy/build.py

Lines changed: 75 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,20 @@
1010
"""
1111
# TODO: More consistent terminology, e.g. path/fnam, module/id, state/file
1212

13+
import ast
1314
import binascii
1415
import collections
1516
import contextlib
1617
from distutils.sysconfig import get_python_lib
18+
import functools
1719
import gc
1820
import hashlib
1921
import json
2022
import os.path
2123
import re
2224
import site
2325
import stat
26+
import subprocess
2427
import sys
2528
import time
2629
from os.path import dirname, basename
@@ -33,6 +36,7 @@
3336
if MYPY:
3437
from typing import Deque
3538

39+
from mypy import sitepkgs
3640
from mypy.nodes import (MODULE_REF, MypyFile, Node, ImportBase, Import, ImportFrom, ImportAll)
3741
from mypy.semanal_pass1 import SemanticAnalyzerPass1
3842
from mypy.semanal import SemanticAnalyzerPass2, apply_semantic_analyzer_patches
@@ -698,7 +702,8 @@ def correct_rel_imp(imp: Union[ImportFrom, ImportAll]) -> str:
698702

699703
def is_module(self, id: str) -> bool:
700704
"""Is there a file in the file system corresponding to module id?"""
701-
return self.find_module_cache.find_module(id, self.lib_path) is not None
705+
return self.find_module_cache.find_module(id, self.lib_path,
706+
self.options.python_executable) is not None
702707

703708
def parse_file(self, id: str, path: str, source: str, ignore_errors: bool) -> MypyFile:
704709
"""Parse the source of a file with the given name.
@@ -789,6 +794,24 @@ def remove_cwd_prefix_from_path(fscache: FileSystemCache, p: str) -> str:
789794
return p
790795

791796

797+
@functools.lru_cache(maxsize=None)
798+
def _get_site_packages_dirs(python_executable: Optional[str]) -> List[str]:
799+
"""Find package directories for given python.
800+
801+
This runs a subprocess call, which generates a list of the site package directories.
802+
To avoid repeatedly calling a subprocess (which can be slow!) we lru_cache the results."""
803+
if python_executable is None:
804+
return []
805+
if python_executable == sys.executable:
806+
# Use running Python's package dirs
807+
return sitepkgs.getsitepackages()
808+
else:
809+
# Use subprocess to get the package directory of given Python
810+
# executable
811+
return ast.literal_eval(subprocess.check_output([python_executable, sitepkgs.__file__],
812+
stderr=subprocess.PIPE).decode())
813+
814+
792815
class FindModuleCache:
793816
"""Module finder with integrated cache.
794817
@@ -802,20 +825,29 @@ class FindModuleCache:
802825

803826
def __init__(self, fscache: Optional[FileSystemMetaCache] = None) -> None:
804827
self.fscache = fscache or FileSystemMetaCache()
805-
# Cache find_module: (id, lib_path) -> result.
806-
self.results = {} # type: Dict[Tuple[str, Tuple[str, ...]], Optional[str]]
828+
self.find_lib_path_dirs = functools.lru_cache(maxsize=None)(self._find_lib_path_dirs)
829+
self.find_module = functools.lru_cache(maxsize=None)(self._find_module)
830+
831+
def clear(self) -> None:
832+
self.find_module.cache_clear()
833+
self.find_lib_path_dirs.cache_clear()
807834

835+
def _find_lib_path_dirs(self, dir_chain: str, lib_path: Tuple[str, ...]) -> List[str]:
808836
# Cache some repeated work within distinct find_module calls: finding which
809837
# elements of lib_path have even the subdirectory they'd need for the module
810838
# to exist. This is shared among different module ids when they differ only
811839
# in the last component.
812-
self.dirs = {} # type: Dict[Tuple[str, Tuple[str, ...]], List[str]]
813-
814-
def clear(self) -> None:
815-
self.results.clear()
816-
self.dirs.clear()
817-
818-
def _find_module(self, id: str, lib_path: Tuple[str, ...]) -> Optional[str]:
840+
dirs = []
841+
for pathitem in lib_path:
842+
# e.g., '/usr/lib/python3.4/foo/bar'
843+
dir = os.path.normpath(os.path.join(pathitem, dir_chain))
844+
if self.fscache.isdir(dir):
845+
dirs.append(dir)
846+
return dirs
847+
848+
def _find_module(self, id: str, lib_path: Tuple[str, ...],
849+
python_executable: Optional[str]) -> Optional[str]:
850+
"""Return the path of the module source file, or None if not found."""
819851
fscache = self.fscache
820852

821853
# If we're looking for a module like 'foo.bar.baz', it's likely that most of the
@@ -824,15 +856,23 @@ def _find_module(self, id: str, lib_path: Tuple[str, ...]) -> Optional[str]:
824856
# that will require the same subdirectory.
825857
components = id.split('.')
826858
dir_chain = os.sep.join(components[:-1]) # e.g., 'foo/bar'
827-
if (dir_chain, lib_path) not in self.dirs:
828-
dirs = []
829-
for pathitem in lib_path:
830-
# e.g., '/usr/lib/python3.4/foo/bar'
831-
dir = os.path.normpath(os.path.join(pathitem, dir_chain))
832-
if fscache.isdir(dir):
833-
dirs.append(dir)
834-
self.dirs[dir_chain, lib_path] = dirs
835-
candidate_base_dirs = self.dirs[dir_chain, lib_path]
859+
# TODO (ethanhs): refactor each path search to its own method with lru_cache
860+
861+
third_party_dirs = []
862+
# Third-party stub/typed packages
863+
for pkg_dir in _get_site_packages_dirs(python_executable):
864+
stub_name = components[0] + '-stubs'
865+
typed_file = os.path.join(pkg_dir, components[0], 'py.typed')
866+
stub_dir = os.path.join(pkg_dir, stub_name)
867+
if fscache.isdir(stub_dir):
868+
stub_components = [stub_name] + components[1:]
869+
path = os.path.join(pkg_dir, *stub_components[:-1])
870+
if fscache.isdir(path):
871+
third_party_dirs.append(path)
872+
elif fscache.isfile(typed_file):
873+
path = os.path.join(pkg_dir, dir_chain)
874+
third_party_dirs.append(path)
875+
candidate_base_dirs = self.find_lib_path_dirs(dir_chain, lib_path) + third_party_dirs
836876

837877
# If we're looking for a module like 'foo.bar.baz', then candidate_base_dirs now
838878
# contains just the subdirectories 'foo/bar' that actually exist under the
@@ -845,26 +885,21 @@ def _find_module(self, id: str, lib_path: Tuple[str, ...]) -> Optional[str]:
845885
# Prefer package over module, i.e. baz/__init__.py* over baz.py*.
846886
for extension in PYTHON_EXTENSIONS:
847887
path = base_path + sepinit + extension
888+
path_stubs = base_path + '-stubs' + sepinit + extension
848889
if fscache.isfile_case(path) and verify_module(fscache, id, path):
849890
return path
891+
elif fscache.isfile_case(path_stubs) and verify_module(fscache, id, path_stubs):
892+
return path_stubs
850893
# No package, look for module.
851894
for extension in PYTHON_EXTENSIONS:
852895
path = base_path + extension
853896
if fscache.isfile_case(path) and verify_module(fscache, id, path):
854897
return path
855898
return None
856899

857-
def find_module(self, id: str, lib_path_arg: Iterable[str]) -> Optional[str]:
858-
"""Return the path of the module source file, or None if not found."""
859-
lib_path = tuple(lib_path_arg)
860-
861-
key = (id, lib_path)
862-
if key not in self.results:
863-
self.results[key] = self._find_module(id, lib_path)
864-
return self.results[key]
865-
866-
def find_modules_recursive(self, module: str, lib_path: List[str]) -> List[BuildSource]:
867-
module_path = self.find_module(module, lib_path)
900+
def find_modules_recursive(self, module: str, lib_path: Tuple[str, ...],
901+
python_executable: Optional[str]) -> List[BuildSource]:
902+
module_path = self.find_module(module, lib_path, python_executable)
868903
if not module_path:
869904
return []
870905
result = [BuildSource(module_path, module, None)]
@@ -884,13 +919,15 @@ def find_modules_recursive(self, module: str, lib_path: List[str]) -> List[Build
884919
(os.path.isfile(os.path.join(abs_path, '__init__.py')) or
885920
os.path.isfile(os.path.join(abs_path, '__init__.pyi'))):
886921
hits.add(item)
887-
result += self.find_modules_recursive(module + '.' + item, lib_path)
922+
result += self.find_modules_recursive(module + '.' + item, lib_path,
923+
python_executable)
888924
elif item != '__init__.py' and item != '__init__.pyi' and \
889925
item.endswith(('.py', '.pyi')):
890926
mod = item.split('.')[0]
891927
if mod not in hits:
892928
hits.add(mod)
893-
result += self.find_modules_recursive(module + '.' + mod, lib_path)
929+
result += self.find_modules_recursive(module + '.' + mod, lib_path,
930+
python_executable)
894931
return result
895932

896933

@@ -2001,7 +2038,8 @@ def find_module_and_diagnose(manager: BuildManager,
20012038
# difference and just assume 'builtins' everywhere,
20022039
# which simplifies code.
20032040
file_id = '__builtin__'
2004-
path = manager.find_module_cache.find_module(file_id, manager.lib_path)
2041+
path = manager.find_module_cache.find_module(file_id, manager.lib_path,
2042+
manager.options.python_executable)
20052043
if path:
20062044
# For non-stubs, look at options.follow_imports:
20072045
# - normal (default) -> fully analyze
@@ -2125,12 +2163,14 @@ def dispatch(sources: List[BuildSource], manager: BuildManager) -> Graph:
21252163
graph = load_graph(sources, manager)
21262164

21272165
t1 = time.time()
2166+
fm_cache_size = manager.find_module_cache.find_module.cache_info().currsize
2167+
fm_dir_cache_size = manager.find_module_cache.find_lib_path_dirs.cache_info().currsize
21282168
manager.add_stats(graph_size=len(graph),
21292169
stubs_found=sum(g.path is not None and g.path.endswith('.pyi')
21302170
for g in graph.values()),
21312171
graph_load_time=(t1 - t0),
2132-
fm_cache_size=len(manager.find_module_cache.results),
2133-
fm_dir_cache_size=len(manager.find_module_cache.dirs),
2172+
fm_cache_size=fm_cache_size,
2173+
fm_dir_cache_size=fm_dir_cache_size,
21342174
)
21352175
if not graph:
21362176
print("Nothing to do?!")

0 commit comments

Comments
 (0)