Skip to content

Commit 30177ca

Browse files
author
hauntsaninja
committed
better crawling for namespace packages, explicit base dirs, abs paths
1 parent ccb5e94 commit 30177ca

File tree

5 files changed

+350
-96
lines changed

5 files changed

+350
-96
lines changed

mypy/find_sources.py

+113-84
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Routines for finding the sources that mypy will check"""
22

3-
import os.path
3+
import functools
4+
import os
45

5-
from typing import List, Sequence, Set, Tuple, Optional, Dict
6+
from typing import List, Sequence, Set, Tuple, Optional
67
from typing_extensions import Final
78

8-
from mypy.modulefinder import BuildSource, PYTHON_EXTENSIONS
9+
from mypy.modulefinder import BuildSource, PYTHON_EXTENSIONS, mypy_path
910
from mypy.fscache import FileSystemCache
1011
from mypy.options import Options
1112

@@ -24,7 +25,7 @@ def create_source_list(paths: Sequence[str], options: Options,
2425
Raises InvalidSourceList on errors.
2526
"""
2627
fscache = fscache or FileSystemCache()
27-
finder = SourceFinder(fscache)
28+
finder = SourceFinder(fscache, options)
2829

2930
sources = []
3031
for path in paths:
@@ -34,7 +35,7 @@ def create_source_list(paths: Sequence[str], options: Options,
3435
name, base_dir = finder.crawl_up(path)
3536
sources.append(BuildSource(path, name, None, base_dir))
3637
elif fscache.isdir(path):
37-
sub_sources = finder.find_sources_in_dir(path, explicit_package_roots=None)
38+
sub_sources = finder.find_sources_in_dir(path)
3839
if not sub_sources and not allow_empty_dir:
3940
raise InvalidSourceList(
4041
"There are no .py[i] files in directory '{}'".format(path)
@@ -58,112 +59,141 @@ def keyfunc(name: str) -> Tuple[int, str]:
5859
return (-1, name)
5960

6061

62+
def normalise_package_base(root: str) -> str:
63+
if not root:
64+
root = os.curdir
65+
root = os.path.normpath(os.path.abspath(root))
66+
if root.endswith(os.sep):
67+
root = root[:-1]
68+
return root
69+
70+
71+
def get_explicit_package_bases(options: Options) -> Optional[List[str]]:
72+
if not options.explicit_package_bases:
73+
return None
74+
roots = mypy_path() + options.mypy_path + [os.getcwd()]
75+
return [normalise_package_base(root) for root in roots]
76+
77+
6178
class SourceFinder:
62-
def __init__(self, fscache: FileSystemCache) -> None:
79+
def __init__(self, fscache: FileSystemCache, options: Options) -> None:
6380
self.fscache = fscache
64-
# A cache for package names, mapping from directory path to module id and base dir
65-
self.package_cache = {} # type: Dict[str, Tuple[str, str]]
66-
67-
def find_sources_in_dir(
68-
self, path: str, explicit_package_roots: Optional[List[str]]
69-
) -> List[BuildSource]:
70-
if explicit_package_roots is None:
71-
mod_prefix, root_dir = self.crawl_up_dir(path)
72-
else:
73-
mod_prefix = os.path.basename(path)
74-
root_dir = os.path.dirname(path) or "."
75-
if mod_prefix:
76-
mod_prefix += "."
77-
return self.find_sources_in_dir_helper(path, mod_prefix, root_dir, explicit_package_roots)
78-
79-
def find_sources_in_dir_helper(
80-
self, dir_path: str, mod_prefix: str, root_dir: str,
81-
explicit_package_roots: Optional[List[str]]
82-
) -> List[BuildSource]:
83-
assert not mod_prefix or mod_prefix.endswith(".")
84-
85-
init_file = self.get_init_file(dir_path)
86-
# If the current directory is an explicit package root, explore it as such.
87-
# Alternatively, if we aren't given explicit package roots and we don't have an __init__
88-
# file, recursively explore this directory as a new package root.
89-
if (
90-
(explicit_package_roots is not None and dir_path in explicit_package_roots)
91-
or (explicit_package_roots is None and init_file is None)
92-
):
93-
mod_prefix = ""
94-
root_dir = dir_path
81+
self.explicit_package_bases = get_explicit_package_bases(options)
82+
self.namespace_packages = options.namespace_packages
9583

96-
seen = set() # type: Set[str]
97-
sources = []
84+
def is_explicit_package_base(self, path: str) -> bool:
85+
assert self.explicit_package_bases
86+
return normalise_package_base(path) in self.explicit_package_bases
9887

99-
if init_file:
100-
sources.append(BuildSource(init_file, mod_prefix.rstrip("."), None, root_dir))
88+
def find_sources_in_dir(self, path: str) -> List[BuildSource]:
89+
sources = []
10190

102-
names = self.fscache.listdir(dir_path)
103-
names.sort(key=keyfunc)
91+
seen = set() # type: Set[str]
92+
names = sorted(self.fscache.listdir(path), key=keyfunc)
10493
for name in names:
10594
# Skip certain names altogether
10695
if name == '__pycache__' or name.startswith('.') or name.endswith('~'):
10796
continue
108-
path = os.path.join(dir_path, name)
97+
subpath = os.path.join(path, name)
10998

110-
if self.fscache.isdir(path):
111-
sub_sources = self.find_sources_in_dir_helper(
112-
path, mod_prefix + name + '.', root_dir, explicit_package_roots
113-
)
99+
if self.fscache.isdir(subpath):
100+
sub_sources = self.find_sources_in_dir(subpath)
114101
if sub_sources:
115102
seen.add(name)
116103
sources.extend(sub_sources)
117104
else:
118105
stem, suffix = os.path.splitext(name)
119-
if stem == '__init__':
120-
continue
121-
if stem not in seen and '.' not in stem and suffix in PY_EXTENSIONS:
106+
if stem not in seen and suffix in PY_EXTENSIONS:
122107
seen.add(stem)
123-
src = BuildSource(path, mod_prefix + stem, None, root_dir)
124-
sources.append(src)
108+
module, base_dir = self.crawl_up(subpath)
109+
sources.append(BuildSource(subpath, module, None, base_dir))
125110

126111
return sources
127112

128113
def crawl_up(self, path: str) -> Tuple[str, str]:
129-
"""Given a .py[i] filename, return module and base directory
114+
"""Given a .py[i] filename, return module and base directory.
130115
131-
We crawl up the path until we find a directory without
132-
__init__.py[i], or until we run out of path components.
116+
For example, given "xxx/yyy/foo/bar.py", we might return something like:
117+
("foo.bar", "xxx/yyy")
118+
119+
If namespace packages is off, we crawl upwards until we find a directory without
120+
an __init__.py
121+
122+
If namespace packages is on, we crawl upwards until the nearest explicit base directory.
123+
Failing that, we return one past the highest directory containing an __init__.py
124+
125+
We won't crawl past directories with invalid package names.
126+
The base directory returned is an absolute path.
133127
"""
128+
path = os.path.normpath(os.path.abspath(path))
134129
parent, filename = os.path.split(path)
135-
module_name = strip_py(filename) or os.path.basename(filename)
136-
module_prefix, base_dir = self.crawl_up_dir(parent)
137-
if module_name == '__init__' or not module_name:
138-
module = module_prefix
139-
else:
140-
module = module_join(module_prefix, module_name)
141130

131+
module_name = strip_py(filename) or filename
132+
if not module_name.isidentifier():
133+
return module_name, parent
134+
135+
parent_module, base_dir = self.crawl_up_dir(parent)
136+
if module_name == "__init__":
137+
return parent_module, base_dir
138+
139+
module = module_join(parent_module, module_name)
142140
return module, base_dir
143141

144142
def crawl_up_dir(self, dir: str) -> Tuple[str, str]:
145-
"""Given a directory name, return the corresponding module name and base directory
143+
return self._crawl_up_helper(dir) or ("", dir)
146144

147-
Use package_cache to cache results.
148-
"""
149-
if dir in self.package_cache:
150-
return self.package_cache[dir]
145+
@functools.lru_cache()
146+
def _crawl_up_helper(self, dir: str) -> Optional[Tuple[str, str]]:
147+
"""Given a directory, maybe returns module and base directory.
151148
152-
parent_dir, base = os.path.split(dir)
153-
if not dir or not self.get_init_file(dir) or not base:
154-
module = ''
155-
base_dir = dir or '.'
156-
else:
157-
# Ensure that base is a valid python module name
158-
if base.endswith('-stubs'):
159-
base = base[:-6] # PEP-561 stub-only directory
160-
if not base.isidentifier():
161-
raise InvalidSourceList('{} is not a valid Python package name'.format(base))
162-
parent_module, base_dir = self.crawl_up_dir(parent_dir)
163-
module = module_join(parent_module, base)
164-
165-
self.package_cache[dir] = module, base_dir
166-
return module, base_dir
149+
We return a non-None value if we were able to find something clearly intended as a base
150+
directory (as adjudicated by being an explicit base directory or by containing a package
151+
with __init__.py).
152+
153+
This distinction is necessary for namespace packages, so that we know when to treat
154+
ourselves as a subpackage.
155+
"""
156+
# stop crawling if we're an explicit base directory
157+
if self.explicit_package_bases is not None and self.is_explicit_package_base(dir):
158+
return "", dir
159+
160+
# stop crawling if we've exhausted path components
161+
parent, name = os.path.split(dir)
162+
if not name or not parent:
163+
return None
164+
if name.endswith('-stubs'):
165+
name = name[:-6] # PEP-561 stub-only directory
166+
167+
# recurse if there's an __init__.py
168+
init_file = self.get_init_file(dir)
169+
if init_file is not None:
170+
if not name.isidentifier():
171+
# in most cases the directory name is invalid, we'll just stop crawling upwards
172+
# but if there's an __init__.py in the directory, something is messed up
173+
raise InvalidSourceList("{} is not a valid Python package name".format(name))
174+
# we're definitely a package, so we always return a non-None value
175+
mod_prefix, base_dir = self.crawl_up_dir(parent)
176+
return module_join(mod_prefix, name), base_dir
177+
178+
# stop crawling if our name is an invalid identifier
179+
if not name.isidentifier():
180+
return None
181+
182+
# stop crawling if namespace packages is off (and we don't have an __init__.py)
183+
if not self.namespace_packages:
184+
return None
185+
186+
# at this point: namespace packages is on, we don't have an __init__.py and we're not an
187+
# explicit base directory
188+
result = self._crawl_up_helper(parent)
189+
if result is None:
190+
# we're not an explicit base directory and we don't have an __init__.py
191+
# and none of our parents are either, so return
192+
return None
193+
# one of our parents was an explicit base directory or had an __init__.py, so we're
194+
# definitely a subpackage! chain our name to the module.
195+
mod_prefix, base_dir = result
196+
return module_join(mod_prefix, name), base_dir
167197

168198
def get_init_file(self, dir: str) -> Optional[str]:
169199
"""Check whether a directory contains a file named __init__.py[i].
@@ -185,8 +215,7 @@ def module_join(parent: str, child: str) -> str:
185215
"""Join module ids, accounting for a possibly empty parent."""
186216
if parent:
187217
return parent + '.' + child
188-
else:
189-
return child
218+
return child
190219

191220

192221
def strip_py(arg: str) -> Optional[str]:

mypy/main.py

+15-11
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,9 @@ def add_invertible_flag(flag: str,
786786
title="Running code",
787787
description="Specify the code you want to type check. For more details, see "
788788
"mypy.readthedocs.io/en/latest/running_mypy.html#running-mypy")
789+
code_group.add_argument(
790+
'--explicit-package-bases', action='store_true',
791+
help="Use current directory and MYPYPATH to determine module names of files passed")
789792
code_group.add_argument(
790793
'-m', '--module', action='append', metavar='MODULE',
791794
default=[],
@@ -862,6 +865,11 @@ def set_strict_flags() -> None:
862865
parser.error("Missing target module, package, files, or command.")
863866
elif code_methods > 1:
864867
parser.error("May only specify one of: module/package, files, or command.")
868+
if options.explicit_package_bases and not options.namespace_packages:
869+
parser.error(
870+
"Can only use --explicit-base-dirs with --namespace-packages, since otherwise "
871+
"examining __init__.py's is sufficient to determine module names for files"
872+
)
865873

866874
# Check for overlapping `--always-true` and `--always-false` flags.
867875
overlap = set(options.always_true) & set(options.always_false)
@@ -966,10 +974,7 @@ def process_package_roots(fscache: Optional[FileSystemCache],
966974
assert fscache is not None # Since mypy doesn't know parser.error() raises.
967975
# Do some stuff with drive letters to make Windows happy (esp. tests).
968976
current_drive, _ = os.path.splitdrive(os.getcwd())
969-
dot = os.curdir
970-
dotslash = os.curdir + os.sep
971977
dotdotslash = os.pardir + os.sep
972-
trivial_paths = {dot, dotslash}
973978
package_root = []
974979
for root in options.package_root:
975980
if os.path.isabs(root):
@@ -978,14 +983,13 @@ def process_package_roots(fscache: Optional[FileSystemCache],
978983
if drive and drive != current_drive:
979984
parser.error("Package root must be on current drive: %r" % (drive + root))
980985
# Empty package root is always okay.
981-
if root:
982-
root = os.path.relpath(root) # Normalize the heck out of it.
983-
if root.startswith(dotdotslash):
984-
parser.error("Package root cannot be above current directory: %r" % root)
985-
if root in trivial_paths:
986-
root = ''
987-
elif not root.endswith(os.sep):
988-
root = root + os.sep
986+
if not root:
987+
root = os.curdir
988+
if os.path.relpath(root).startswith(dotdotslash):
989+
parser.error("Package root cannot be above current directory: %r" % root)
990+
root = os.path.normpath(os.path.abspath(root))
991+
if not root.endswith(os.sep):
992+
root += os.sep
989993
package_root.append(root)
990994
options.package_root = package_root
991995
# Pass the package root on the the filesystem cache.

mypy/options.py

+1
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def __init__(self) -> None:
8888
self.follow_imports_for_stubs = False
8989
# PEP 420 namespace packages
9090
self.namespace_packages = False
91+
self.explicit_package_bases = False
9192

9293
# disallow_any options
9394
self.disallow_any_generics = False

mypy/suggestions.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def __init__(self, fgmanager: FineGrainedBuildManager,
220220
self.manager = fgmanager.manager
221221
self.plugin = self.manager.plugin
222222
self.graph = fgmanager.graph
223-
self.finder = SourceFinder(self.manager.fscache)
223+
self.finder = SourceFinder(self.manager.fscache, self.manager.options)
224224

225225
self.give_json = json
226226
self.no_errors = no_errors

0 commit comments

Comments
 (0)