Skip to content

Commit 0fb671c

Browse files
authored
find_sources: find build sources recursively (#9614)
Fixes #8548 This is important to fix because it's a really common source of surprising false negatives. I also lay some groundwork for supporting passing in namespace packages on the command line. The approach towards namespace packages that this anticipates is that if you pass in files to mypy and you want mypy to understand your namespace packages, you'll need to specify explicit package roots. We also make many fewer calls to crawl functions, since we just pass around what we need. Co-authored-by: hauntsaninja <>
1 parent af3c8be commit 0fb671c

File tree

2 files changed

+97
-53
lines changed

2 files changed

+97
-53
lines changed

mypy/find_sources.py

+77-51
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class InvalidSourceList(Exception):
1616
"""Exception indicating a problem in the list of sources given to mypy."""
1717

1818

19-
def create_source_list(files: Sequence[str], options: Options,
19+
def create_source_list(paths: Sequence[str], options: Options,
2020
fscache: Optional[FileSystemCache] = None,
2121
allow_empty_dir: bool = False) -> List[BuildSource]:
2222
"""From a list of source files/directories, makes a list of BuildSources.
@@ -26,22 +26,24 @@ def create_source_list(files: Sequence[str], options: Options,
2626
fscache = fscache or FileSystemCache()
2727
finder = SourceFinder(fscache)
2828

29-
targets = []
30-
for f in files:
31-
if f.endswith(PY_EXTENSIONS):
29+
sources = []
30+
for path in paths:
31+
path = os.path.normpath(path)
32+
if path.endswith(PY_EXTENSIONS):
3233
# Can raise InvalidSourceList if a directory doesn't have a valid module name.
33-
name, base_dir = finder.crawl_up(os.path.normpath(f))
34-
targets.append(BuildSource(f, name, None, base_dir))
35-
elif fscache.isdir(f):
36-
sub_targets = finder.expand_dir(os.path.normpath(f))
37-
if not sub_targets and not allow_empty_dir:
38-
raise InvalidSourceList("There are no .py[i] files in directory '{}'"
39-
.format(f))
40-
targets.extend(sub_targets)
34+
name, base_dir = finder.crawl_up(path)
35+
sources.append(BuildSource(path, name, None, base_dir))
36+
elif fscache.isdir(path):
37+
sub_sources = finder.find_sources_in_dir(path, explicit_package_roots=None)
38+
if not sub_sources and not allow_empty_dir:
39+
raise InvalidSourceList(
40+
"There are no .py[i] files in directory '{}'".format(path)
41+
)
42+
sources.extend(sub_sources)
4143
else:
42-
mod = os.path.basename(f) if options.scripts_are_modules else None
43-
targets.append(BuildSource(f, mod, None))
44-
return targets
44+
mod = os.path.basename(path) if options.scripts_are_modules else None
45+
sources.append(BuildSource(path, mod, None))
46+
return sources
4547

4648

4749
def keyfunc(name: str) -> Tuple[int, str]:
@@ -62,57 +64,82 @@ def __init__(self, fscache: FileSystemCache) -> None:
6264
# A cache for package names, mapping from directory path to module id and base dir
6365
self.package_cache = {} # type: Dict[str, Tuple[str, str]]
6466

65-
def expand_dir(self, arg: str, mod_prefix: str = '') -> List[BuildSource]:
66-
"""Convert a directory name to a list of sources to build."""
67-
f = self.get_init_file(arg)
68-
if mod_prefix and not f:
69-
return []
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
95+
7096
seen = set() # type: Set[str]
7197
sources = []
72-
top_mod, base_dir = self.crawl_up_dir(arg)
73-
if f and not mod_prefix:
74-
mod_prefix = top_mod + '.'
75-
if mod_prefix:
76-
sources.append(BuildSource(f, mod_prefix.rstrip('.'), None, base_dir))
77-
names = self.fscache.listdir(arg)
98+
99+
if init_file:
100+
sources.append(BuildSource(init_file, mod_prefix.rstrip("."), None, root_dir))
101+
102+
names = self.fscache.listdir(dir_path)
78103
names.sort(key=keyfunc)
79104
for name in names:
80105
# Skip certain names altogether
81-
if (name == '__pycache__' or name == 'py.typed'
82-
or name.startswith('.')
83-
or name.endswith(('~', '.pyc', '.pyo'))):
106+
if name == '__pycache__' or name.startswith('.') or name.endswith('~'):
84107
continue
85-
path = os.path.join(arg, name)
108+
path = os.path.join(dir_path, name)
109+
86110
if self.fscache.isdir(path):
87-
sub_sources = self.expand_dir(path, mod_prefix + name + '.')
111+
sub_sources = self.find_sources_in_dir_helper(
112+
path, mod_prefix + name + '.', root_dir, explicit_package_roots
113+
)
88114
if sub_sources:
89115
seen.add(name)
90116
sources.extend(sub_sources)
91117
else:
92-
base, suffix = os.path.splitext(name)
93-
if base == '__init__':
118+
stem, suffix = os.path.splitext(name)
119+
if stem == '__init__':
94120
continue
95-
if base not in seen and '.' not in base and suffix in PY_EXTENSIONS:
96-
seen.add(base)
97-
src = BuildSource(path, mod_prefix + base, None, base_dir)
121+
if stem not in seen and '.' not in stem and suffix in PY_EXTENSIONS:
122+
seen.add(stem)
123+
src = BuildSource(path, mod_prefix + stem, None, root_dir)
98124
sources.append(src)
125+
99126
return sources
100127

101-
def crawl_up(self, arg: str) -> Tuple[str, str]:
128+
def crawl_up(self, path: str) -> Tuple[str, str]:
102129
"""Given a .py[i] filename, return module and base directory
103130
104131
We crawl up the path until we find a directory without
105132
__init__.py[i], or until we run out of path components.
106133
"""
107-
dir, mod = os.path.split(arg)
108-
mod = strip_py(mod) or mod
109-
base, base_dir = self.crawl_up_dir(dir)
110-
if mod == '__init__' or not mod:
111-
mod = base
134+
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
112139
else:
113-
mod = module_join(base, mod)
140+
module = module_join(module_prefix, module_name)
114141

115-
return mod, base_dir
142+
return module, base_dir
116143

117144
def crawl_up_dir(self, dir: str) -> Tuple[str, str]:
118145
"""Given a directory name, return the corresponding module name and base directory
@@ -124,25 +151,24 @@ def crawl_up_dir(self, dir: str) -> Tuple[str, str]:
124151

125152
parent_dir, base = os.path.split(dir)
126153
if not dir or not self.get_init_file(dir) or not base:
127-
res = ''
154+
module = ''
128155
base_dir = dir or '.'
129156
else:
130157
# Ensure that base is a valid python module name
131158
if base.endswith('-stubs'):
132159
base = base[:-6] # PEP-561 stub-only directory
133160
if not base.isidentifier():
134161
raise InvalidSourceList('{} is not a valid Python package name'.format(base))
135-
parent, base_dir = self.crawl_up_dir(parent_dir)
136-
res = module_join(parent, base)
162+
parent_module, base_dir = self.crawl_up_dir(parent_dir)
163+
module = module_join(parent_module, base)
137164

138-
self.package_cache[dir] = res, base_dir
139-
return res, base_dir
165+
self.package_cache[dir] = module, base_dir
166+
return module, base_dir
140167

141168
def get_init_file(self, dir: str) -> Optional[str]:
142169
"""Check whether a directory contains a file named __init__.py[i].
143170
144-
If so, return the file's name (with dir prefixed). If not, return
145-
None.
171+
If so, return the file's name (with dir prefixed). If not, return None.
146172
147173
This prefers .pyi over .py (because of the ordering of PY_EXTENSIONS).
148174
"""

test-data/unit/cmdline.test

+20-2
Original file line numberDiff line numberDiff line change
@@ -45,29 +45,47 @@ pkg/subpkg/a.py:1: error: Name 'undef' is not defined
4545
# cmd: mypy dir
4646
[file dir/a.py]
4747
undef
48-
[file dir/subdir/a.py]
48+
[file dir/subdir/b.py]
4949
undef
5050
[out]
5151
dir/a.py:1: error: Name 'undef' is not defined
52+
dir/subdir/b.py:1: error: Name 'undef' is not defined
53+
54+
[case testCmdlineNonPackageDuplicate]
55+
# cmd: mypy dir
56+
[file dir/a.py]
57+
undef
58+
[file dir/subdir/a.py]
59+
undef
60+
[out]
61+
dir/a.py: error: Duplicate module named 'a' (also at 'dir/subdir/a.py')
62+
dir/a.py: error: Are you missing an __init__.py?
63+
== Return code: 2
5264

5365
[case testCmdlineNonPackageSlash]
5466
# cmd: mypy dir/
5567
[file dir/a.py]
5668
undef
57-
[file dir/subdir/a.py]
69+
import b
70+
[file dir/subdir/b.py]
5871
undef
72+
import a
5973
[out]
6074
dir/a.py:1: error: Name 'undef' is not defined
75+
dir/subdir/b.py:1: error: Name 'undef' is not defined
6176

6277
[case testCmdlinePackageContainingSubdir]
6378
# cmd: mypy pkg
6479
[file pkg/__init__.py]
6580
[file pkg/a.py]
6681
undef
82+
import a
6783
[file pkg/subdir/a.py]
6884
undef
85+
import pkg.a
6986
[out]
7087
pkg/a.py:1: error: Name 'undef' is not defined
88+
pkg/subdir/a.py:1: error: Name 'undef' is not defined
7189

7290
[case testCmdlineNonPackageContainingPackage]
7391
# cmd: mypy dir

0 commit comments

Comments
 (0)