From 70df85c011a13c423b71bb171e68fcb53a420f67 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' 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/X.feature.rst | 1 + doc/en/reference/reference.rst | 1 + src/_pytest/config/__init__.py | 24 +++++++++++++++++++++++- src/_pytest/terminal.py | 4 ++-- testing/test_collection.py | 23 +++++++++++------------ 6 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 changelog/X.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/X.feature.rst b/changelog/X.feature.rst new file mode 100644 index 00000000000..7464067bfcd --- /dev/null +++ b/changelog/X.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..66dba36d89b 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1761,6 +1761,7 @@ 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. + 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..e234176aa65 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,20 @@ class InvocationParams: dir: Path """The directory from which :func:`pytest.main` was invoked.""" + @final + class ArgsSource(enum.IntEnum): + """Indicates the source of the test arguments. + + .. versionadded:: 7.2 + """ + + #: Command line arguments. + ARGS = 0 + #: Invocation directory. + INCOVATION_DIR = 1 + #: 'testpaths' configuration value. + TESTPATHS = 2 + def __init__( self, pluginmanager: PytestPluginManager, @@ -1308,15 +1323,22 @@ 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 = cast(List[str], self.getini("testpaths")) + for path in testpaths: + args.extend(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 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..fd493119572 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1207,26 +1207,25 @@ def test_1(): def test_collect_pyargs_with_testpaths( pytester: Pytester, monkeypatch: MonkeyPatch ) -> None: - testmod = pytester.mkdir("testmod") - # NOTE: __init__.py is not collected since it does not match python_files. - testmod.joinpath("__init__.py").write_text("def test_func(): pass") - testmod.joinpath("test_file.py").write_text("def test_func(): pass") + for i in range(3): + testpkg = pytester.mkpydir(f"tests{i}") + testmod = pytester.mkdir(str(testpkg.joinpath("testmod"))) + # NOTE: __init__.py is not collected since it does not match python_files. + testmod.joinpath("__init__.py").write_text("def test_func(): pass") + testmod.joinpath("test_file.py").write_text("def test_func(): pass") - root = pytester.mkdir("root") - root.joinpath("pytest.ini").write_text( - textwrap.dedent( - """ + pytester.makeini( + """ [pytest] addopts = --pyargs - testpaths = testmod + testpaths = tests*/testmod """ - ) ) monkeypatch.setenv("PYTHONPATH", str(pytester.path), prepend=os.pathsep) with monkeypatch.context() as mp: - mp.chdir(root) + mp.chdir(pytester.path) result = pytester.runpytest_subprocess() - result.stdout.fnmatch_lines(["*1 passed in*"]) + result.stdout.fnmatch_lines(["*3 passed in*"]) def test_collect_symlink_file_arg(pytester: Pytester) -> None: