From d21c83175b9dc2c12dbe9c4263b7ff38c82d33ec Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 25 Apr 2022 15:02:38 -0700 Subject: [PATCH] Add shell-style wildcard support to 'testpaths' This is especially useful for large repositories (e.g. monorepos) that use a hierarchical file system organization for nested test paths. src/*/tests The implementation uses the standard `glob` module to perform wildcard expansion in Config.parse(). The related logic that determines whether or not to include 'testpaths' in the terminal header was previously relying on a weak heuristic: if Config.args matched 'testpaths', then its value was printed. That generally worked, but it could also print when the user explicitly used the same arguments on the command-line as listed in 'testpaths'. Not a big deal, but it shows that the check was logically incorrect. Now that 'testpaths' can contain wildcards, it's no longer possible to perform this simple comparison, so this change also introduces a public Config.ArgSource enum and Config.args_source attribute that explicitly names the "source" of the arguments: the command line, the invocation directory, or the 'testdata' configuration value. [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- AUTHORS | 1 + changelog/9897.feature.rst | 1 + doc/en/reference/reference.rst | 2 ++ src/_pytest/config/__init__.py | 30 +++++++++++++++++++++++++++++- src/_pytest/terminal.py | 4 ++-- testing/test_collection.py | 18 +++++++++++------- 6 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 changelog/9897.feature.rst diff --git a/AUTHORS b/AUTHORS index 1a8c5306f75..643799edcef 100644 --- a/AUTHORS +++ b/AUTHORS @@ -164,6 +164,7 @@ Jeff Widman Jenni Rinker John Eddie Ayson John Towler +Jon Parise Jon Sonesen Jonas Obrist Jordan Guymon diff --git a/changelog/9897.feature.rst b/changelog/9897.feature.rst new file mode 100644 index 00000000000..7464067bfcd --- /dev/null +++ b/changelog/9897.feature.rst @@ -0,0 +1 @@ +Added shell-style wildcard support to ``testpaths``. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 167c8fed9a3..a5b12db70ed 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1761,6 +1761,8 @@ passed multiple times. The expected format is ``name=value``. For example:: Sets list of directories that should be searched for tests when no specific directories, files or test ids are given in the command line when executing pytest from the :ref:`rootdir ` directory. + File system paths may use shell-style wildcards, including the recursive + ``**`` pattern. Useful when all project tests are in a known location to speed up test collection and to avoid picking up undesired tests by accident. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 05abaa8eda6..cf2168a6c29 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -3,6 +3,7 @@ import collections.abc import copy import enum +import glob import inspect import os import re @@ -863,6 +864,10 @@ def _args_converter(args: Iterable[str]) -> Tuple[str, ...]: return tuple(args) +# shell-style wildcards supported by the `glob` module +_glob_wildcards = re.compile("([*?[])") + + @final class Config: """Access to configuration values, pluginmanager and plugin hooks. @@ -899,6 +904,19 @@ class InvocationParams: dir: Path """The directory from which :func:`pytest.main` was invoked.""" + class ArgsSource(enum.Enum): + """Indicates the source of the test arguments. + + .. versionadded:: 7.2 + """ + + #: Command line arguments. + ARGS = enum.auto() + #: Invocation directory. + INCOVATION_DIR = enum.auto() + #: 'testpaths' configuration value. + TESTPATHS = enum.auto() + def __init__( self, pluginmanager: PytestPluginManager, @@ -1308,15 +1326,25 @@ def parse(self, args: List[str], addopts: bool = True) -> None: self.hook.pytest_cmdline_preparse(config=self, args=args) self._parser.after_preparse = True # type: ignore try: + source = Config.ArgsSource.ARGS args = self._parser.parse_setoption( args, self.option, namespace=self.option ) if not args: if self.invocation_params.dir == self.rootpath: - args = self.getini("testpaths") + args = [] + source = Config.ArgsSource.TESTPATHS + testpaths: List[str] = self.getini("testpaths") + for path in testpaths: + if _glob_wildcards.search(path) is not None: + args.extend(sorted(glob.iglob(path, recursive=True))) + else: + args.append(path) if not args: + source = Config.ArgsSource.INCOVATION_DIR args = [str(self.invocation_params.dir)] self.args = args + self.args_source = source except PrintHelp: pass diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index b4848c48aba..6811fe073fb 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -728,8 +728,8 @@ def pytest_report_header(self, config: Config) -> List[str]: if config.inipath: line += ", configfile: " + bestrelpath(config.rootpath, config.inipath) - testpaths: List[str] = config.getini("testpaths") - if config.invocation_params.dir == config.rootpath and config.args == testpaths: + if config.args_source == Config.ArgsSource.TESTPATHS: + testpaths: List[str] = config.getini("testpaths") line += ", testpaths: {}".format(", ".join(testpaths)) result = [line] diff --git a/testing/test_collection.py b/testing/test_collection.py index 9099ec57fca..58e1d862a35 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -244,28 +244,32 @@ def test_testpaths_ini(self, pytester: Pytester, monkeypatch: MonkeyPatch) -> No pytester.makeini( """ [pytest] - testpaths = gui uts + testpaths = */tests """ ) tmp_path = pytester.path - ensure_file(tmp_path / "env" / "test_1.py").write_text("def test_env(): pass") - ensure_file(tmp_path / "gui" / "test_2.py").write_text("def test_gui(): pass") - ensure_file(tmp_path / "uts" / "test_3.py").write_text("def test_uts(): pass") + ensure_file(tmp_path / "a" / "test_1.py").write_text("def test_a(): pass") + ensure_file(tmp_path / "b" / "tests" / "test_2.py").write_text( + "def test_b(): pass" + ) + ensure_file(tmp_path / "c" / "tests" / "test_3.py").write_text( + "def test_c(): pass" + ) # executing from rootdir only tests from `testpaths` directories # are collected items, reprec = pytester.inline_genitems("-v") - assert [x.name for x in items] == ["test_gui", "test_uts"] + assert [x.name for x in items] == ["test_b", "test_c"] # check that explicitly passing directories in the command-line # collects the tests - for dirname in ("env", "gui", "uts"): + for dirname in ("a", "b", "c"): items, reprec = pytester.inline_genitems(tmp_path.joinpath(dirname)) assert [x.name for x in items] == ["test_%s" % dirname] # changing cwd to each subdirectory and running pytest without # arguments collects the tests in that directory normally - for dirname in ("env", "gui", "uts"): + for dirname in ("a", "b", "c"): monkeypatch.chdir(pytester.path.joinpath(dirname)) items, reprec = pytester.inline_genitems() assert [x.name for x in items] == ["test_%s" % dirname]