diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index 36c13910c21a..b0820796ca35 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -66,7 +66,8 @@ for full details, see :ref:`running-mypy`. is, when mypy is discovering files within a directory tree or submodules of a package to check. If you pass a file or module explicitly it will still be checked. For instance, ``mypy --exclude '/setup.py$' - but_still_check/setup.py``. + but_still_check/setup.py``. To ignore files and modules passed explicitly, + use :option:`--force-exclude` instead. In particular, ``--exclude`` does not affect mypy's :ref:`import following `. You can use a per-module :confval:`follow_imports` config @@ -81,6 +82,14 @@ for full details, see :ref:`running-mypy`. ``.pyi``. +.. option:: --force-exclude + + Behavior is identical to :option:`--exclude`, except ``--force-exclude`` + also applies to files and modules passed to mypy explicitly. For instance, + ``mypy --force-exclude '/setup.py$' some_dir/setup.py`` will not check + ``some_dir/setup.py``. + + Optional arguments ****************** diff --git a/docs/source/config_file.rst b/docs/source/config_file.rst index 5cfc5f86e37f..66858e4bdd93 100644 --- a/docs/source/config_file.rst +++ b/docs/source/config_file.rst @@ -255,6 +255,15 @@ section of the command line docs. See :ref:`using-a-pyproject-toml`. +.. confval:: force_exclude + + :type: regular expression + + A regular expression that matches file names, directory names and paths + which mypy should ignore while recursively discovering files to check. + Behavior is identical to :confval:`exclude`, except ``force_exclude`` also + applies to files and modules passed to mypy explicitly. + .. confval:: namespace_packages :type: boolean diff --git a/docs/source/running_mypy.rst b/docs/source/running_mypy.rst index 070e4556c04e..981f2893ac5b 100644 --- a/docs/source/running_mypy.rst +++ b/docs/source/running_mypy.rst @@ -407,7 +407,7 @@ to modules to type check. - Mypy will recursively discover and check all files ending in ``.py`` or ``.pyi`` in directory paths provided, after accounting for - :option:`--exclude `. + :option:`--exclude ` and :option:`--force-exclude `. - For each file to be checked, mypy will attempt to associate the file (e.g. ``project/foo/bar/baz.py``) with a fully qualified module name (e.g. diff --git a/mypy/build.py b/mypy/build.py index 6513414dd298..ea6f60b000eb 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -2641,6 +2641,7 @@ def log_configuration(manager: BuildManager, sources: List[BuildSource]) -> None ("Cache Dir", manager.options.cache_dir), ("Compiled", str(not __file__.endswith(".py"))), ("Exclude", manager.options.exclude), + ("Force Exclude", manager.options.force_exclude), ] for conf_name, conf_value in configuration_vars: diff --git a/mypy/find_sources.py b/mypy/find_sources.py index a44648f261ed..1ee5a9cfb890 100644 --- a/mypy/find_sources.py +++ b/mypy/find_sources.py @@ -30,17 +30,19 @@ def create_source_list(paths: Sequence[str], options: Options, sources = [] for path in paths: path = os.path.normpath(path) - if path.endswith(PY_EXTENSIONS): - # Can raise InvalidSourceList if a directory doesn't have a valid module name. - name, base_dir = finder.crawl_up(path) - sources.append(BuildSource(path, name, None, base_dir)) - elif fscache.isdir(path): + if fscache.isdir(path): sub_sources = finder.find_sources_in_dir(path) if not sub_sources and not allow_empty_dir: raise InvalidSourceList( "There are no .py[i] files in directory '{}'".format(path) ) sources.extend(sub_sources) + elif matches_exclude(path, options.force_exclude, fscache, options.verbosity >= 2): + continue + elif path.endswith(PY_EXTENSIONS): + # Can raise InvalidSourceList if a directory doesn't have a valid module name. + name, base_dir = finder.crawl_up(path) + sources.append(BuildSource(path, name, None, base_dir)) else: mod = os.path.basename(path) if options.scripts_are_modules else None sources.append(BuildSource(path, mod, None)) @@ -92,6 +94,7 @@ def __init__(self, fscache: FileSystemCache, options: Options) -> None: self.explicit_package_bases = get_explicit_package_bases(options) self.namespace_packages = options.namespace_packages self.exclude = options.exclude + self.force_exclude = options.force_exclude self.verbosity = options.verbosity def is_explicit_package_base(self, path: str) -> bool: @@ -110,7 +113,7 @@ def find_sources_in_dir(self, path: str) -> List[BuildSource]: subpath = os.path.join(path, name) if matches_exclude( - subpath, self.exclude, self.fscache, self.verbosity >= 2 + subpath, self.exclude + self.force_exclude, self.fscache, self.verbosity >= 2 ): continue diff --git a/mypy/main.py b/mypy/main.py index d6e16eaf5a62..565893235647 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -880,7 +880,20 @@ def add_invertible_flag(flag: str, help=( "Regular expression to match file names, directory names or paths which mypy should " "ignore while recursively discovering files to check, e.g. --exclude '/setup\\.py$'. " - "May be specified more than once, eg. --exclude a --exclude b" + "May be specified more than once, e.g. --exclude a --exclude b." + ) + ) + code_group.add_argument( + "--force-exclude", + action="append", + metavar="PATTERN", + default=[], + help=( + "Regular expression to match file names, directory names or paths which mypy should " + "ignore while recursively discovering files to check, e.g. --force-exclude " + "'/setup\\.py$'. May be specified more than once, e.g. --force-exclude a " + "--force-exclude b. Behavior is identical to --exclude, except also applies to files " + "and modules passed to mypy explicitly." ) ) code_group.add_argument( diff --git a/mypy/modulefinder.py b/mypy/modulefinder.py index 94d2dd34c16e..3f985b2698f7 100644 --- a/mypy/modulefinder.py +++ b/mypy/modulefinder.py @@ -490,7 +490,10 @@ def find_modules_recursive(self, module: str) -> List[BuildSource]: subpath = os.path.join(package_path, name) if self.options and matches_exclude( - subpath, self.options.exclude, self.fscache, self.options.verbosity >= 2 + subpath, + self.options.exclude + self.options.force_exclude, + self.fscache, + self.options.verbosity >= 2 ): continue diff --git a/mypy/options.py b/mypy/options.py index 58278b1580e8..95c9ea11f559 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -101,6 +101,8 @@ def __init__(self) -> None: self.explicit_package_bases = False # File names, directory names or subpaths to avoid checking self.exclude: List[str] = [] + # File names, directory names or subpaths to avoid checking even if explicitly specified + self.force_exclude: List[str] = [] # disallow_any options self.disallow_any_generics = False diff --git a/mypy/test/test_find_sources.py b/mypy/test/test_find_sources.py index 53da9c384bd2..2a75d2a513e3 100644 --- a/mypy/test/test_find_sources.py +++ b/mypy/test/test_find_sources.py @@ -377,3 +377,96 @@ def test_find_sources_exclude(self) -> None: } fscache = FakeFSCache(files) assert len(find_sources(["."], options, fscache)) == len(files) + + def test_find_sources_force_exclude(self) -> None: + options = Options() + options.namespace_packages = True + + files = { + "/pkg/a1/b/c/d/e.py", + "/pkg/a1/b/f.py", + "/pkg/a2/__init__.py", + "/pkg/a2/b/c/d/e.py", + "/pkg/a2/b/f.py", + } + + # file name + options.force_exclude = [r"/f\.py$"] + fscache = FakeFSCache(files) + assert find_sources(["/"], options, fscache) == [ + ("a2", "/pkg"), + ("a2.b.c.d.e", "/pkg"), + ("e", "/pkg/a1/b/c/d"), + ] + assert find_sources(["/pkg/a1/b/f.py"], options, fscache) == [] + assert find_sources(["/pkg/a2/b/f.py"], options, fscache) == [] + + # directory name + options.force_exclude = ["/a1/"] + fscache = FakeFSCache(files) + assert find_sources(["/"], options, fscache) == [ + ("a2", "/pkg"), + ("a2.b.c.d.e", "/pkg"), + ("a2.b.f", "/pkg"), + ] + with pytest.raises(InvalidSourceList): + find_sources(["/pkg/a1"], options, fscache) + with pytest.raises(InvalidSourceList): + find_sources(["/pkg/a1/"], options, fscache) + with pytest.raises(InvalidSourceList): + find_sources(["/pkg/a1/b"], options, fscache) + + options.force_exclude = ["/a1/$"] + assert find_sources(["/pkg/a1"], options, fscache) == [ + ('e', '/pkg/a1/b/c/d'), ('f', '/pkg/a1/b') + ] + + # paths + options.force_exclude = ["/pkg/a1/"] + fscache = FakeFSCache(files) + assert find_sources(["/"], options, fscache) == [ + ("a2", "/pkg"), + ("a2.b.c.d.e", "/pkg"), + ("a2.b.f", "/pkg"), + ] + with pytest.raises(InvalidSourceList): + find_sources(["/pkg/a1"], options, fscache) + + # OR two patterns together + for orred in [["/(a1|a3)/"], ["a1", "a3"], ["a3", "a1"]]: + options.force_exclude = orred + fscache = FakeFSCache(files) + assert find_sources(["/"], options, fscache) == [ + ("a2", "/pkg"), + ("a2.b.c.d.e", "/pkg"), + ("a2.b.f", "/pkg"), + ] + + options.force_exclude = ["b/c/"] + fscache = FakeFSCache(files) + assert find_sources(["/"], options, fscache) == [ + ("a2", "/pkg"), + ("a2.b.f", "/pkg"), + ("f", "/pkg/a1/b"), + ] + + # nothing should be ignored as a result of this + big_exclude1 = [ + "/pkg/a/", "/2", "/1", "/pk/", "/kg", "/g.py", "/bc", "/xxx/pkg/a2/b/f.py" + "xxx/pkg/a2/b/f.py", + ] + big_exclude2 = ["|".join(big_exclude1)] + for big_exclude in [big_exclude1, big_exclude2]: + options.force_exclude = big_exclude + fscache = FakeFSCache(files) + assert len(find_sources(["/"], options, fscache)) == len(files) + + files = { + "pkg/a1/b/c/d/e.py", + "pkg/a1/b/f.py", + "pkg/a2/__init__.py", + "pkg/a2/b/c/d/e.py", + "pkg/a2/b/f.py", + } + fscache = FakeFSCache(files) + assert len(find_sources(["."], options, fscache)) == len(files)