From 8ac6dce2c7ec1083e849417379e13b354d5aecfd Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 24 May 2022 01:20:51 -0700 Subject: [PATCH] Add shell-style wildcard support to 'testpaths' (#9897) 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. --- AUTHORS | 1 + changelog/9897.feature.rst | 1 + doc/en/reference/reference.rst | 2 ++ src/_pytest/config/__init__.py | 26 +++++++++++++++++++++++++- src/_pytest/terminal.py | 4 ++-- testing/test_collection.py | 18 +++++++++++------- 6 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 changelog/9897.feature.rst diff --git a/AUTHORS b/AUTHORS index 86a814a137b..6baf9be2bd9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -165,6 +165,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 d082697258c..c1fb47e33b9 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1699,6 +1699,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..dfbda68ea78 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 @@ -899,6 +900,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 +1322,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") + source = Config.ArgsSource.TESTPATHS + testpaths: List[str] = self.getini("testpaths") + if self.known_args_namespace.pyargs: + args = testpaths + else: + args = [] + for path in testpaths: + args.extend(sorted(glob.iglob(path, recursive=True))) 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 bb07b3ce7c6..8e47f26045a 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -730,8 +730,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]