Skip to content

Commit a26d8d0

Browse files
authored
Add an option to exclude everything in .gitignore (#18696)
Fixes #12505 This is (somewhat surprisingly) one of the most upvoted issues, and looks like a simple thing to add. I essentially do what other tools do, but optimize for how we work with sources discovery (to avoid performance issues). I am making this opt-in for now, we can change this later if needed.
1 parent 972bad2 commit a26d8d0

10 files changed

+100
-2
lines changed

docs/source/command_line.rst

+4
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ for full details, see :ref:`running-mypy`.
8181
never recursively discover files with extensions other than ``.py`` or
8282
``.pyi``.
8383

84+
.. option:: --exclude-gitignore
85+
86+
This flag will add everything that matches ``.gitignore`` file(s) to :option:`--exclude`.
87+
8488

8589
Optional arguments
8690
******************

docs/source/config_file.rst

+8
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,14 @@ section of the command line docs.
288288
289289
See :ref:`using-a-pyproject-toml`.
290290

291+
.. confval:: exclude_gitignore
292+
293+
:type: boolean
294+
:default: False
295+
296+
This flag will add everything that matches ``.gitignore`` file(s) to :confval:`exclude`.
297+
This option may only be set in the global section (``[mypy]``).
298+
291299
.. confval:: namespace_packages
292300

293301
:type: boolean

mypy-requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
# and the pins in setup.py
33
typing_extensions>=4.6.0
44
mypy_extensions>=1.0.0
5+
pathspec>=0.9.0
56
tomli>=1.1.0; python_version<'3.11'

mypy/find_sources.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
from typing import Final
99

1010
from mypy.fscache import FileSystemCache
11-
from mypy.modulefinder import PYTHON_EXTENSIONS, BuildSource, matches_exclude, mypy_path
11+
from mypy.modulefinder import (
12+
PYTHON_EXTENSIONS,
13+
BuildSource,
14+
matches_exclude,
15+
matches_gitignore,
16+
mypy_path,
17+
)
1218
from mypy.options import Options
1319

1420
PY_EXTENSIONS: Final = tuple(PYTHON_EXTENSIONS)
@@ -94,6 +100,7 @@ def __init__(self, fscache: FileSystemCache, options: Options) -> None:
94100
self.explicit_package_bases = get_explicit_package_bases(options)
95101
self.namespace_packages = options.namespace_packages
96102
self.exclude = options.exclude
103+
self.exclude_gitignore = options.exclude_gitignore
97104
self.verbosity = options.verbosity
98105

99106
def is_explicit_package_base(self, path: str) -> bool:
@@ -113,6 +120,10 @@ def find_sources_in_dir(self, path: str) -> list[BuildSource]:
113120

114121
if matches_exclude(subpath, self.exclude, self.fscache, self.verbosity >= 2):
115122
continue
123+
if self.exclude_gitignore and matches_gitignore(
124+
subpath, self.fscache, self.verbosity >= 2
125+
):
126+
continue
116127

117128
if self.fscache.isdir(subpath):
118129
sub_sources = self.find_sources_in_dir(subpath)

mypy/main.py

+9
Original file line numberDiff line numberDiff line change
@@ -1252,6 +1252,15 @@ def add_invertible_flag(
12521252
"May be specified more than once, eg. --exclude a --exclude b"
12531253
),
12541254
)
1255+
add_invertible_flag(
1256+
"--exclude-gitignore",
1257+
default=False,
1258+
help=(
1259+
"Use .gitignore file(s) to exclude files from checking "
1260+
"(in addition to any explicit --exclude if present)"
1261+
),
1262+
group=code_group,
1263+
)
12551264
code_group.add_argument(
12561265
"-m",
12571266
"--module",

mypy/modulefinder.py

+45
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
from typing import Final, Optional, Union
1717
from typing_extensions import TypeAlias as _TypeAlias
1818

19+
from pathspec import PathSpec
20+
from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
21+
1922
from mypy import pyinfo
2023
from mypy.errors import CompileError
2124
from mypy.fscache import FileSystemCache
@@ -625,6 +628,12 @@ def find_modules_recursive(self, module: str) -> list[BuildSource]:
625628
subpath, self.options.exclude, self.fscache, self.options.verbosity >= 2
626629
):
627630
continue
631+
if (
632+
self.options
633+
and self.options.exclude_gitignore
634+
and matches_gitignore(subpath, self.fscache, self.options.verbosity >= 2)
635+
):
636+
continue
628637

629638
if self.fscache.isdir(subpath):
630639
# Only recurse into packages
@@ -664,6 +673,42 @@ def matches_exclude(
664673
return False
665674

666675

676+
def matches_gitignore(subpath: str, fscache: FileSystemCache, verbose: bool) -> bool:
677+
dir, _ = os.path.split(subpath)
678+
for gi_path, gi_spec in find_gitignores(dir):
679+
relative_path = os.path.relpath(subpath, gi_path)
680+
if fscache.isdir(relative_path):
681+
relative_path = relative_path + "/"
682+
if gi_spec.match_file(relative_path):
683+
if verbose:
684+
print(
685+
f"TRACE: Excluding {relative_path} (matches .gitignore) in {gi_path}",
686+
file=sys.stderr,
687+
)
688+
return True
689+
return False
690+
691+
692+
@functools.lru_cache
693+
def find_gitignores(dir: str) -> list[tuple[str, PathSpec]]:
694+
parent_dir = os.path.dirname(dir)
695+
if parent_dir == dir:
696+
parent_gitignores = []
697+
else:
698+
parent_gitignores = find_gitignores(parent_dir)
699+
700+
gitignore = os.path.join(dir, ".gitignore")
701+
if os.path.isfile(gitignore):
702+
with open(gitignore) as f:
703+
lines = f.readlines()
704+
try:
705+
return parent_gitignores + [(dir, PathSpec.from_lines("gitwildmatch", lines))]
706+
except GitWildMatchPatternError:
707+
print(f"error: could not parse {gitignore}", file=sys.stderr)
708+
return parent_gitignores
709+
return parent_gitignores
710+
711+
667712
def is_init_file(path: str) -> bool:
668713
return os.path.basename(path) in ("__init__.py", "__init__.pyi")
669714

mypy/options.py

+1
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def __init__(self) -> None:
136136
self.explicit_package_bases = False
137137
# File names, directory names or subpaths to avoid checking
138138
self.exclude: list[str] = []
139+
self.exclude_gitignore: bool = False
139140

140141
# disallow_any options
141142
self.disallow_any_generics = False

pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ requires = [
77
# the following is from mypy-requirements.txt/setup.py
88
"typing_extensions>=4.6.0",
99
"mypy_extensions>=1.0.0",
10+
"pathspec>=0.9.0",
1011
"tomli>=1.1.0; python_version<'3.11'",
1112
# the following is from build-requirements.txt
1213
"types-psutil",
@@ -49,6 +50,7 @@ dependencies = [
4950
# When changing this, also update build-system.requires and mypy-requirements.txt
5051
"typing_extensions>=4.6.0",
5152
"mypy_extensions>=1.0.0",
53+
"pathspec>=0.9.0",
5254
"tomli>=1.1.0; python_version<'3.11'",
5355
]
5456
dynamic = ["version"]

test-data/unit/cmdline.test

+15
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,21 @@ b/bpkg.py:1: error: "int" not callable
11351135
[out]
11361136
c/cpkg.py:1: error: "int" not callable
11371137

1138+
[case testCmdlineExcludeGitignore]
1139+
# cmd: mypy --exclude-gitignore .
1140+
[file .gitignore]
1141+
abc
1142+
[file abc/apkg.py]
1143+
1()
1144+
[file b/.gitignore]
1145+
bpkg.*
1146+
[file b/bpkg.py]
1147+
1()
1148+
[file c/cpkg.py]
1149+
1()
1150+
[out]
1151+
c/cpkg.py:1: error: "int" not callable
1152+
11381153
[case testCmdlineCfgExclude]
11391154
# cmd: mypy .
11401155
[file mypy.ini]

test-requirements.txt

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.11
2+
# This file is autogenerated by pip-compile with Python 3.12
33
# by the following command:
44
#
55
# pip-compile --allow-unsafe --output-file=test-requirements.txt --strip-extras test-requirements.in
@@ -30,6 +30,8 @@ nodeenv==1.9.1
3030
# via pre-commit
3131
packaging==24.2
3232
# via pytest
33+
pathspec==0.12.1
34+
# via -r mypy-requirements.txt
3335
platformdirs==4.3.6
3436
# via virtualenv
3537
pluggy==1.5.0

0 commit comments

Comments
 (0)