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.
This commit is contained in:
Jon Parise 2022-05-24 01:20:51 -07:00 committed by GitHub
parent 611b579d21
commit 8ac6dce2c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 42 additions and 10 deletions

View File

@ -165,6 +165,7 @@ Jeff Widman
Jenni Rinker Jenni Rinker
John Eddie Ayson John Eddie Ayson
John Towler John Towler
Jon Parise
Jon Sonesen Jon Sonesen
Jonas Obrist Jonas Obrist
Jordan Guymon Jordan Guymon

View File

@ -0,0 +1 @@
Added shell-style wildcard support to ``testpaths``.

View File

@ -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 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 no specific directories, files or test ids are given in the command line when
executing pytest from the :ref:`rootdir <rootdir>` directory. executing pytest from the :ref:`rootdir <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 Useful when all project tests are in a known location to speed up
test collection and to avoid picking up undesired tests by accident. test collection and to avoid picking up undesired tests by accident.

View File

@ -3,6 +3,7 @@ import argparse
import collections.abc import collections.abc
import copy import copy
import enum import enum
import glob
import inspect import inspect
import os import os
import re import re
@ -899,6 +900,19 @@ class Config:
dir: Path dir: Path
"""The directory from which :func:`pytest.main` was invoked.""" """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__( def __init__(
self, self,
pluginmanager: PytestPluginManager, pluginmanager: PytestPluginManager,
@ -1308,15 +1322,25 @@ class Config:
self.hook.pytest_cmdline_preparse(config=self, args=args) self.hook.pytest_cmdline_preparse(config=self, args=args)
self._parser.after_preparse = True # type: ignore self._parser.after_preparse = True # type: ignore
try: try:
source = Config.ArgsSource.ARGS
args = self._parser.parse_setoption( args = self._parser.parse_setoption(
args, self.option, namespace=self.option args, self.option, namespace=self.option
) )
if not args: if not args:
if self.invocation_params.dir == self.rootpath: 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: if not args:
source = Config.ArgsSource.INCOVATION_DIR
args = [str(self.invocation_params.dir)] args = [str(self.invocation_params.dir)]
self.args = args self.args = args
self.args_source = source
except PrintHelp: except PrintHelp:
pass pass

View File

@ -730,8 +730,8 @@ class TerminalReporter:
if config.inipath: if config.inipath:
line += ", configfile: " + bestrelpath(config.rootpath, config.inipath) line += ", configfile: " + bestrelpath(config.rootpath, config.inipath)
if config.args_source == Config.ArgsSource.TESTPATHS:
testpaths: List[str] = config.getini("testpaths") testpaths: List[str] = config.getini("testpaths")
if config.invocation_params.dir == config.rootpath and config.args == testpaths:
line += ", testpaths: {}".format(", ".join(testpaths)) line += ", testpaths: {}".format(", ".join(testpaths))
result = [line] result = [line]

View File

@ -244,28 +244,32 @@ class TestCollectFS:
pytester.makeini( pytester.makeini(
""" """
[pytest] [pytest]
testpaths = gui uts testpaths = */tests
""" """
) )
tmp_path = pytester.path tmp_path = pytester.path
ensure_file(tmp_path / "env" / "test_1.py").write_text("def test_env(): pass") ensure_file(tmp_path / "a" / "test_1.py").write_text("def test_a(): pass")
ensure_file(tmp_path / "gui" / "test_2.py").write_text("def test_gui(): pass") ensure_file(tmp_path / "b" / "tests" / "test_2.py").write_text(
ensure_file(tmp_path / "uts" / "test_3.py").write_text("def test_uts(): pass") "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 # executing from rootdir only tests from `testpaths` directories
# are collected # are collected
items, reprec = pytester.inline_genitems("-v") 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 # check that explicitly passing directories in the command-line
# collects the tests # collects the tests
for dirname in ("env", "gui", "uts"): for dirname in ("a", "b", "c"):
items, reprec = pytester.inline_genitems(tmp_path.joinpath(dirname)) items, reprec = pytester.inline_genitems(tmp_path.joinpath(dirname))
assert [x.name for x in items] == ["test_%s" % dirname] assert [x.name for x in items] == ["test_%s" % dirname]
# changing cwd to each subdirectory and running pytest without # changing cwd to each subdirectory and running pytest without
# arguments collects the tests in that directory normally # 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)) monkeypatch.chdir(pytester.path.joinpath(dirname))
items, reprec = pytester.inline_genitems() items, reprec = pytester.inline_genitems()
assert [x.name for x in items] == ["test_%s" % dirname] assert [x.name for x in items] == ["test_%s" % dirname]