From 9d65bc288e7e354db8007a51b206db9ef62724ea Mon Sep 17 00:00:00 2001 From: Chris Mahoney Date: Thu, 25 May 2023 09:23:13 +1000 Subject: [PATCH] Add new flag to CLI: Signed-off-by: Chris Mahoney --- AUTHORS | 1 + changelog/11031.trivial.rst | 1 + src/_pytest/main.py | 1807 +++++++-------- testing/test_config.py | 4281 ++++++++++++++++++----------------- 4 files changed, 3052 insertions(+), 3038 deletions(-) create mode 100644 changelog/11031.trivial.rst diff --git a/AUTHORS b/AUTHORS index c9e990bf1..309088707 100644 --- a/AUTHORS +++ b/AUTHORS @@ -72,6 +72,7 @@ Charles Cloud Charles Machalow Charnjit SiNGH (CCSJ) Cheuk Ting Ho +Chris Mahoney Chris Lamb Chris NeJame Chris Rose diff --git a/changelog/11031.trivial.rst b/changelog/11031.trivial.rst new file mode 100644 index 000000000..b8ca26572 --- /dev/null +++ b/changelog/11031.trivial.rst @@ -0,0 +1 @@ +Enhanced the CLI flag for ``-c`` to now include ``--config-file`` to make it clear that this flag applies to the usage of a custom config file. \ No newline at end of file diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 5f8ac4689..b9686bbfa 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -1,903 +1,904 @@ -"""Core implementation of the testing process: init, session, runtest loop.""" -import argparse -import dataclasses -import fnmatch -import functools -import importlib -import os -import sys -from pathlib import Path -from typing import Callable -from typing import Dict -from typing import FrozenSet -from typing import Iterator -from typing import List -from typing import Optional -from typing import Sequence -from typing import Set -from typing import Tuple -from typing import Type -from typing import TYPE_CHECKING -from typing import Union - -import _pytest._code -from _pytest import nodes -from _pytest.compat import final -from _pytest.compat import overload -from _pytest.config import Config -from _pytest.config import directory_arg -from _pytest.config import ExitCode -from _pytest.config import hookimpl -from _pytest.config import PytestPluginManager -from _pytest.config import UsageError -from _pytest.config.argparsing import Parser -from _pytest.fixtures import FixtureManager -from _pytest.outcomes import exit -from _pytest.pathlib import absolutepath -from _pytest.pathlib import bestrelpath -from _pytest.pathlib import fnmatch_ex -from _pytest.pathlib import visit -from _pytest.reports import CollectReport -from _pytest.reports import TestReport -from _pytest.runner import collect_one_node -from _pytest.runner import SetupState - - -if TYPE_CHECKING: - from typing_extensions import Literal - - -def pytest_addoption(parser: Parser) -> None: - parser.addini( - "norecursedirs", - "Directory patterns to avoid for recursion", - type="args", - default=[ - "*.egg", - ".*", - "_darcs", - "build", - "CVS", - "dist", - "node_modules", - "venv", - "{arch}", - ], - ) - parser.addini( - "testpaths", - "Directories to search for tests when no files or directories are given on the " - "command line", - type="args", - default=[], - ) - group = parser.getgroup("general", "Running and selection options") - group._addoption( - "-x", - "--exitfirst", - action="store_const", - dest="maxfail", - const=1, - help="Exit instantly on first error or failed test", - ) - group = parser.getgroup("pytest-warnings") - group.addoption( - "-W", - "--pythonwarnings", - action="append", - help="Set which warnings to report, see -W option of Python itself", - ) - parser.addini( - "filterwarnings", - type="linelist", - help="Each line specifies a pattern for " - "warnings.filterwarnings. " - "Processed after -W/--pythonwarnings.", - ) - group._addoption( - "--maxfail", - metavar="num", - action="store", - type=int, - dest="maxfail", - default=0, - help="Exit after first num failures or errors", - ) - group._addoption( - "--strict-config", - action="store_true", - help="Any warnings encountered while parsing the `pytest` section of the " - "configuration file raise errors", - ) - group._addoption( - "--strict-markers", - action="store_true", - help="Markers not registered in the `markers` section of the configuration " - "file raise errors", - ) - group._addoption( - "--strict", - action="store_true", - help="(Deprecated) alias to --strict-markers", - ) - group._addoption( - "-c", - metavar="file", - type=str, - dest="inifilename", - help="Load configuration from `file` instead of trying to locate one of the " - "implicit configuration files", - ) - group._addoption( - "--continue-on-collection-errors", - action="store_true", - default=False, - dest="continue_on_collection_errors", - help="Force test execution even if collection errors occur", - ) - group._addoption( - "--rootdir", - action="store", - dest="rootdir", - help="Define root directory for tests. Can be relative path: 'root_dir', './root_dir', " - "'root_dir/another_dir/'; absolute path: '/home/user/root_dir'; path with variables: " - "'$HOME/root_dir'.", - ) - - group = parser.getgroup("collect", "collection") - group.addoption( - "--collectonly", - "--collect-only", - "--co", - action="store_true", - help="Only collect tests, don't execute them", - ) - group.addoption( - "--pyargs", - action="store_true", - help="Try to interpret all arguments as Python packages", - ) - group.addoption( - "--ignore", - action="append", - metavar="path", - help="Ignore path during collection (multi-allowed)", - ) - group.addoption( - "--ignore-glob", - action="append", - metavar="path", - help="Ignore path pattern during collection (multi-allowed)", - ) - group.addoption( - "--deselect", - action="append", - metavar="nodeid_prefix", - help="Deselect item (via node id prefix) during collection (multi-allowed)", - ) - group.addoption( - "--confcutdir", - dest="confcutdir", - default=None, - metavar="dir", - type=functools.partial(directory_arg, optname="--confcutdir"), - help="Only load conftest.py's relative to specified dir", - ) - group.addoption( - "--noconftest", - action="store_true", - dest="noconftest", - default=False, - help="Don't load any conftest.py files", - ) - group.addoption( - "--keepduplicates", - "--keep-duplicates", - action="store_true", - dest="keepduplicates", - default=False, - help="Keep duplicate tests", - ) - group.addoption( - "--collect-in-virtualenv", - action="store_true", - dest="collect_in_virtualenv", - default=False, - help="Don't ignore tests in a local virtualenv directory", - ) - group.addoption( - "--import-mode", - default="prepend", - choices=["prepend", "append", "importlib"], - dest="importmode", - help="Prepend/append to sys.path when importing test modules and conftest " - "files. Default: prepend.", - ) - - group = parser.getgroup("debugconfig", "test session debugging and configuration") - group.addoption( - "--basetemp", - dest="basetemp", - default=None, - type=validate_basetemp, - metavar="dir", - help=( - "Base temporary directory for this test run. " - "(Warning: this directory is removed if it exists.)" - ), - ) - - -def validate_basetemp(path: str) -> str: - # GH 7119 - msg = "basetemp must not be empty, the current working directory or any parent directory of it" - - # empty path - if not path: - raise argparse.ArgumentTypeError(msg) - - def is_ancestor(base: Path, query: Path) -> bool: - """Return whether query is an ancestor of base.""" - if base == query: - return True - return query in base.parents - - # check if path is an ancestor of cwd - if is_ancestor(Path.cwd(), Path(path).absolute()): - raise argparse.ArgumentTypeError(msg) - - # check symlinks for ancestors - if is_ancestor(Path.cwd().resolve(), Path(path).resolve()): - raise argparse.ArgumentTypeError(msg) - - return path - - -def wrap_session( - config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]] -) -> Union[int, ExitCode]: - """Skeleton command line program.""" - session = Session.from_config(config) - session.exitstatus = ExitCode.OK - initstate = 0 - try: - try: - config._do_configure() - initstate = 1 - config.hook.pytest_sessionstart(session=session) - initstate = 2 - session.exitstatus = doit(config, session) or 0 - except UsageError: - session.exitstatus = ExitCode.USAGE_ERROR - raise - except Failed: - session.exitstatus = ExitCode.TESTS_FAILED - except (KeyboardInterrupt, exit.Exception): - excinfo = _pytest._code.ExceptionInfo.from_current() - exitstatus: Union[int, ExitCode] = ExitCode.INTERRUPTED - if isinstance(excinfo.value, exit.Exception): - if excinfo.value.returncode is not None: - exitstatus = excinfo.value.returncode - if initstate < 2: - sys.stderr.write(f"{excinfo.typename}: {excinfo.value.msg}\n") - config.hook.pytest_keyboard_interrupt(excinfo=excinfo) - session.exitstatus = exitstatus - except BaseException: - session.exitstatus = ExitCode.INTERNAL_ERROR - excinfo = _pytest._code.ExceptionInfo.from_current() - try: - config.notify_exception(excinfo, config.option) - except exit.Exception as exc: - if exc.returncode is not None: - session.exitstatus = exc.returncode - sys.stderr.write(f"{type(exc).__name__}: {exc}\n") - else: - if isinstance(excinfo.value, SystemExit): - sys.stderr.write("mainloop: caught unexpected SystemExit!\n") - - finally: - # Explicitly break reference cycle. - excinfo = None # type: ignore - os.chdir(session.startpath) - if initstate >= 2: - try: - config.hook.pytest_sessionfinish( - session=session, exitstatus=session.exitstatus - ) - except exit.Exception as exc: - if exc.returncode is not None: - session.exitstatus = exc.returncode - sys.stderr.write(f"{type(exc).__name__}: {exc}\n") - config._ensure_unconfigure() - return session.exitstatus - - -def pytest_cmdline_main(config: Config) -> Union[int, ExitCode]: - return wrap_session(config, _main) - - -def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: - """Default command line protocol for initialization, session, - running tests and reporting.""" - config.hook.pytest_collection(session=session) - config.hook.pytest_runtestloop(session=session) - - if session.testsfailed: - return ExitCode.TESTS_FAILED - elif session.testscollected == 0: - return ExitCode.NO_TESTS_COLLECTED - return None - - -def pytest_collection(session: "Session") -> None: - session.perform_collect() - - -def pytest_runtestloop(session: "Session") -> bool: - if session.testsfailed and not session.config.option.continue_on_collection_errors: - raise session.Interrupted( - "%d error%s during collection" - % (session.testsfailed, "s" if session.testsfailed != 1 else "") - ) - - if session.config.option.collectonly: - return True - - for i, item in enumerate(session.items): - nextitem = session.items[i + 1] if i + 1 < len(session.items) else None - item.config.hook.pytest_runtest_protocol(item=item, nextitem=nextitem) - if session.shouldfail: - raise session.Failed(session.shouldfail) - if session.shouldstop: - raise session.Interrupted(session.shouldstop) - return True - - -def _in_venv(path: Path) -> bool: - """Attempt to detect if ``path`` is the root of a Virtual Environment by - checking for the existence of the appropriate activate script.""" - bindir = path.joinpath("Scripts" if sys.platform.startswith("win") else "bin") - try: - if not bindir.is_dir(): - return False - except OSError: - return False - activates = ( - "activate", - "activate.csh", - "activate.fish", - "Activate", - "Activate.bat", - "Activate.ps1", - ) - return any(fname.name in activates for fname in bindir.iterdir()) - - -def pytest_ignore_collect(collection_path: Path, config: Config) -> Optional[bool]: - ignore_paths = config._getconftest_pathlist( - "collect_ignore", path=collection_path.parent, rootpath=config.rootpath - ) - ignore_paths = ignore_paths or [] - excludeopt = config.getoption("ignore") - if excludeopt: - ignore_paths.extend(absolutepath(x) for x in excludeopt) - - if collection_path in ignore_paths: - return True - - ignore_globs = config._getconftest_pathlist( - "collect_ignore_glob", path=collection_path.parent, rootpath=config.rootpath - ) - ignore_globs = ignore_globs or [] - excludeglobopt = config.getoption("ignore_glob") - if excludeglobopt: - ignore_globs.extend(absolutepath(x) for x in excludeglobopt) - - if any(fnmatch.fnmatch(str(collection_path), str(glob)) for glob in ignore_globs): - return True - - allow_in_venv = config.getoption("collect_in_virtualenv") - if not allow_in_venv and _in_venv(collection_path): - return True - return None - - -def pytest_collection_modifyitems(items: List[nodes.Item], config: Config) -> None: - deselect_prefixes = tuple(config.getoption("deselect") or []) - if not deselect_prefixes: - return - - remaining = [] - deselected = [] - for colitem in items: - if colitem.nodeid.startswith(deselect_prefixes): - deselected.append(colitem) - else: - remaining.append(colitem) - - if deselected: - config.hook.pytest_deselected(items=deselected) - items[:] = remaining - - -class FSHookProxy: - def __init__(self, pm: PytestPluginManager, remove_mods) -> None: - self.pm = pm - self.remove_mods = remove_mods - - def __getattr__(self, name: str): - x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods) - self.__dict__[name] = x - return x - - -class Interrupted(KeyboardInterrupt): - """Signals that the test run was interrupted.""" - - __module__ = "builtins" # For py3. - - -class Failed(Exception): - """Signals a stop as failed test run.""" - - -@dataclasses.dataclass -class _bestrelpath_cache(Dict[Path, str]): - __slots__ = ("path",) - - path: Path - - def __missing__(self, path: Path) -> str: - r = bestrelpath(self.path, path) - self[path] = r - return r - - -@final -class Session(nodes.FSCollector): - Interrupted = Interrupted - Failed = Failed - # Set on the session by runner.pytest_sessionstart. - _setupstate: SetupState - # Set on the session by fixtures.pytest_sessionstart. - _fixturemanager: FixtureManager - exitstatus: Union[int, ExitCode] - - def __init__(self, config: Config) -> None: - super().__init__( - path=config.rootpath, - fspath=None, - parent=None, - config=config, - session=self, - nodeid="", - ) - self.testsfailed = 0 - self.testscollected = 0 - self.shouldstop: Union[bool, str] = False - self.shouldfail: Union[bool, str] = False - self.trace = config.trace.root.get("collection") - self._initialpaths: FrozenSet[Path] = frozenset() - - self._bestrelpathcache: Dict[Path, str] = _bestrelpath_cache(config.rootpath) - - self.config.pluginmanager.register(self, name="session") - - @classmethod - def from_config(cls, config: Config) -> "Session": - session: Session = cls._create(config=config) - return session - - def __repr__(self) -> str: - return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % ( - self.__class__.__name__, - self.name, - getattr(self, "exitstatus", ""), - self.testsfailed, - self.testscollected, - ) - - @property - def startpath(self) -> Path: - """The path from which pytest was invoked. - - .. versionadded:: 7.0.0 - """ - return self.config.invocation_params.dir - - def _node_location_to_relpath(self, node_path: Path) -> str: - # bestrelpath is a quite slow function. - return self._bestrelpathcache[node_path] - - @hookimpl(tryfirst=True) - def pytest_collectstart(self) -> None: - if self.shouldfail: - raise self.Failed(self.shouldfail) - if self.shouldstop: - raise self.Interrupted(self.shouldstop) - - @hookimpl(tryfirst=True) - def pytest_runtest_logreport( - self, report: Union[TestReport, CollectReport] - ) -> None: - if report.failed and not hasattr(report, "wasxfail"): - self.testsfailed += 1 - maxfail = self.config.getvalue("maxfail") - if maxfail and self.testsfailed >= maxfail: - self.shouldfail = "stopping after %d failures" % (self.testsfailed) - - pytest_collectreport = pytest_runtest_logreport - - def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool: - # Optimization: Path(Path(...)) is much slower than isinstance. - path_ = path if isinstance(path, Path) else Path(path) - return path_ in self._initialpaths - - def gethookproxy(self, fspath: "os.PathLike[str]"): - # Optimization: Path(Path(...)) is much slower than isinstance. - path = fspath if isinstance(fspath, Path) else Path(fspath) - pm = self.config.pluginmanager - # Check if we have the common case of running - # hooks with all conftest.py files. - my_conftestmodules = pm._getconftestmodules( - path, - self.config.getoption("importmode"), - rootpath=self.config.rootpath, - ) - remove_mods = pm._conftest_plugins.difference(my_conftestmodules) - if remove_mods: - # One or more conftests are not in use at this fspath. - from .config.compat import PathAwareHookProxy - - proxy = PathAwareHookProxy(FSHookProxy(pm, remove_mods)) - else: - # All plugins are active for this fspath. - proxy = self.config.hook - return proxy - - def _recurse(self, direntry: "os.DirEntry[str]") -> bool: - if direntry.name == "__pycache__": - return False - fspath = Path(direntry.path) - ihook = self.gethookproxy(fspath.parent) - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return False - norecursepatterns = self.config.getini("norecursedirs") - if any(fnmatch_ex(pat, fspath) for pat in norecursepatterns): - return False - return True - - def _collectfile( - self, fspath: Path, handle_dupes: bool = True - ) -> Sequence[nodes.Collector]: - assert ( - fspath.is_file() - ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( - fspath, fspath.is_dir(), fspath.exists(), fspath.is_symlink() - ) - ihook = self.gethookproxy(fspath) - if not self.isinitpath(fspath): - if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): - return () - - if handle_dupes: - keepduplicates = self.config.getoption("keepduplicates") - if not keepduplicates: - duplicate_paths = self.config.pluginmanager._duplicatepaths - if fspath in duplicate_paths: - return () - else: - duplicate_paths.add(fspath) - - return ihook.pytest_collect_file(file_path=fspath, parent=self) # type: ignore[no-any-return] - - @overload - def perform_collect( - self, args: Optional[Sequence[str]] = ..., genitems: "Literal[True]" = ... - ) -> Sequence[nodes.Item]: - ... - - @overload - def perform_collect( # noqa: F811 - self, args: Optional[Sequence[str]] = ..., genitems: bool = ... - ) -> Sequence[Union[nodes.Item, nodes.Collector]]: - ... - - def perform_collect( # noqa: F811 - self, args: Optional[Sequence[str]] = None, genitems: bool = True - ) -> Sequence[Union[nodes.Item, nodes.Collector]]: - """Perform the collection phase for this session. - - This is called by the default :hook:`pytest_collection` hook - implementation; see the documentation of this hook for more details. - For testing purposes, it may also be called directly on a fresh - ``Session``. - - This function normally recursively expands any collectors collected - from the session to their items, and only items are returned. For - testing purposes, this may be suppressed by passing ``genitems=False``, - in which case the return value contains these collectors unexpanded, - and ``session.items`` is empty. - """ - if args is None: - args = self.config.args - - self.trace("perform_collect", self, args) - self.trace.root.indent += 1 - - self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = [] - self._initial_parts: List[Tuple[Path, List[str]]] = [] - self.items: List[nodes.Item] = [] - - hook = self.config.hook - - items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items - try: - initialpaths: List[Path] = [] - for arg in args: - fspath, parts = resolve_collection_argument( - self.config.invocation_params.dir, - arg, - as_pypath=self.config.option.pyargs, - ) - self._initial_parts.append((fspath, parts)) - initialpaths.append(fspath) - self._initialpaths = frozenset(initialpaths) - rep = collect_one_node(self) - self.ihook.pytest_collectreport(report=rep) - self.trace.root.indent -= 1 - if self._notfound: - errors = [] - for arg, collectors in self._notfound: - if collectors: - errors.append( - f"not found: {arg}\n(no name {arg!r} in any of {collectors!r})" - ) - else: - errors.append(f"found no collectors for {arg}") - - raise UsageError(*errors) - if not genitems: - items = rep.result - else: - if rep.passed: - for node in rep.result: - self.items.extend(self.genitems(node)) - - self.config.pluginmanager.check_pending() - hook.pytest_collection_modifyitems( - session=self, config=self.config, items=items - ) - finally: - hook.pytest_collection_finish(session=self) - - self.testscollected = len(items) - return items - - def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]: - from _pytest.python import Package - - # Keep track of any collected nodes in here, so we don't duplicate fixtures. - node_cache1: Dict[Path, Sequence[nodes.Collector]] = {} - node_cache2: Dict[Tuple[Type[nodes.Collector], Path], nodes.Collector] = {} - - # Keep track of any collected collectors in matchnodes paths, so they - # are not collected more than once. - matchnodes_cache: Dict[Tuple[Type[nodes.Collector], str], CollectReport] = {} - - # Dirnames of pkgs with dunder-init files. - pkg_roots: Dict[str, Package] = {} - - for argpath, names in self._initial_parts: - self.trace("processing argument", (argpath, names)) - self.trace.root.indent += 1 - - # Start with a Session root, and delve to argpath item (dir or file) - # and stack all Packages found on the way. - # No point in finding packages when collecting doctests. - if not self.config.getoption("doctestmodules", False): - pm = self.config.pluginmanager - for parent in (argpath, *argpath.parents): - if not pm._is_in_confcutdir(argpath): - break - - if parent.is_dir(): - pkginit = parent / "__init__.py" - if pkginit.is_file() and pkginit not in node_cache1: - col = self._collectfile(pkginit, handle_dupes=False) - if col: - if isinstance(col[0], Package): - pkg_roots[str(parent)] = col[0] - node_cache1[col[0].path] = [col[0]] - - # If it's a directory argument, recurse and look for any Subpackages. - # Let the Package collector deal with subnodes, don't collect here. - if argpath.is_dir(): - assert not names, f"invalid arg {(argpath, names)!r}" - - seen_dirs: Set[Path] = set() - for direntry in visit(str(argpath), self._recurse): - if not direntry.is_file(): - continue - - path = Path(direntry.path) - dirpath = path.parent - - if dirpath not in seen_dirs: - # Collect packages first. - seen_dirs.add(dirpath) - pkginit = dirpath / "__init__.py" - if pkginit.exists(): - for x in self._collectfile(pkginit): - yield x - if isinstance(x, Package): - pkg_roots[str(dirpath)] = x - if str(dirpath) in pkg_roots: - # Do not collect packages here. - continue - - for x in self._collectfile(path): - key2 = (type(x), x.path) - if key2 in node_cache2: - yield node_cache2[key2] - else: - node_cache2[key2] = x - yield x - else: - assert argpath.is_file() - - if argpath in node_cache1: - col = node_cache1[argpath] - else: - collect_root = pkg_roots.get(str(argpath.parent), self) - col = collect_root._collectfile(argpath, handle_dupes=False) - if col: - node_cache1[argpath] = col - - matching = [] - work: List[ - Tuple[Sequence[Union[nodes.Item, nodes.Collector]], Sequence[str]] - ] = [(col, names)] - while work: - self.trace("matchnodes", col, names) - self.trace.root.indent += 1 - - matchnodes, matchnames = work.pop() - for node in matchnodes: - if not matchnames: - matching.append(node) - continue - if not isinstance(node, nodes.Collector): - continue - key = (type(node), node.nodeid) - if key in matchnodes_cache: - rep = matchnodes_cache[key] - else: - rep = collect_one_node(node) - matchnodes_cache[key] = rep - if rep.passed: - submatchnodes = [] - for r in rep.result: - # TODO: Remove parametrized workaround once collection structure contains - # parametrization. - if ( - r.name == matchnames[0] - or r.name.split("[")[0] == matchnames[0] - ): - submatchnodes.append(r) - if submatchnodes: - work.append((submatchnodes, matchnames[1:])) - else: - # Report collection failures here to avoid failing to run some test - # specified in the command line because the module could not be - # imported (#134). - node.ihook.pytest_collectreport(report=rep) - - self.trace("matchnodes finished -> ", len(matching), "nodes") - self.trace.root.indent -= 1 - - if not matching: - report_arg = "::".join((str(argpath), *names)) - self._notfound.append((report_arg, col)) - continue - - # If __init__.py was the only file requested, then the matched - # node will be the corresponding Package (by default), and the - # first yielded item will be the __init__ Module itself, so - # just use that. If this special case isn't taken, then all the - # files in the package will be yielded. - if argpath.name == "__init__.py" and isinstance(matching[0], Package): - try: - yield next(iter(matching[0].collect())) - except StopIteration: - # The package collects nothing with only an __init__.py - # file in it, which gets ignored by the default - # "python_files" option. - pass - continue - - yield from matching - - self.trace.root.indent -= 1 - - def genitems( - self, node: Union[nodes.Item, nodes.Collector] - ) -> Iterator[nodes.Item]: - self.trace("genitems", node) - if isinstance(node, nodes.Item): - node.ihook.pytest_itemcollected(item=node) - yield node - else: - assert isinstance(node, nodes.Collector) - rep = collect_one_node(node) - if rep.passed: - for subnode in rep.result: - yield from self.genitems(subnode) - node.ihook.pytest_collectreport(report=rep) - - -def search_pypath(module_name: str) -> str: - """Search sys.path for the given a dotted module name, and return its file system path.""" - try: - spec = importlib.util.find_spec(module_name) - # AttributeError: looks like package module, but actually filename - # ImportError: module does not exist - # ValueError: not a module name - except (AttributeError, ImportError, ValueError): - return module_name - if spec is None or spec.origin is None or spec.origin == "namespace": - return module_name - elif spec.submodule_search_locations: - return os.path.dirname(spec.origin) - else: - return spec.origin - - -def resolve_collection_argument( - invocation_path: Path, arg: str, *, as_pypath: bool = False -) -> Tuple[Path, List[str]]: - """Parse path arguments optionally containing selection parts and return (fspath, names). - - Command-line arguments can point to files and/or directories, and optionally contain - parts for specific tests selection, for example: - - "pkg/tests/test_foo.py::TestClass::test_foo" - - This function ensures the path exists, and returns a tuple: - - (Path("/full/path/to/pkg/tests/test_foo.py"), ["TestClass", "test_foo"]) - - When as_pypath is True, expects that the command-line argument actually contains - module paths instead of file-system paths: - - "pkg.tests.test_foo::TestClass::test_foo" - - In which case we search sys.path for a matching module, and then return the *path* to the - found module. - - If the path doesn't exist, raise UsageError. - If the path is a directory and selection parts are present, raise UsageError. - """ - base, squacket, rest = str(arg).partition("[") - strpath, *parts = base.split("::") - if parts: - parts[-1] = f"{parts[-1]}{squacket}{rest}" - if as_pypath: - strpath = search_pypath(strpath) - fspath = invocation_path / strpath - fspath = absolutepath(fspath) - if not fspath.exists(): - msg = ( - "module or package not found: {arg} (missing __init__.py?)" - if as_pypath - else "file or directory not found: {arg}" - ) - raise UsageError(msg.format(arg=arg)) - if parts and fspath.is_dir(): - msg = ( - "package argument cannot contain :: selection parts: {arg}" - if as_pypath - else "directory argument cannot contain :: selection parts: {arg}" - ) - raise UsageError(msg.format(arg=arg)) - return fspath, parts +"""Core implementation of the testing process: init, session, runtest loop.""" +import argparse +import dataclasses +import fnmatch +import functools +import importlib +import os +import sys +from pathlib import Path +from typing import Callable +from typing import Dict +from typing import FrozenSet +from typing import Iterator +from typing import List +from typing import Optional +from typing import Sequence +from typing import Set +from typing import Tuple +from typing import Type +from typing import TYPE_CHECKING +from typing import Union + +import _pytest._code +from _pytest import nodes +from _pytest.compat import final +from _pytest.compat import overload +from _pytest.config import Config +from _pytest.config import directory_arg +from _pytest.config import ExitCode +from _pytest.config import hookimpl +from _pytest.config import PytestPluginManager +from _pytest.config import UsageError +from _pytest.config.argparsing import Parser +from _pytest.fixtures import FixtureManager +from _pytest.outcomes import exit +from _pytest.pathlib import absolutepath +from _pytest.pathlib import bestrelpath +from _pytest.pathlib import fnmatch_ex +from _pytest.pathlib import visit +from _pytest.reports import CollectReport +from _pytest.reports import TestReport +from _pytest.runner import collect_one_node +from _pytest.runner import SetupState + + +if TYPE_CHECKING: + from typing_extensions import Literal + + +def pytest_addoption(parser: Parser) -> None: + parser.addini( + "norecursedirs", + "Directory patterns to avoid for recursion", + type="args", + default=[ + "*.egg", + ".*", + "_darcs", + "build", + "CVS", + "dist", + "node_modules", + "venv", + "{arch}", + ], + ) + parser.addini( + "testpaths", + "Directories to search for tests when no files or directories are given on the " + "command line", + type="args", + default=[], + ) + group = parser.getgroup("general", "Running and selection options") + group._addoption( + "-x", + "--exitfirst", + action="store_const", + dest="maxfail", + const=1, + help="Exit instantly on first error or failed test", + ) + group = parser.getgroup("pytest-warnings") + group.addoption( + "-W", + "--pythonwarnings", + action="append", + help="Set which warnings to report, see -W option of Python itself", + ) + parser.addini( + "filterwarnings", + type="linelist", + help="Each line specifies a pattern for " + "warnings.filterwarnings. " + "Processed after -W/--pythonwarnings.", + ) + group._addoption( + "--maxfail", + metavar="num", + action="store", + type=int, + dest="maxfail", + default=0, + help="Exit after first num failures or errors", + ) + group._addoption( + "--strict-config", + action="store_true", + help="Any warnings encountered while parsing the `pytest` section of the " + "configuration file raise errors", + ) + group._addoption( + "--strict-markers", + action="store_true", + help="Markers not registered in the `markers` section of the configuration " + "file raise errors", + ) + group._addoption( + "--strict", + action="store_true", + help="(Deprecated) alias to --strict-markers", + ) + group._addoption( + "-c", + "--config-file", + metavar="FILE", + type=str, + dest="inifilename", + help="Load configuration from `FILE` instead of trying to locate one of the " + "implicit configuration files.", + ) + group._addoption( + "--continue-on-collection-errors", + action="store_true", + default=False, + dest="continue_on_collection_errors", + help="Force test execution even if collection errors occur", + ) + group._addoption( + "--rootdir", + action="store", + dest="rootdir", + help="Define root directory for tests. Can be relative path: 'root_dir', './root_dir', " + "'root_dir/another_dir/'; absolute path: '/home/user/root_dir'; path with variables: " + "'$HOME/root_dir'.", + ) + + group = parser.getgroup("collect", "collection") + group.addoption( + "--collectonly", + "--collect-only", + "--co", + action="store_true", + help="Only collect tests, don't execute them", + ) + group.addoption( + "--pyargs", + action="store_true", + help="Try to interpret all arguments as Python packages", + ) + group.addoption( + "--ignore", + action="append", + metavar="path", + help="Ignore path during collection (multi-allowed)", + ) + group.addoption( + "--ignore-glob", + action="append", + metavar="path", + help="Ignore path pattern during collection (multi-allowed)", + ) + group.addoption( + "--deselect", + action="append", + metavar="nodeid_prefix", + help="Deselect item (via node id prefix) during collection (multi-allowed)", + ) + group.addoption( + "--confcutdir", + dest="confcutdir", + default=None, + metavar="dir", + type=functools.partial(directory_arg, optname="--confcutdir"), + help="Only load conftest.py's relative to specified dir", + ) + group.addoption( + "--noconftest", + action="store_true", + dest="noconftest", + default=False, + help="Don't load any conftest.py files", + ) + group.addoption( + "--keepduplicates", + "--keep-duplicates", + action="store_true", + dest="keepduplicates", + default=False, + help="Keep duplicate tests", + ) + group.addoption( + "--collect-in-virtualenv", + action="store_true", + dest="collect_in_virtualenv", + default=False, + help="Don't ignore tests in a local virtualenv directory", + ) + group.addoption( + "--import-mode", + default="prepend", + choices=["prepend", "append", "importlib"], + dest="importmode", + help="Prepend/append to sys.path when importing test modules and conftest " + "files. Default: prepend.", + ) + + group = parser.getgroup("debugconfig", "test session debugging and configuration") + group.addoption( + "--basetemp", + dest="basetemp", + default=None, + type=validate_basetemp, + metavar="dir", + help=( + "Base temporary directory for this test run. " + "(Warning: this directory is removed if it exists.)" + ), + ) + + +def validate_basetemp(path: str) -> str: + # GH 7119 + msg = "basetemp must not be empty, the current working directory or any parent directory of it" + + # empty path + if not path: + raise argparse.ArgumentTypeError(msg) + + def is_ancestor(base: Path, query: Path) -> bool: + """Return whether query is an ancestor of base.""" + if base == query: + return True + return query in base.parents + + # check if path is an ancestor of cwd + if is_ancestor(Path.cwd(), Path(path).absolute()): + raise argparse.ArgumentTypeError(msg) + + # check symlinks for ancestors + if is_ancestor(Path.cwd().resolve(), Path(path).resolve()): + raise argparse.ArgumentTypeError(msg) + + return path + + +def wrap_session( + config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]] +) -> Union[int, ExitCode]: + """Skeleton command line program.""" + session = Session.from_config(config) + session.exitstatus = ExitCode.OK + initstate = 0 + try: + try: + config._do_configure() + initstate = 1 + config.hook.pytest_sessionstart(session=session) + initstate = 2 + session.exitstatus = doit(config, session) or 0 + except UsageError: + session.exitstatus = ExitCode.USAGE_ERROR + raise + except Failed: + session.exitstatus = ExitCode.TESTS_FAILED + except (KeyboardInterrupt, exit.Exception): + excinfo = _pytest._code.ExceptionInfo.from_current() + exitstatus: Union[int, ExitCode] = ExitCode.INTERRUPTED + if isinstance(excinfo.value, exit.Exception): + if excinfo.value.returncode is not None: + exitstatus = excinfo.value.returncode + if initstate < 2: + sys.stderr.write(f"{excinfo.typename}: {excinfo.value.msg}\n") + config.hook.pytest_keyboard_interrupt(excinfo=excinfo) + session.exitstatus = exitstatus + except BaseException: + session.exitstatus = ExitCode.INTERNAL_ERROR + excinfo = _pytest._code.ExceptionInfo.from_current() + try: + config.notify_exception(excinfo, config.option) + except exit.Exception as exc: + if exc.returncode is not None: + session.exitstatus = exc.returncode + sys.stderr.write(f"{type(exc).__name__}: {exc}\n") + else: + if isinstance(excinfo.value, SystemExit): + sys.stderr.write("mainloop: caught unexpected SystemExit!\n") + + finally: + # Explicitly break reference cycle. + excinfo = None # type: ignore + os.chdir(session.startpath) + if initstate >= 2: + try: + config.hook.pytest_sessionfinish( + session=session, exitstatus=session.exitstatus + ) + except exit.Exception as exc: + if exc.returncode is not None: + session.exitstatus = exc.returncode + sys.stderr.write(f"{type(exc).__name__}: {exc}\n") + config._ensure_unconfigure() + return session.exitstatus + + +def pytest_cmdline_main(config: Config) -> Union[int, ExitCode]: + return wrap_session(config, _main) + + +def _main(config: Config, session: "Session") -> Optional[Union[int, ExitCode]]: + """Default command line protocol for initialization, session, + running tests and reporting.""" + config.hook.pytest_collection(session=session) + config.hook.pytest_runtestloop(session=session) + + if session.testsfailed: + return ExitCode.TESTS_FAILED + elif session.testscollected == 0: + return ExitCode.NO_TESTS_COLLECTED + return None + + +def pytest_collection(session: "Session") -> None: + session.perform_collect() + + +def pytest_runtestloop(session: "Session") -> bool: + if session.testsfailed and not session.config.option.continue_on_collection_errors: + raise session.Interrupted( + "%d error%s during collection" + % (session.testsfailed, "s" if session.testsfailed != 1 else "") + ) + + if session.config.option.collectonly: + return True + + for i, item in enumerate(session.items): + nextitem = session.items[i + 1] if i + 1 < len(session.items) else None + item.config.hook.pytest_runtest_protocol(item=item, nextitem=nextitem) + if session.shouldfail: + raise session.Failed(session.shouldfail) + if session.shouldstop: + raise session.Interrupted(session.shouldstop) + return True + + +def _in_venv(path: Path) -> bool: + """Attempt to detect if ``path`` is the root of a Virtual Environment by + checking for the existence of the appropriate activate script.""" + bindir = path.joinpath("Scripts" if sys.platform.startswith("win") else "bin") + try: + if not bindir.is_dir(): + return False + except OSError: + return False + activates = ( + "activate", + "activate.csh", + "activate.fish", + "Activate", + "Activate.bat", + "Activate.ps1", + ) + return any(fname.name in activates for fname in bindir.iterdir()) + + +def pytest_ignore_collect(collection_path: Path, config: Config) -> Optional[bool]: + ignore_paths = config._getconftest_pathlist( + "collect_ignore", path=collection_path.parent, rootpath=config.rootpath + ) + ignore_paths = ignore_paths or [] + excludeopt = config.getoption("ignore") + if excludeopt: + ignore_paths.extend(absolutepath(x) for x in excludeopt) + + if collection_path in ignore_paths: + return True + + ignore_globs = config._getconftest_pathlist( + "collect_ignore_glob", path=collection_path.parent, rootpath=config.rootpath + ) + ignore_globs = ignore_globs or [] + excludeglobopt = config.getoption("ignore_glob") + if excludeglobopt: + ignore_globs.extend(absolutepath(x) for x in excludeglobopt) + + if any(fnmatch.fnmatch(str(collection_path), str(glob)) for glob in ignore_globs): + return True + + allow_in_venv = config.getoption("collect_in_virtualenv") + if not allow_in_venv and _in_venv(collection_path): + return True + return None + + +def pytest_collection_modifyitems(items: List[nodes.Item], config: Config) -> None: + deselect_prefixes = tuple(config.getoption("deselect") or []) + if not deselect_prefixes: + return + + remaining = [] + deselected = [] + for colitem in items: + if colitem.nodeid.startswith(deselect_prefixes): + deselected.append(colitem) + else: + remaining.append(colitem) + + if deselected: + config.hook.pytest_deselected(items=deselected) + items[:] = remaining + + +class FSHookProxy: + def __init__(self, pm: PytestPluginManager, remove_mods) -> None: + self.pm = pm + self.remove_mods = remove_mods + + def __getattr__(self, name: str): + x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods) + self.__dict__[name] = x + return x + + +class Interrupted(KeyboardInterrupt): + """Signals that the test run was interrupted.""" + + __module__ = "builtins" # For py3. + + +class Failed(Exception): + """Signals a stop as failed test run.""" + + +@dataclasses.dataclass +class _bestrelpath_cache(Dict[Path, str]): + __slots__ = ("path",) + + path: Path + + def __missing__(self, path: Path) -> str: + r = bestrelpath(self.path, path) + self[path] = r + return r + + +@final +class Session(nodes.FSCollector): + Interrupted = Interrupted + Failed = Failed + # Set on the session by runner.pytest_sessionstart. + _setupstate: SetupState + # Set on the session by fixtures.pytest_sessionstart. + _fixturemanager: FixtureManager + exitstatus: Union[int, ExitCode] + + def __init__(self, config: Config) -> None: + super().__init__( + path=config.rootpath, + fspath=None, + parent=None, + config=config, + session=self, + nodeid="", + ) + self.testsfailed = 0 + self.testscollected = 0 + self.shouldstop: Union[bool, str] = False + self.shouldfail: Union[bool, str] = False + self.trace = config.trace.root.get("collection") + self._initialpaths: FrozenSet[Path] = frozenset() + + self._bestrelpathcache: Dict[Path, str] = _bestrelpath_cache(config.rootpath) + + self.config.pluginmanager.register(self, name="session") + + @classmethod + def from_config(cls, config: Config) -> "Session": + session: Session = cls._create(config=config) + return session + + def __repr__(self) -> str: + return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % ( + self.__class__.__name__, + self.name, + getattr(self, "exitstatus", ""), + self.testsfailed, + self.testscollected, + ) + + @property + def startpath(self) -> Path: + """The path from which pytest was invoked. + + .. versionadded:: 7.0.0 + """ + return self.config.invocation_params.dir + + def _node_location_to_relpath(self, node_path: Path) -> str: + # bestrelpath is a quite slow function. + return self._bestrelpathcache[node_path] + + @hookimpl(tryfirst=True) + def pytest_collectstart(self) -> None: + if self.shouldfail: + raise self.Failed(self.shouldfail) + if self.shouldstop: + raise self.Interrupted(self.shouldstop) + + @hookimpl(tryfirst=True) + def pytest_runtest_logreport( + self, report: Union[TestReport, CollectReport] + ) -> None: + if report.failed and not hasattr(report, "wasxfail"): + self.testsfailed += 1 + maxfail = self.config.getvalue("maxfail") + if maxfail and self.testsfailed >= maxfail: + self.shouldfail = "stopping after %d failures" % (self.testsfailed) + + pytest_collectreport = pytest_runtest_logreport + + def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool: + # Optimization: Path(Path(...)) is much slower than isinstance. + path_ = path if isinstance(path, Path) else Path(path) + return path_ in self._initialpaths + + def gethookproxy(self, fspath: "os.PathLike[str]"): + # Optimization: Path(Path(...)) is much slower than isinstance. + path = fspath if isinstance(fspath, Path) else Path(fspath) + pm = self.config.pluginmanager + # Check if we have the common case of running + # hooks with all conftest.py files. + my_conftestmodules = pm._getconftestmodules( + path, + self.config.getoption("importmode"), + rootpath=self.config.rootpath, + ) + remove_mods = pm._conftest_plugins.difference(my_conftestmodules) + if remove_mods: + # One or more conftests are not in use at this fspath. + from .config.compat import PathAwareHookProxy + + proxy = PathAwareHookProxy(FSHookProxy(pm, remove_mods)) + else: + # All plugins are active for this fspath. + proxy = self.config.hook + return proxy + + def _recurse(self, direntry: "os.DirEntry[str]") -> bool: + if direntry.name == "__pycache__": + return False + fspath = Path(direntry.path) + ihook = self.gethookproxy(fspath.parent) + if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): + return False + norecursepatterns = self.config.getini("norecursedirs") + if any(fnmatch_ex(pat, fspath) for pat in norecursepatterns): + return False + return True + + def _collectfile( + self, fspath: Path, handle_dupes: bool = True + ) -> Sequence[nodes.Collector]: + assert ( + fspath.is_file() + ), "{!r} is not a file (isdir={!r}, exists={!r}, islink={!r})".format( + fspath, fspath.is_dir(), fspath.exists(), fspath.is_symlink() + ) + ihook = self.gethookproxy(fspath) + if not self.isinitpath(fspath): + if ihook.pytest_ignore_collect(collection_path=fspath, config=self.config): + return () + + if handle_dupes: + keepduplicates = self.config.getoption("keepduplicates") + if not keepduplicates: + duplicate_paths = self.config.pluginmanager._duplicatepaths + if fspath in duplicate_paths: + return () + else: + duplicate_paths.add(fspath) + + return ihook.pytest_collect_file(file_path=fspath, parent=self) # type: ignore[no-any-return] + + @overload + def perform_collect( + self, args: Optional[Sequence[str]] = ..., genitems: "Literal[True]" = ... + ) -> Sequence[nodes.Item]: + ... + + @overload + def perform_collect( # noqa: F811 + self, args: Optional[Sequence[str]] = ..., genitems: bool = ... + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: + ... + + def perform_collect( # noqa: F811 + self, args: Optional[Sequence[str]] = None, genitems: bool = True + ) -> Sequence[Union[nodes.Item, nodes.Collector]]: + """Perform the collection phase for this session. + + This is called by the default :hook:`pytest_collection` hook + implementation; see the documentation of this hook for more details. + For testing purposes, it may also be called directly on a fresh + ``Session``. + + This function normally recursively expands any collectors collected + from the session to their items, and only items are returned. For + testing purposes, this may be suppressed by passing ``genitems=False``, + in which case the return value contains these collectors unexpanded, + and ``session.items`` is empty. + """ + if args is None: + args = self.config.args + + self.trace("perform_collect", self, args) + self.trace.root.indent += 1 + + self._notfound: List[Tuple[str, Sequence[nodes.Collector]]] = [] + self._initial_parts: List[Tuple[Path, List[str]]] = [] + self.items: List[nodes.Item] = [] + + hook = self.config.hook + + items: Sequence[Union[nodes.Item, nodes.Collector]] = self.items + try: + initialpaths: List[Path] = [] + for arg in args: + fspath, parts = resolve_collection_argument( + self.config.invocation_params.dir, + arg, + as_pypath=self.config.option.pyargs, + ) + self._initial_parts.append((fspath, parts)) + initialpaths.append(fspath) + self._initialpaths = frozenset(initialpaths) + rep = collect_one_node(self) + self.ihook.pytest_collectreport(report=rep) + self.trace.root.indent -= 1 + if self._notfound: + errors = [] + for arg, collectors in self._notfound: + if collectors: + errors.append( + f"not found: {arg}\n(no name {arg!r} in any of {collectors!r})" + ) + else: + errors.append(f"found no collectors for {arg}") + + raise UsageError(*errors) + if not genitems: + items = rep.result + else: + if rep.passed: + for node in rep.result: + self.items.extend(self.genitems(node)) + + self.config.pluginmanager.check_pending() + hook.pytest_collection_modifyitems( + session=self, config=self.config, items=items + ) + finally: + hook.pytest_collection_finish(session=self) + + self.testscollected = len(items) + return items + + def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]: + from _pytest.python import Package + + # Keep track of any collected nodes in here, so we don't duplicate fixtures. + node_cache1: Dict[Path, Sequence[nodes.Collector]] = {} + node_cache2: Dict[Tuple[Type[nodes.Collector], Path], nodes.Collector] = {} + + # Keep track of any collected collectors in matchnodes paths, so they + # are not collected more than once. + matchnodes_cache: Dict[Tuple[Type[nodes.Collector], str], CollectReport] = {} + + # Dirnames of pkgs with dunder-init files. + pkg_roots: Dict[str, Package] = {} + + for argpath, names in self._initial_parts: + self.trace("processing argument", (argpath, names)) + self.trace.root.indent += 1 + + # Start with a Session root, and delve to argpath item (dir or file) + # and stack all Packages found on the way. + # No point in finding packages when collecting doctests. + if not self.config.getoption("doctestmodules", False): + pm = self.config.pluginmanager + for parent in (argpath, *argpath.parents): + if not pm._is_in_confcutdir(argpath): + break + + if parent.is_dir(): + pkginit = parent / "__init__.py" + if pkginit.is_file() and pkginit not in node_cache1: + col = self._collectfile(pkginit, handle_dupes=False) + if col: + if isinstance(col[0], Package): + pkg_roots[str(parent)] = col[0] + node_cache1[col[0].path] = [col[0]] + + # If it's a directory argument, recurse and look for any Subpackages. + # Let the Package collector deal with subnodes, don't collect here. + if argpath.is_dir(): + assert not names, f"invalid arg {(argpath, names)!r}" + + seen_dirs: Set[Path] = set() + for direntry in visit(str(argpath), self._recurse): + if not direntry.is_file(): + continue + + path = Path(direntry.path) + dirpath = path.parent + + if dirpath not in seen_dirs: + # Collect packages first. + seen_dirs.add(dirpath) + pkginit = dirpath / "__init__.py" + if pkginit.exists(): + for x in self._collectfile(pkginit): + yield x + if isinstance(x, Package): + pkg_roots[str(dirpath)] = x + if str(dirpath) in pkg_roots: + # Do not collect packages here. + continue + + for x in self._collectfile(path): + key2 = (type(x), x.path) + if key2 in node_cache2: + yield node_cache2[key2] + else: + node_cache2[key2] = x + yield x + else: + assert argpath.is_file() + + if argpath in node_cache1: + col = node_cache1[argpath] + else: + collect_root = pkg_roots.get(str(argpath.parent), self) + col = collect_root._collectfile(argpath, handle_dupes=False) + if col: + node_cache1[argpath] = col + + matching = [] + work: List[ + Tuple[Sequence[Union[nodes.Item, nodes.Collector]], Sequence[str]] + ] = [(col, names)] + while work: + self.trace("matchnodes", col, names) + self.trace.root.indent += 1 + + matchnodes, matchnames = work.pop() + for node in matchnodes: + if not matchnames: + matching.append(node) + continue + if not isinstance(node, nodes.Collector): + continue + key = (type(node), node.nodeid) + if key in matchnodes_cache: + rep = matchnodes_cache[key] + else: + rep = collect_one_node(node) + matchnodes_cache[key] = rep + if rep.passed: + submatchnodes = [] + for r in rep.result: + # TODO: Remove parametrized workaround once collection structure contains + # parametrization. + if ( + r.name == matchnames[0] + or r.name.split("[")[0] == matchnames[0] + ): + submatchnodes.append(r) + if submatchnodes: + work.append((submatchnodes, matchnames[1:])) + else: + # Report collection failures here to avoid failing to run some test + # specified in the command line because the module could not be + # imported (#134). + node.ihook.pytest_collectreport(report=rep) + + self.trace("matchnodes finished -> ", len(matching), "nodes") + self.trace.root.indent -= 1 + + if not matching: + report_arg = "::".join((str(argpath), *names)) + self._notfound.append((report_arg, col)) + continue + + # If __init__.py was the only file requested, then the matched + # node will be the corresponding Package (by default), and the + # first yielded item will be the __init__ Module itself, so + # just use that. If this special case isn't taken, then all the + # files in the package will be yielded. + if argpath.name == "__init__.py" and isinstance(matching[0], Package): + try: + yield next(iter(matching[0].collect())) + except StopIteration: + # The package collects nothing with only an __init__.py + # file in it, which gets ignored by the default + # "python_files" option. + pass + continue + + yield from matching + + self.trace.root.indent -= 1 + + def genitems( + self, node: Union[nodes.Item, nodes.Collector] + ) -> Iterator[nodes.Item]: + self.trace("genitems", node) + if isinstance(node, nodes.Item): + node.ihook.pytest_itemcollected(item=node) + yield node + else: + assert isinstance(node, nodes.Collector) + rep = collect_one_node(node) + if rep.passed: + for subnode in rep.result: + yield from self.genitems(subnode) + node.ihook.pytest_collectreport(report=rep) + + +def search_pypath(module_name: str) -> str: + """Search sys.path for the given a dotted module name, and return its file system path.""" + try: + spec = importlib.util.find_spec(module_name) + # AttributeError: looks like package module, but actually filename + # ImportError: module does not exist + # ValueError: not a module name + except (AttributeError, ImportError, ValueError): + return module_name + if spec is None or spec.origin is None or spec.origin == "namespace": + return module_name + elif spec.submodule_search_locations: + return os.path.dirname(spec.origin) + else: + return spec.origin + + +def resolve_collection_argument( + invocation_path: Path, arg: str, *, as_pypath: bool = False +) -> Tuple[Path, List[str]]: + """Parse path arguments optionally containing selection parts and return (fspath, names). + + Command-line arguments can point to files and/or directories, and optionally contain + parts for specific tests selection, for example: + + "pkg/tests/test_foo.py::TestClass::test_foo" + + This function ensures the path exists, and returns a tuple: + + (Path("/full/path/to/pkg/tests/test_foo.py"), ["TestClass", "test_foo"]) + + When as_pypath is True, expects that the command-line argument actually contains + module paths instead of file-system paths: + + "pkg.tests.test_foo::TestClass::test_foo" + + In which case we search sys.path for a matching module, and then return the *path* to the + found module. + + If the path doesn't exist, raise UsageError. + If the path is a directory and selection parts are present, raise UsageError. + """ + base, squacket, rest = str(arg).partition("[") + strpath, *parts = base.split("::") + if parts: + parts[-1] = f"{parts[-1]}{squacket}{rest}" + if as_pypath: + strpath = search_pypath(strpath) + fspath = invocation_path / strpath + fspath = absolutepath(fspath) + if not fspath.exists(): + msg = ( + "module or package not found: {arg} (missing __init__.py?)" + if as_pypath + else "file or directory not found: {arg}" + ) + raise UsageError(msg.format(arg=arg)) + if parts and fspath.is_dir(): + msg = ( + "package argument cannot contain :: selection parts: {arg}" + if as_pypath + else "directory argument cannot contain :: selection parts: {arg}" + ) + raise UsageError(msg.format(arg=arg)) + return fspath, parts diff --git a/testing/test_config.py b/testing/test_config.py index 6754cd15b..33b989339 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1,2135 +1,2146 @@ -import dataclasses -import os -import re -import sys -import textwrap -from pathlib import Path -from typing import Dict -from typing import List -from typing import Sequence -from typing import Tuple -from typing import Type -from typing import Union - -import _pytest._code -import pytest -from _pytest.compat import importlib_metadata -from _pytest.config import _get_plugin_specs_as_list -from _pytest.config import _iter_rewritable_modules -from _pytest.config import _strtobool -from _pytest.config import Config -from _pytest.config import ConftestImportFailure -from _pytest.config import ExitCode -from _pytest.config import parse_warning_filter -from _pytest.config.exceptions import UsageError -from _pytest.config.findpaths import determine_setup -from _pytest.config.findpaths import get_common_ancestor -from _pytest.config.findpaths import locate_config -from _pytest.monkeypatch import MonkeyPatch -from _pytest.pathlib import absolutepath -from _pytest.pytester import Pytester - - -class TestParseIni: - @pytest.mark.parametrize( - "section, filename", [("pytest", "pytest.ini"), ("tool:pytest", "setup.cfg")] - ) - def test_getcfg_and_config( - self, - pytester: Pytester, - tmp_path: Path, - section: str, - filename: str, - monkeypatch: MonkeyPatch, - ) -> None: - sub = tmp_path / "sub" - sub.mkdir() - monkeypatch.chdir(sub) - (tmp_path / filename).write_text( - textwrap.dedent( - """\ - [{section}] - name = value - """.format( - section=section - ) - ), - encoding="utf-8", - ) - _, _, cfg = locate_config([sub]) - assert cfg["name"] == "value" - config = pytester.parseconfigure(str(sub)) - assert config.inicfg["name"] == "value" - - def test_setupcfg_uses_toolpytest_with_pytest(self, pytester: Pytester) -> None: - p1 = pytester.makepyfile("def test(): pass") - pytester.makefile( - ".cfg", - setup=""" - [tool:pytest] - testpaths=%s - [pytest] - testpaths=ignored - """ - % p1.name, - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["configfile: setup.cfg", "* 1 passed in *"]) - assert result.ret == 0 - - def test_append_parse_args( - self, pytester: Pytester, tmp_path: Path, monkeypatch: MonkeyPatch - ) -> None: - monkeypatch.setenv("PYTEST_ADDOPTS", '--color no -rs --tb="short"') - tmp_path.joinpath("pytest.ini").write_text( - textwrap.dedent( - """\ - [pytest] - addopts = --verbose - """ - ) - ) - config = pytester.parseconfig(tmp_path) - assert config.option.color == "no" - assert config.option.reportchars == "s" - assert config.option.tbstyle == "short" - assert config.option.verbose - - def test_tox_ini_wrong_version(self, pytester: Pytester) -> None: - pytester.makefile( - ".ini", - tox=""" - [pytest] - minversion=999.0 - """, - ) - result = pytester.runpytest() - assert result.ret != 0 - result.stderr.fnmatch_lines( - ["*tox.ini: 'minversion' requires pytest-999.0, actual pytest-*"] - ) - - @pytest.mark.parametrize( - "section, name", - [ - ("tool:pytest", "setup.cfg"), - ("pytest", "tox.ini"), - ("pytest", "pytest.ini"), - ("pytest", ".pytest.ini"), - ], - ) - def test_ini_names(self, pytester: Pytester, name, section) -> None: - pytester.path.joinpath(name).write_text( - textwrap.dedent( - """ - [{section}] - minversion = 3.36 - """.format( - section=section - ) - ) - ) - config = pytester.parseconfig() - assert config.getini("minversion") == "3.36" - - def test_pyproject_toml(self, pytester: Pytester) -> None: - pytester.makepyprojecttoml( - """ - [tool.pytest.ini_options] - minversion = "1.0" - """ - ) - config = pytester.parseconfig() - assert config.getini("minversion") == "1.0" - - def test_toxini_before_lower_pytestini(self, pytester: Pytester) -> None: - sub = pytester.mkdir("sub") - sub.joinpath("tox.ini").write_text( - textwrap.dedent( - """ - [pytest] - minversion = 2.0 - """ - ) - ) - pytester.path.joinpath("pytest.ini").write_text( - textwrap.dedent( - """ - [pytest] - minversion = 1.5 - """ - ) - ) - config = pytester.parseconfigure(sub) - assert config.getini("minversion") == "2.0" - - def test_ini_parse_error(self, pytester: Pytester) -> None: - pytester.path.joinpath("pytest.ini").write_text("addopts = -x") - result = pytester.runpytest() - assert result.ret != 0 - result.stderr.fnmatch_lines("ERROR: *pytest.ini:1: no section header defined") - - def test_toml_parse_error(self, pytester: Pytester) -> None: - pytester.makepyprojecttoml( - """ - \\" - """ - ) - result = pytester.runpytest() - assert result.ret != 0 - result.stderr.fnmatch_lines("ERROR: *pyproject.toml: Invalid statement*") - - @pytest.mark.xfail(reason="probably not needed") - def test_confcutdir(self, pytester: Pytester) -> None: - sub = pytester.mkdir("sub") - os.chdir(sub) - pytester.makeini( - """ - [pytest] - addopts = --qwe - """ - ) - result = pytester.inline_run("--confcutdir=.") - assert result.ret == 0 - - @pytest.mark.parametrize( - "ini_file_text, invalid_keys, warning_output, exception_text", - [ - pytest.param( - """ - [pytest] - unknown_ini = value1 - another_unknown_ini = value2 - """, - ["unknown_ini", "another_unknown_ini"], - [ - "=*= warnings summary =*=", - "*PytestConfigWarning:*Unknown config option: another_unknown_ini", - "*PytestConfigWarning:*Unknown config option: unknown_ini", - ], - "Unknown config option: another_unknown_ini", - id="2-unknowns", - ), - pytest.param( - """ - [pytest] - unknown_ini = value1 - minversion = 5.0.0 - """, - ["unknown_ini"], - [ - "=*= warnings summary =*=", - "*PytestConfigWarning:*Unknown config option: unknown_ini", - ], - "Unknown config option: unknown_ini", - id="1-unknown", - ), - pytest.param( - """ - [some_other_header] - unknown_ini = value1 - [pytest] - minversion = 5.0.0 - """, - [], - [], - "", - id="unknown-in-other-header", - ), - pytest.param( - """ - [pytest] - minversion = 5.0.0 - """, - [], - [], - "", - id="no-unknowns", - ), - pytest.param( - """ - [pytest] - conftest_ini_key = 1 - """, - [], - [], - "", - id="1-known", - ), - ], - ) - @pytest.mark.filterwarnings("default") - def test_invalid_config_options( - self, - pytester: Pytester, - ini_file_text, - invalid_keys, - warning_output, - exception_text, - ) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("conftest_ini_key", "") - """ - ) - pytester.makepyfile("def test(): pass") - pytester.makeini(ini_file_text) - - config = pytester.parseconfig() - assert sorted(config._get_unknown_ini_keys()) == sorted(invalid_keys) - - result = pytester.runpytest() - result.stdout.fnmatch_lines(warning_output) - - result = pytester.runpytest("--strict-config") - if exception_text: - result.stderr.fnmatch_lines("ERROR: " + exception_text) - assert result.ret == pytest.ExitCode.USAGE_ERROR - else: - result.stderr.no_fnmatch_line(exception_text) - assert result.ret == pytest.ExitCode.OK - - @pytest.mark.filterwarnings("default") - def test_silence_unknown_key_warning(self, pytester: Pytester) -> None: - """Unknown config key warnings can be silenced using filterwarnings (#7620)""" - pytester.makeini( - """ - [pytest] - filterwarnings = - ignore:Unknown config option:pytest.PytestConfigWarning - foobar=1 - """ - ) - result = pytester.runpytest() - result.stdout.no_fnmatch_line("*PytestConfigWarning*") - - @pytest.mark.filterwarnings("default::pytest.PytestConfigWarning") - def test_disable_warnings_plugin_disables_config_warnings( - self, pytester: Pytester - ) -> None: - """Disabling 'warnings' plugin also disables config time warnings""" - pytester.makeconftest( - """ - import pytest - def pytest_configure(config): - config.issue_config_time_warning( - pytest.PytestConfigWarning("custom config warning"), - stacklevel=2, - ) - """ - ) - result = pytester.runpytest("-pno:warnings") - result.stdout.no_fnmatch_line("*PytestConfigWarning*") - - @pytest.mark.parametrize( - "ini_file_text, plugin_version, exception_text", - [ - pytest.param( - """ - [pytest] - required_plugins = a z - """, - "1.5", - "Missing required plugins: a, z", - id="2-missing", - ), - pytest.param( - """ - [pytest] - required_plugins = a z myplugin - """, - "1.5", - "Missing required plugins: a, z", - id="2-missing-1-ok", - ), - pytest.param( - """ - [pytest] - required_plugins = myplugin - """, - "1.5", - None, - id="1-ok", - ), - pytest.param( - """ - [pytest] - required_plugins = myplugin==1.5 - """, - "1.5", - None, - id="1-ok-pin-exact", - ), - pytest.param( - """ - [pytest] - required_plugins = myplugin>1.0,<2.0 - """, - "1.5", - None, - id="1-ok-pin-loose", - ), - pytest.param( - """ - [pytest] - required_plugins = myplugin - """, - "1.5a1", - None, - id="1-ok-prerelease", - ), - pytest.param( - """ - [pytest] - required_plugins = myplugin==1.6 - """, - "1.5", - "Missing required plugins: myplugin==1.6", - id="missing-version", - ), - pytest.param( - """ - [pytest] - required_plugins = myplugin==1.6 other==1.0 - """, - "1.5", - "Missing required plugins: myplugin==1.6, other==1.0", - id="missing-versions", - ), - pytest.param( - """ - [some_other_header] - required_plugins = won't be triggered - [pytest] - """, - "1.5", - None, - id="invalid-header", - ), - ], - ) - def test_missing_required_plugins( - self, - pytester: Pytester, - monkeypatch: MonkeyPatch, - ini_file_text: str, - plugin_version: str, - exception_text: str, - ) -> None: - """Check 'required_plugins' option with various settings. - - This test installs a mock "myplugin-1.5" which is used in the parametrized test cases. - """ - - @dataclasses.dataclass - class DummyEntryPoint: - name: str - module: str - group: str = "pytest11" - - def load(self): - __import__(self.module) - return sys.modules[self.module] - - entry_points = [ - DummyEntryPoint("myplugin1", "myplugin1_module"), - ] - - @dataclasses.dataclass - class DummyDist: - entry_points: object - files: object = () - version: str = plugin_version - - @property - def metadata(self): - return {"name": "myplugin"} - - def my_dists(): - return [DummyDist(entry_points)] - - pytester.makepyfile(myplugin1_module="# my plugin module") - pytester.syspathinsert() - - monkeypatch.setattr(importlib_metadata, "distributions", my_dists) - monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) - - pytester.makeini(ini_file_text) - - if exception_text: - with pytest.raises(pytest.UsageError, match=exception_text): - pytester.parseconfig() - else: - pytester.parseconfig() - - def test_early_config_cmdline( - self, pytester: Pytester, monkeypatch: MonkeyPatch - ) -> None: - """early_config contains options registered by third-party plugins. - - This is a regression involving pytest-cov (and possibly others) introduced in #7700. - """ - pytester.makepyfile( - myplugin=""" - def pytest_addoption(parser): - parser.addoption('--foo', default=None, dest='foo') - - def pytest_load_initial_conftests(early_config, parser, args): - assert early_config.known_args_namespace.foo == "1" - """ - ) - monkeypatch.setenv("PYTEST_PLUGINS", "myplugin") - pytester.syspathinsert() - result = pytester.runpytest("--foo=1") - result.stdout.fnmatch_lines("* no tests ran in *") - - -class TestConfigCmdlineParsing: - def test_parsing_again_fails(self, pytester: Pytester) -> None: - config = pytester.parseconfig() - pytest.raises(AssertionError, lambda: config.parse([])) - - def test_explicitly_specified_config_file_is_loaded( - self, pytester: Pytester - ) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("custom", "") - """ - ) - pytester.makeini( - """ - [pytest] - custom = 0 - """ - ) - pytester.makefile( - ".ini", - custom=""" - [pytest] - custom = 1 - """, - ) - config = pytester.parseconfig("-c", "custom.ini") - assert config.getini("custom") == "1" - - pytester.makefile( - ".cfg", - custom_tool_pytest_section=""" - [tool:pytest] - custom = 1 - """, - ) - config = pytester.parseconfig("-c", "custom_tool_pytest_section.cfg") - assert config.getini("custom") == "1" - - pytester.makefile( - ".toml", - custom=""" - [tool.pytest.ini_options] - custom = 1 - value = [ - ] # this is here on purpose, as it makes this an invalid '.ini' file - """, - ) - config = pytester.parseconfig("-c", "custom.toml") - assert config.getini("custom") == "1" - - def test_absolute_win32_path(self, pytester: Pytester) -> None: - temp_ini_file = pytester.makefile( - ".ini", - custom=""" - [pytest] - addopts = --version - """, - ) - from os.path import normpath - - temp_ini_file_norm = normpath(str(temp_ini_file)) - ret = pytest.main(["-c", temp_ini_file_norm]) - assert ret == ExitCode.OK - - -class TestConfigAPI: - def test_config_trace(self, pytester: Pytester) -> None: - config = pytester.parseconfig() - values: List[str] = [] - config.trace.root.setwriter(values.append) - config.trace("hello") - assert len(values) == 1 - assert values[0] == "hello [config]\n" - - def test_config_getoption(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addoption("--hello", "-X", dest="hello") - """ - ) - config = pytester.parseconfig("--hello=this") - for x in ("hello", "--hello", "-X"): - assert config.getoption(x) == "this" - pytest.raises(ValueError, config.getoption, "qweqwe") - - def test_config_getoption_unicode(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addoption('--hello', type=str) - """ - ) - config = pytester.parseconfig("--hello=this") - assert config.getoption("hello") == "this" - - def test_config_getvalueorskip(self, pytester: Pytester) -> None: - config = pytester.parseconfig() - pytest.raises(pytest.skip.Exception, config.getvalueorskip, "hello") - verbose = config.getvalueorskip("verbose") - assert verbose == config.option.verbose - - def test_config_getvalueorskip_None(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addoption("--hello") - """ - ) - config = pytester.parseconfig() - with pytest.raises(pytest.skip.Exception): - config.getvalueorskip("hello") - - def test_getoption(self, pytester: Pytester) -> None: - config = pytester.parseconfig() - with pytest.raises(ValueError): - config.getvalue("x") - assert config.getoption("x", 1) == 1 - - def test_getconftest_pathlist(self, pytester: Pytester, tmp_path: Path) -> None: - somepath = tmp_path.joinpath("x", "y", "z") - p = tmp_path.joinpath("conftest.py") - p.write_text(f"mylist = {['.', str(somepath)]}") - config = pytester.parseconfigure(p) - assert ( - config._getconftest_pathlist("notexist", path=tmp_path, rootpath=tmp_path) - is None - ) - pl = ( - config._getconftest_pathlist("mylist", path=tmp_path, rootpath=tmp_path) - or [] - ) - print(pl) - assert len(pl) == 2 - assert pl[0] == tmp_path - assert pl[1] == somepath - - @pytest.mark.parametrize("maybe_type", ["not passed", "None", '"string"']) - def test_addini(self, pytester: Pytester, maybe_type: str) -> None: - if maybe_type == "not passed": - type_string = "" - else: - type_string = f", {maybe_type}" - - pytester.makeconftest( - f""" - def pytest_addoption(parser): - parser.addini("myname", "my new ini value"{type_string}) - """ - ) - pytester.makeini( - """ - [pytest] - myname=hello - """ - ) - config = pytester.parseconfig() - val = config.getini("myname") - assert val == "hello" - pytest.raises(ValueError, config.getini, "other") - - @pytest.mark.parametrize("config_type", ["ini", "pyproject"]) - def test_addini_paths(self, pytester: Pytester, config_type: str) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("paths", "my new ini value", type="paths") - parser.addini("abc", "abc value") - """ - ) - if config_type == "ini": - inipath = pytester.makeini( - """ - [pytest] - paths=hello world/sub.py - """ - ) - elif config_type == "pyproject": - inipath = pytester.makepyprojecttoml( - """ - [tool.pytest.ini_options] - paths=["hello", "world/sub.py"] - """ - ) - config = pytester.parseconfig() - values = config.getini("paths") - assert len(values) == 2 - assert values[0] == inipath.parent.joinpath("hello") - assert values[1] == inipath.parent.joinpath("world/sub.py") - pytest.raises(ValueError, config.getini, "other") - - def make_conftest_for_args(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("args", "new args", type="args") - parser.addini("a2", "", "args", default="1 2 3".split()) - """ - ) - - def test_addini_args_ini_files(self, pytester: Pytester) -> None: - self.make_conftest_for_args(pytester) - pytester.makeini( - """ - [pytest] - args=123 "123 hello" "this" - """ - ) - self.check_config_args(pytester) - - def test_addini_args_pyproject_toml(self, pytester: Pytester) -> None: - self.make_conftest_for_args(pytester) - pytester.makepyprojecttoml( - """ - [tool.pytest.ini_options] - args = ["123", "123 hello", "this"] - """ - ) - self.check_config_args(pytester) - - def check_config_args(self, pytester: Pytester) -> None: - config = pytester.parseconfig() - values = config.getini("args") - assert values == ["123", "123 hello", "this"] - values = config.getini("a2") - assert values == list("123") - - def make_conftest_for_linelist(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("xy", "", type="linelist") - parser.addini("a2", "", "linelist") - """ - ) - - def test_addini_linelist_ini_files(self, pytester: Pytester) -> None: - self.make_conftest_for_linelist(pytester) - pytester.makeini( - """ - [pytest] - xy= 123 345 - second line - """ - ) - self.check_config_linelist(pytester) - - def test_addini_linelist_pprojecttoml(self, pytester: Pytester) -> None: - self.make_conftest_for_linelist(pytester) - pytester.makepyprojecttoml( - """ - [tool.pytest.ini_options] - xy = ["123 345", "second line"] - """ - ) - self.check_config_linelist(pytester) - - def check_config_linelist(self, pytester: Pytester) -> None: - config = pytester.parseconfig() - values = config.getini("xy") - assert len(values) == 2 - assert values == ["123 345", "second line"] - values = config.getini("a2") - assert values == [] - - @pytest.mark.parametrize( - "str_val, bool_val", [("True", True), ("no", False), ("no-ini", True)] - ) - def test_addini_bool( - self, pytester: Pytester, str_val: str, bool_val: bool - ) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("strip", "", type="bool", default=True) - """ - ) - if str_val != "no-ini": - pytester.makeini( - """ - [pytest] - strip=%s - """ - % str_val - ) - config = pytester.parseconfig() - assert config.getini("strip") is bool_val - - def test_addinivalue_line_existing(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("xy", "", type="linelist") - """ - ) - pytester.makeini( - """ - [pytest] - xy= 123 - """ - ) - config = pytester.parseconfig() - values = config.getini("xy") - assert len(values) == 1 - assert values == ["123"] - config.addinivalue_line("xy", "456") - values = config.getini("xy") - assert len(values) == 2 - assert values == ["123", "456"] - - def test_addinivalue_line_new(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("xy", "", type="linelist") - """ - ) - config = pytester.parseconfig() - assert not config.getini("xy") - config.addinivalue_line("xy", "456") - values = config.getini("xy") - assert len(values) == 1 - assert values == ["456"] - config.addinivalue_line("xy", "123") - values = config.getini("xy") - assert len(values) == 2 - assert values == ["456", "123"] - - def test_confcutdir_check_isdir(self, pytester: Pytester) -> None: - """Give an error if --confcutdir is not a valid directory (#2078)""" - exp_match = r"^--confcutdir must be a directory, given: " - with pytest.raises(pytest.UsageError, match=exp_match): - pytester.parseconfig("--confcutdir", pytester.path.joinpath("file")) - with pytest.raises(pytest.UsageError, match=exp_match): - pytester.parseconfig("--confcutdir", pytester.path.joinpath("nonexistent")) - - p = pytester.mkdir("dir") - config = pytester.parseconfig("--confcutdir", p) - assert config.getoption("confcutdir") == str(p) - - @pytest.mark.parametrize( - "names, expected", - [ - # dist-info based distributions root are files as will be put in PYTHONPATH - (["bar.py"], ["bar"]), - (["foo/bar.py"], ["bar"]), - (["foo/bar.pyc"], []), - (["foo/__init__.py"], ["foo"]), - (["bar/__init__.py", "xz.py"], ["bar", "xz"]), - (["setup.py"], []), - # egg based distributions root contain the files from the dist root - (["src/bar/__init__.py"], ["bar"]), - (["src/bar/__init__.py", "setup.py"], ["bar"]), - (["source/python/bar/__init__.py", "setup.py"], ["bar"]), - # editable installation finder modules - (["__editable___xyz_finder.py"], []), - (["bar/__init__.py", "__editable___xyz_finder.py"], ["bar"]), - ], - ) - def test_iter_rewritable_modules(self, names, expected) -> None: - assert list(_iter_rewritable_modules(names)) == expected - - -class TestConfigFromdictargs: - def test_basic_behavior(self, _sys_snapshot) -> None: - option_dict = {"verbose": 444, "foo": "bar", "capture": "no"} - args = ["a", "b"] - - config = Config.fromdictargs(option_dict, args) - with pytest.raises(AssertionError): - config.parse(["should refuse to parse again"]) - assert config.option.verbose == 444 - assert config.option.foo == "bar" - assert config.option.capture == "no" - assert config.args == args - - def test_invocation_params_args(self, _sys_snapshot) -> None: - """Show that fromdictargs can handle args in their "orig" format""" - option_dict: Dict[str, object] = {} - args = ["-vvvv", "-s", "a", "b"] - - config = Config.fromdictargs(option_dict, args) - assert config.args == ["a", "b"] - assert config.invocation_params.args == tuple(args) - assert config.option.verbose == 4 - assert config.option.capture == "no" - - def test_inifilename(self, tmp_path: Path) -> None: - d1 = tmp_path.joinpath("foo") - d1.mkdir() - p1 = d1.joinpath("bar.ini") - p1.touch() - p1.write_text( - textwrap.dedent( - """\ - [pytest] - name = value - """ - ) - ) - - inifilename = "../../foo/bar.ini" - option_dict = {"inifilename": inifilename, "capture": "no"} - - cwd = tmp_path.joinpath("a/b") - cwd.mkdir(parents=True) - p2 = cwd.joinpath("pytest.ini") - p2.touch() - p2.write_text( - textwrap.dedent( - """\ - [pytest] - name = wrong-value - should_not_be_set = true - """ - ) - ) - with MonkeyPatch.context() as mp: - mp.chdir(cwd) - config = Config.fromdictargs(option_dict, ()) - inipath = absolutepath(inifilename) - - assert config.args == [str(cwd)] - assert config.option.inifilename == inifilename - assert config.option.capture == "no" - - # this indicates this is the file used for getting configuration values - assert config.inipath == inipath - assert config.inicfg.get("name") == "value" - assert config.inicfg.get("should_not_be_set") is None - - -def test_options_on_small_file_do_not_blow_up(pytester: Pytester) -> None: - def runfiletest(opts: Sequence[str]) -> None: - reprec = pytester.inline_run(*opts) - passed, skipped, failed = reprec.countoutcomes() - assert failed == 2 - assert skipped == passed == 0 - - path = str( - pytester.makepyfile( - """ - def test_f1(): assert 0 - def test_f2(): assert 0 - """ - ) - ) - - runfiletest([path]) - runfiletest(["-l", path]) - runfiletest(["-s", path]) - runfiletest(["--tb=no", path]) - runfiletest(["--tb=short", path]) - runfiletest(["--tb=long", path]) - runfiletest(["--fulltrace", path]) - runfiletest(["--traceconfig", path]) - runfiletest(["-v", path]) - runfiletest(["-v", "-v", path]) - - -def test_preparse_ordering_with_setuptools( - pytester: Pytester, monkeypatch: MonkeyPatch -) -> None: - monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) - - class EntryPoint: - name = "mytestplugin" - group = "pytest11" - - def load(self): - class PseudoPlugin: - x = 42 - - return PseudoPlugin() - - class Dist: - files = () - metadata = {"name": "foo"} - entry_points = (EntryPoint(),) - - def my_dists(): - return (Dist,) - - monkeypatch.setattr(importlib_metadata, "distributions", my_dists) - pytester.makeconftest( - """ - pytest_plugins = "mytestplugin", - """ - ) - monkeypatch.setenv("PYTEST_PLUGINS", "mytestplugin") - config = pytester.parseconfig() - plugin = config.pluginmanager.getplugin("mytestplugin") - assert plugin.x == 42 - - -def test_setuptools_importerror_issue1479( - pytester: Pytester, monkeypatch: MonkeyPatch -) -> None: - monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) - - class DummyEntryPoint: - name = "mytestplugin" - group = "pytest11" - - def load(self): - raise ImportError("Don't hide me!") - - class Distribution: - version = "1.0" - files = ("foo.txt",) - metadata = {"name": "foo"} - entry_points = (DummyEntryPoint(),) - - def distributions(): - return (Distribution(),) - - monkeypatch.setattr(importlib_metadata, "distributions", distributions) - with pytest.raises(ImportError): - pytester.parseconfig() - - -def test_importlib_metadata_broken_distribution( - pytester: Pytester, monkeypatch: MonkeyPatch -) -> None: - """Integration test for broken distributions with 'files' metadata being None (#5389)""" - monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) - - class DummyEntryPoint: - name = "mytestplugin" - group = "pytest11" - - def load(self): - return object() - - class Distribution: - version = "1.0" - files = None - metadata = {"name": "foo"} - entry_points = (DummyEntryPoint(),) - - def distributions(): - return (Distribution(),) - - monkeypatch.setattr(importlib_metadata, "distributions", distributions) - pytester.parseconfig() - - -@pytest.mark.parametrize("block_it", [True, False]) -def test_plugin_preparse_prevents_setuptools_loading( - pytester: Pytester, monkeypatch: MonkeyPatch, block_it: bool -) -> None: - monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) - - plugin_module_placeholder = object() - - class DummyEntryPoint: - name = "mytestplugin" - group = "pytest11" - - def load(self): - return plugin_module_placeholder - - class Distribution: - version = "1.0" - files = ("foo.txt",) - metadata = {"name": "foo"} - entry_points = (DummyEntryPoint(),) - - def distributions(): - return (Distribution(),) - - monkeypatch.setattr(importlib_metadata, "distributions", distributions) - args = ("-p", "no:mytestplugin") if block_it else () - config = pytester.parseconfig(*args) - config.pluginmanager.import_plugin("mytestplugin") - if block_it: - assert "mytestplugin" not in sys.modules - assert config.pluginmanager.get_plugin("mytestplugin") is None - else: - assert ( - config.pluginmanager.get_plugin("mytestplugin") is plugin_module_placeholder - ) - - -@pytest.mark.parametrize( - "parse_args,should_load", [(("-p", "mytestplugin"), True), ((), False)] -) -def test_disable_plugin_autoload( - pytester: Pytester, - monkeypatch: MonkeyPatch, - parse_args: Union[Tuple[str, str], Tuple[()]], - should_load: bool, -) -> None: - class DummyEntryPoint: - project_name = name = "mytestplugin" - group = "pytest11" - version = "1.0" - - def load(self): - return sys.modules[self.name] - - class Distribution: - metadata = {"name": "foo"} - entry_points = (DummyEntryPoint(),) - files = () - - class PseudoPlugin: - x = 42 - - attrs_used = [] - - def __getattr__(self, name): - assert name == "__loader__" - self.attrs_used.append(name) - return object() - - def distributions(): - return (Distribution(),) - - monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") - monkeypatch.setattr(importlib_metadata, "distributions", distributions) - monkeypatch.setitem(sys.modules, "mytestplugin", PseudoPlugin()) # type: ignore[misc] - config = pytester.parseconfig(*parse_args) - has_loaded = config.pluginmanager.get_plugin("mytestplugin") is not None - assert has_loaded == should_load - if should_load: - assert PseudoPlugin.attrs_used == ["__loader__"] - else: - assert PseudoPlugin.attrs_used == [] - - -def test_plugin_loading_order(pytester: Pytester) -> None: - """Test order of plugin loading with `-p`.""" - p1 = pytester.makepyfile( - """ - def test_terminal_plugin(request): - import myplugin - assert myplugin.terminal_plugin == [False, True] - """, - **{ - "myplugin": """ - terminal_plugin = [] - - def pytest_configure(config): - terminal_plugin.append(bool(config.pluginmanager.get_plugin("terminalreporter"))) - - def pytest_sessionstart(session): - config = session.config - terminal_plugin.append(bool(config.pluginmanager.get_plugin("terminalreporter"))) - """ - }, - ) - pytester.syspathinsert() - result = pytester.runpytest("-p", "myplugin", str(p1)) - assert result.ret == 0 - - -def test_cmdline_processargs_simple(pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_cmdline_preparse(args): - args.append("-h") - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*pytest*", "*-h*"]) - - -def test_invalid_options_show_extra_information(pytester: Pytester) -> None: - """Display extra information when pytest exits due to unrecognized - options in the command-line.""" - pytester.makeini( - """ - [pytest] - addopts = --invalid-option - """ - ) - result = pytester.runpytest() - result.stderr.fnmatch_lines( - [ - "*error: unrecognized arguments: --invalid-option*", - "* inifile: %s*" % pytester.path.joinpath("tox.ini"), - "* rootdir: %s*" % pytester.path, - ] - ) - - -@pytest.mark.parametrize( - "args", - [ - ["dir1", "dir2", "-v"], - ["dir1", "-v", "dir2"], - ["dir2", "-v", "dir1"], - ["-v", "dir2", "dir1"], - ], -) -def test_consider_args_after_options_for_rootdir( - pytester: Pytester, args: List[str] -) -> None: - """ - Consider all arguments in the command-line for rootdir - discovery, even if they happen to occur after an option. #949 - """ - # replace "dir1" and "dir2" from "args" into their real directory - root = pytester.mkdir("myroot") - d1 = root.joinpath("dir1") - d1.mkdir() - d2 = root.joinpath("dir2") - d2.mkdir() - for i, arg in enumerate(args): - if arg == "dir1": - args[i] = str(d1) - elif arg == "dir2": - args[i] = str(d2) - with MonkeyPatch.context() as mp: - mp.chdir(root) - result = pytester.runpytest(*args) - result.stdout.fnmatch_lines(["*rootdir: *myroot"]) - - -def test_toolongargs_issue224(pytester: Pytester) -> None: - result = pytester.runpytest("-m", "hello" * 500) - assert result.ret == ExitCode.NO_TESTS_COLLECTED - - -def test_config_in_subdirectory_colon_command_line_issue2148( - pytester: Pytester, -) -> None: - conftest_source = """ - def pytest_addoption(parser): - parser.addini('foo', 'foo') - """ - - pytester.makefile( - ".ini", - **{"pytest": "[pytest]\nfoo = root", "subdir/pytest": "[pytest]\nfoo = subdir"}, - ) - - pytester.makepyfile( - **{ - "conftest": conftest_source, - "subdir/conftest": conftest_source, - "subdir/test_foo": """\ - def test_foo(pytestconfig): - assert pytestconfig.getini('foo') == 'subdir' - """, - } - ) - - result = pytester.runpytest("subdir/test_foo.py::test_foo") - assert result.ret == 0 - - -def test_notify_exception(pytester: Pytester, capfd) -> None: - config = pytester.parseconfig() - with pytest.raises(ValueError) as excinfo: - raise ValueError(1) - config.notify_exception(excinfo, config.option) - _, err = capfd.readouterr() - assert "ValueError" in err - - class A: - def pytest_internalerror(self): - return True - - config.pluginmanager.register(A()) - config.notify_exception(excinfo, config.option) - _, err = capfd.readouterr() - assert not err - - config = pytester.parseconfig("-p", "no:terminal") - with pytest.raises(ValueError) as excinfo: - raise ValueError(1) - config.notify_exception(excinfo, config.option) - _, err = capfd.readouterr() - assert "ValueError" in err - - -def test_no_terminal_discovery_error(pytester: Pytester) -> None: - pytester.makepyfile("raise TypeError('oops!')") - result = pytester.runpytest("-p", "no:terminal", "--collect-only") - assert result.ret == ExitCode.INTERRUPTED - - -def test_load_initial_conftest_last_ordering(_config_for_test): - pm = _config_for_test.pluginmanager - - class My: - def pytest_load_initial_conftests(self): - pass - - m = My() - pm.register(m) - hc = pm.hook.pytest_load_initial_conftests - hookimpls = [ - ( - hookimpl.function.__module__, - "wrapper" if hookimpl.hookwrapper else "nonwrapper", - ) - for hookimpl in hc.get_hookimpls() - ] - assert hookimpls == [ - ("_pytest.config", "nonwrapper"), - (m.__module__, "nonwrapper"), - ("_pytest.legacypath", "nonwrapper"), - ("_pytest.python_path", "nonwrapper"), - ("_pytest.capture", "wrapper"), - ("_pytest.warnings", "wrapper"), - ] - - -def test_get_plugin_specs_as_list() -> None: - def exp_match(val: object) -> str: - return ( - "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %s" - % re.escape(repr(val)) - ) - - with pytest.raises(pytest.UsageError, match=exp_match({"foo"})): - _get_plugin_specs_as_list({"foo"}) # type: ignore[arg-type] - with pytest.raises(pytest.UsageError, match=exp_match({})): - _get_plugin_specs_as_list(dict()) # type: ignore[arg-type] - - assert _get_plugin_specs_as_list(None) == [] - assert _get_plugin_specs_as_list("") == [] - assert _get_plugin_specs_as_list("foo") == ["foo"] - assert _get_plugin_specs_as_list("foo,bar") == ["foo", "bar"] - assert _get_plugin_specs_as_list(["foo", "bar"]) == ["foo", "bar"] - assert _get_plugin_specs_as_list(("foo", "bar")) == ["foo", "bar"] - - -def test_collect_pytest_prefix_bug_integration(pytester: Pytester) -> None: - """Integration test for issue #3775""" - p = pytester.copy_example("config/collect_pytest_prefix") - result = pytester.runpytest(p) - result.stdout.fnmatch_lines(["* 1 passed *"]) - - -def test_collect_pytest_prefix_bug(pytestconfig): - """Ensure we collect only actual functions from conftest files (#3775)""" - - class Dummy: - class pytest_something: - pass - - pm = pytestconfig.pluginmanager - assert pm.parse_hookimpl_opts(Dummy(), "pytest_something") is None - - -class TestRootdir: - def test_simple_noini(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: - assert get_common_ancestor([tmp_path]) == tmp_path - a = tmp_path / "a" - a.mkdir() - assert get_common_ancestor([a, tmp_path]) == tmp_path - assert get_common_ancestor([tmp_path, a]) == tmp_path - monkeypatch.chdir(tmp_path) - assert get_common_ancestor([]) == tmp_path - no_path = tmp_path / "does-not-exist" - assert get_common_ancestor([no_path]) == tmp_path - assert get_common_ancestor([no_path / "a"]) == tmp_path - - @pytest.mark.parametrize( - "name, contents", - [ - pytest.param("pytest.ini", "[pytest]\nx=10", id="pytest.ini"), - pytest.param( - "pyproject.toml", "[tool.pytest.ini_options]\nx=10", id="pyproject.toml" - ), - pytest.param("tox.ini", "[pytest]\nx=10", id="tox.ini"), - pytest.param("setup.cfg", "[tool:pytest]\nx=10", id="setup.cfg"), - ], - ) - def test_with_ini(self, tmp_path: Path, name: str, contents: str) -> None: - inipath = tmp_path / name - inipath.write_text(contents, "utf-8") - - a = tmp_path / "a" - a.mkdir() - b = a / "b" - b.mkdir() - for args in ([str(tmp_path)], [str(a)], [str(b)]): - rootpath, parsed_inipath, _ = determine_setup(None, args) - assert rootpath == tmp_path - assert parsed_inipath == inipath - rootpath, parsed_inipath, ini_config = determine_setup(None, [str(b), str(a)]) - assert rootpath == tmp_path - assert parsed_inipath == inipath - assert ini_config == {"x": "10"} - - @pytest.mark.parametrize("name", ["setup.cfg", "tox.ini"]) - def test_pytestini_overrides_empty_other(self, tmp_path: Path, name: str) -> None: - inipath = tmp_path / "pytest.ini" - inipath.touch() - a = tmp_path / "a" - a.mkdir() - (a / name).touch() - rootpath, parsed_inipath, _ = determine_setup(None, [str(a)]) - assert rootpath == tmp_path - assert parsed_inipath == inipath - - def test_setuppy_fallback(self, tmp_path: Path) -> None: - a = tmp_path / "a" - a.mkdir() - (a / "setup.cfg").touch() - (tmp_path / "setup.py").touch() - rootpath, inipath, inicfg = determine_setup(None, [str(a)]) - assert rootpath == tmp_path - assert inipath is None - assert inicfg == {} - - def test_nothing(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: - monkeypatch.chdir(tmp_path) - rootpath, inipath, inicfg = determine_setup(None, [str(tmp_path)]) - assert rootpath == tmp_path - assert inipath is None - assert inicfg == {} - - @pytest.mark.parametrize( - "name, contents", - [ - # pytest.param("pytest.ini", "[pytest]\nx=10", id="pytest.ini"), - pytest.param( - "pyproject.toml", "[tool.pytest.ini_options]\nx=10", id="pyproject.toml" - ), - # pytest.param("tox.ini", "[pytest]\nx=10", id="tox.ini"), - # pytest.param("setup.cfg", "[tool:pytest]\nx=10", id="setup.cfg"), - ], - ) - def test_with_specific_inifile( - self, tmp_path: Path, name: str, contents: str - ) -> None: - p = tmp_path / name - p.touch() - p.write_text(contents, "utf-8") - rootpath, inipath, ini_config = determine_setup(str(p), [str(tmp_path)]) - assert rootpath == tmp_path - assert inipath == p - assert ini_config == {"x": "10"} - - def test_explicit_config_file_sets_rootdir( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - tests_dir = tmp_path / "tests" - tests_dir.mkdir() - - monkeypatch.chdir(tmp_path) - - # No config file is explicitly given: rootdir is determined to be cwd. - rootpath, found_inipath, *_ = determine_setup(None, [str(tests_dir)]) - assert rootpath == tmp_path - assert found_inipath is None - - # Config file is explicitly given: rootdir is determined to be inifile's directory. - inipath = tmp_path / "pytest.ini" - inipath.touch() - rootpath, found_inipath, *_ = determine_setup(str(inipath), [str(tests_dir)]) - assert rootpath == tmp_path - assert found_inipath == inipath - - def test_with_arg_outside_cwd_without_inifile( - self, tmp_path: Path, monkeypatch: MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - a = tmp_path / "a" - a.mkdir() - b = tmp_path / "b" - b.mkdir() - rootpath, inifile, _ = determine_setup(None, [str(a), str(b)]) - assert rootpath == tmp_path - assert inifile is None - - def test_with_arg_outside_cwd_with_inifile(self, tmp_path: Path) -> None: - a = tmp_path / "a" - a.mkdir() - b = tmp_path / "b" - b.mkdir() - inipath = a / "pytest.ini" - inipath.touch() - rootpath, parsed_inipath, _ = determine_setup(None, [str(a), str(b)]) - assert rootpath == a - assert inipath == parsed_inipath - - @pytest.mark.parametrize("dirs", ([], ["does-not-exist"], ["a/does-not-exist"])) - def test_with_non_dir_arg( - self, dirs: Sequence[str], tmp_path: Path, monkeypatch: MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - rootpath, inipath, _ = determine_setup(None, dirs) - assert rootpath == tmp_path - assert inipath is None - - def test_with_existing_file_in_subdir( - self, tmp_path: Path, monkeypatch: MonkeyPatch - ) -> None: - a = tmp_path / "a" - a.mkdir() - (a / "exists").touch() - monkeypatch.chdir(tmp_path) - rootpath, inipath, _ = determine_setup(None, ["a/exist"]) - assert rootpath == tmp_path - assert inipath is None - - def test_with_config_also_in_parent_directory( - self, tmp_path: Path, monkeypatch: MonkeyPatch - ) -> None: - """Regression test for #7807.""" - (tmp_path / "setup.cfg").write_text("[tool:pytest]\n", "utf-8") - (tmp_path / "myproject").mkdir() - (tmp_path / "myproject" / "setup.cfg").write_text("[tool:pytest]\n", "utf-8") - (tmp_path / "myproject" / "tests").mkdir() - monkeypatch.chdir(tmp_path / "myproject") - - rootpath, inipath, _ = determine_setup(None, ["tests/"]) - - assert rootpath == tmp_path / "myproject" - assert inipath == tmp_path / "myproject" / "setup.cfg" - - -class TestOverrideIniArgs: - @pytest.mark.parametrize("name", "setup.cfg tox.ini pytest.ini".split()) - def test_override_ini_names(self, pytester: Pytester, name: str) -> None: - section = "[pytest]" if name != "setup.cfg" else "[tool:pytest]" - pytester.path.joinpath(name).write_text( - textwrap.dedent( - """ - {section} - custom = 1.0""".format( - section=section - ) - ) - ) - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("custom", "")""" - ) - pytester.makepyfile( - """ - def test_pass(pytestconfig): - ini_val = pytestconfig.getini("custom") - print('\\ncustom_option:%s\\n' % ini_val)""" - ) - - result = pytester.runpytest("--override-ini", "custom=2.0", "-s") - assert result.ret == 0 - result.stdout.fnmatch_lines(["custom_option:2.0"]) - - result = pytester.runpytest( - "--override-ini", "custom=2.0", "--override-ini=custom=3.0", "-s" - ) - assert result.ret == 0 - result.stdout.fnmatch_lines(["custom_option:3.0"]) - - def test_override_ini_paths(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("paths", "my new ini value", type="paths")""" - ) - pytester.makeini( - """ - [pytest] - paths=blah.py""" - ) - pytester.makepyfile( - r""" - def test_overriden(pytestconfig): - config_paths = pytestconfig.getini("paths") - print(config_paths) - for cpf in config_paths: - print('\nuser_path:%s' % cpf.name) - """ - ) - result = pytester.runpytest( - "--override-ini", "paths=foo/bar1.py foo/bar2.py", "-s" - ) - result.stdout.fnmatch_lines(["user_path:bar1.py", "user_path:bar2.py"]) - - def test_override_multiple_and_default(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - def pytest_addoption(parser): - addini = parser.addini - addini("custom_option_1", "", default="o1") - addini("custom_option_2", "", default="o2") - addini("custom_option_3", "", default=False, type="bool") - addini("custom_option_4", "", default=True, type="bool")""" - ) - pytester.makeini( - """ - [pytest] - custom_option_1=custom_option_1 - custom_option_2=custom_option_2 - """ - ) - pytester.makepyfile( - """ - def test_multiple_options(pytestconfig): - prefix = "custom_option" - for x in range(1, 5): - ini_value=pytestconfig.getini("%s_%d" % (prefix, x)) - print('\\nini%d:%s' % (x, ini_value)) - """ - ) - result = pytester.runpytest( - "--override-ini", - "custom_option_1=fulldir=/tmp/user1", - "-o", - "custom_option_2=url=/tmp/user2?a=b&d=e", - "-o", - "custom_option_3=True", - "-o", - "custom_option_4=no", - "-s", - ) - result.stdout.fnmatch_lines( - [ - "ini1:fulldir=/tmp/user1", - "ini2:url=/tmp/user2?a=b&d=e", - "ini3:True", - "ini4:False", - ] - ) - - def test_override_ini_usage_error_bad_style(self, pytester: Pytester) -> None: - pytester.makeini( - """ - [pytest] - xdist_strict=False - """ - ) - result = pytester.runpytest("--override-ini", "xdist_strict", "True") - result.stderr.fnmatch_lines( - [ - "ERROR: -o/--override-ini expects option=value style (got: 'xdist_strict').", - ] - ) - - @pytest.mark.parametrize("with_ini", [True, False]) - def test_override_ini_handled_asap( - self, pytester: Pytester, with_ini: bool - ) -> None: - """-o should be handled as soon as possible and always override what's in ini files (#2238)""" - if with_ini: - pytester.makeini( - """ - [pytest] - python_files=test_*.py - """ - ) - pytester.makepyfile( - unittest_ini_handle=""" - def test(): - pass - """ - ) - result = pytester.runpytest("--override-ini", "python_files=unittest_*.py") - result.stdout.fnmatch_lines(["*1 passed in*"]) - - def test_addopts_before_initini( - self, monkeypatch: MonkeyPatch, _config_for_test, _sys_snapshot - ) -> None: - cache_dir = ".custom_cache" - monkeypatch.setenv("PYTEST_ADDOPTS", "-o cache_dir=%s" % cache_dir) - config = _config_for_test - config._preparse([], addopts=True) - assert config._override_ini == ["cache_dir=%s" % cache_dir] - - def test_addopts_from_env_not_concatenated( - self, monkeypatch: MonkeyPatch, _config_for_test - ) -> None: - """PYTEST_ADDOPTS should not take values from normal args (#4265).""" - monkeypatch.setenv("PYTEST_ADDOPTS", "-o") - config = _config_for_test - with pytest.raises(UsageError) as excinfo: - config._preparse(["cache_dir=ignored"], addopts=True) - assert ( - "error: argument -o/--override-ini: expected one argument (via PYTEST_ADDOPTS)" - in excinfo.value.args[0] - ) - - def test_addopts_from_ini_not_concatenated(self, pytester: Pytester) -> None: - """`addopts` from ini should not take values from normal args (#4265).""" - pytester.makeini( - """ - [pytest] - addopts=-o - """ - ) - result = pytester.runpytest("cache_dir=ignored") - result.stderr.fnmatch_lines( - [ - "%s: error: argument -o/--override-ini: expected one argument (via addopts config)" - % (pytester._request.config._parser.optparser.prog,) - ] - ) - assert result.ret == _pytest.config.ExitCode.USAGE_ERROR - - def test_override_ini_does_not_contain_paths( - self, _config_for_test, _sys_snapshot - ) -> None: - """Check that -o no longer swallows all options after it (#3103)""" - config = _config_for_test - config._preparse(["-o", "cache_dir=/cache", "/some/test/path"]) - assert config._override_ini == ["cache_dir=/cache"] - - def test_multiple_override_ini_options(self, pytester: Pytester) -> None: - """Ensure a file path following a '-o' option does not generate an error (#3103)""" - pytester.makepyfile( - **{ - "conftest.py": """ - def pytest_addoption(parser): - parser.addini('foo', default=None, help='some option') - parser.addini('bar', default=None, help='some option') - """, - "test_foo.py": """ - def test(pytestconfig): - assert pytestconfig.getini('foo') == '1' - assert pytestconfig.getini('bar') == '0' - """, - "test_bar.py": """ - def test(): - assert False - """, - } - ) - result = pytester.runpytest("-o", "foo=1", "-o", "bar=0", "test_foo.py") - assert "ERROR:" not in result.stderr.str() - result.stdout.fnmatch_lines(["collected 1 item", "*= 1 passed in *="]) - - -def test_help_via_addopts(pytester: Pytester) -> None: - pytester.makeini( - """ - [pytest] - addopts = --unknown-option-should-allow-for-help --help - """ - ) - result = pytester.runpytest() - assert result.ret == 0 - result.stdout.fnmatch_lines( - [ - "usage: *", - "positional arguments:", - # Displays full/default help. - "to see available markers type: pytest --markers", - ] - ) - - -def test_help_and_version_after_argument_error(pytester: Pytester) -> None: - pytester.makeconftest( - """ - def validate(arg): - raise argparse.ArgumentTypeError("argerror") - - def pytest_addoption(parser): - group = parser.getgroup('cov') - group.addoption( - "--invalid-option-should-allow-for-help", - type=validate, - ) - """ - ) - pytester.makeini( - """ - [pytest] - addopts = --invalid-option-should-allow-for-help - """ - ) - result = pytester.runpytest("--help") - result.stdout.fnmatch_lines( - [ - "usage: *", - "positional arguments:", - "NOTE: displaying only minimal help due to UsageError.", - ] - ) - result.stderr.fnmatch_lines( - [ - "ERROR: usage: *", - "%s: error: argument --invalid-option-should-allow-for-help: expected one argument" - % (pytester._request.config._parser.optparser.prog,), - ] - ) - # Does not display full/default help. - assert "to see available markers type: pytest --markers" not in result.stdout.lines - assert result.ret == ExitCode.USAGE_ERROR - - result = pytester.runpytest("--version") - result.stdout.fnmatch_lines([f"pytest {pytest.__version__}"]) - assert result.ret == ExitCode.USAGE_ERROR - - -def test_help_formatter_uses_py_get_terminal_width(monkeypatch: MonkeyPatch) -> None: - from _pytest.config.argparsing import DropShorterLongHelpFormatter - - monkeypatch.setenv("COLUMNS", "90") - formatter = DropShorterLongHelpFormatter("prog") - assert formatter._width == 90 - - monkeypatch.setattr("_pytest._io.get_terminal_width", lambda: 160) - formatter = DropShorterLongHelpFormatter("prog") - assert formatter._width == 160 - - formatter = DropShorterLongHelpFormatter("prog", width=42) - assert formatter._width == 42 - - -def test_config_does_not_load_blocked_plugin_from_args(pytester: Pytester) -> None: - """This tests that pytest's config setup handles "-p no:X".""" - p = pytester.makepyfile("def test(capfd): pass") - result = pytester.runpytest(str(p), "-pno:capture") - result.stdout.fnmatch_lines(["E fixture 'capfd' not found"]) - assert result.ret == ExitCode.TESTS_FAILED - - result = pytester.runpytest(str(p), "-pno:capture", "-s") - result.stderr.fnmatch_lines(["*: error: unrecognized arguments: -s"]) - assert result.ret == ExitCode.USAGE_ERROR - - result = pytester.runpytest(str(p), "-p no:capture", "-s") - result.stderr.fnmatch_lines(["*: error: unrecognized arguments: -s"]) - assert result.ret == ExitCode.USAGE_ERROR - - -def test_invocation_args(pytester: Pytester) -> None: - """Ensure that Config.invocation_* arguments are correctly defined""" - - class DummyPlugin: - pass - - p = pytester.makepyfile("def test(): pass") - plugin = DummyPlugin() - rec = pytester.inline_run(p, "-v", plugins=[plugin]) - calls = rec.getcalls("pytest_runtest_protocol") - assert len(calls) == 1 - call = calls[0] - config = call.item.config - - assert config.invocation_params.args == (str(p), "-v") - assert config.invocation_params.dir == pytester.path - - plugins = config.invocation_params.plugins - assert len(plugins) == 2 - assert plugins[0] is plugin - assert type(plugins[1]).__name__ == "Collect" # installed by pytester.inline_run() - - # args cannot be None - with pytest.raises(TypeError): - Config.InvocationParams(args=None, plugins=None, dir=Path()) # type: ignore[arg-type] - - -@pytest.mark.parametrize( - "plugin", - [ - x - for x in _pytest.config.default_plugins - if x not in _pytest.config.essential_plugins - ], -) -def test_config_blocked_default_plugins(pytester: Pytester, plugin: str) -> None: - if plugin == "debugging": - # Fixed in xdist (after 1.27.0). - # https://github.com/pytest-dev/pytest-xdist/pull/422 - try: - import xdist # noqa: F401 - except ImportError: - pass - else: - pytest.skip("does not work with xdist currently") - - p = pytester.makepyfile("def test(): pass") - result = pytester.runpytest(str(p), "-pno:%s" % plugin) - - if plugin == "python": - assert result.ret == ExitCode.USAGE_ERROR - result.stderr.fnmatch_lines( - [ - "ERROR: found no collectors for */test_config_blocked_default_plugins.py", - ] - ) - return - - assert result.ret == ExitCode.OK - if plugin != "terminal": - result.stdout.fnmatch_lines(["* 1 passed in *"]) - - p = pytester.makepyfile("def test(): assert 0") - result = pytester.runpytest(str(p), "-pno:%s" % plugin) - assert result.ret == ExitCode.TESTS_FAILED - if plugin != "terminal": - result.stdout.fnmatch_lines(["* 1 failed in *"]) - else: - assert result.stdout.lines == [] - - -class TestSetupCfg: - def test_pytest_setup_cfg_unsupported(self, pytester: Pytester) -> None: - pytester.makefile( - ".cfg", - setup=""" - [pytest] - addopts = --verbose - """, - ) - with pytest.raises(pytest.fail.Exception): - pytester.runpytest() - - def test_pytest_custom_cfg_unsupported(self, pytester: Pytester) -> None: - pytester.makefile( - ".cfg", - custom=""" - [pytest] - addopts = --verbose - """, - ) - with pytest.raises(pytest.fail.Exception): - pytester.runpytest("-c", "custom.cfg") - - -class TestPytestPluginsVariable: - def test_pytest_plugins_in_non_top_level_conftest_unsupported( - self, pytester: Pytester - ) -> None: - pytester.makepyfile( - **{ - "subdirectory/conftest.py": """ - pytest_plugins=['capture'] - """ - } - ) - pytester.makepyfile( - """ - def test_func(): - pass - """ - ) - res = pytester.runpytest() - assert res.ret == 2 - msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" - res.stdout.fnmatch_lines([f"*{msg}*", f"*subdirectory{os.sep}conftest.py*"]) - - @pytest.mark.parametrize("use_pyargs", [True, False]) - def test_pytest_plugins_in_non_top_level_conftest_unsupported_pyargs( - self, pytester: Pytester, use_pyargs: bool - ) -> None: - """When using --pyargs, do not emit the warning about non-top-level conftest warnings (#4039, #4044)""" - - files = { - "src/pkg/__init__.py": "", - "src/pkg/conftest.py": "", - "src/pkg/test_root.py": "def test(): pass", - "src/pkg/sub/__init__.py": "", - "src/pkg/sub/conftest.py": "pytest_plugins=['capture']", - "src/pkg/sub/test_bar.py": "def test(): pass", - } - pytester.makepyfile(**files) - pytester.syspathinsert(pytester.path.joinpath("src")) - - args = ("--pyargs", "pkg") if use_pyargs else () - res = pytester.runpytest(*args) - assert res.ret == (0 if use_pyargs else 2) - msg = ( - msg - ) = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" - if use_pyargs: - assert msg not in res.stdout.str() - else: - res.stdout.fnmatch_lines([f"*{msg}*"]) - - def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_top_level_conftest( - self, pytester: Pytester - ) -> None: - subdirectory = pytester.path.joinpath("subdirectory") - subdirectory.mkdir() - pytester.makeconftest( - """ - pytest_plugins=['capture'] - """ - ) - pytester.path.joinpath("conftest.py").rename( - subdirectory.joinpath("conftest.py") - ) - - pytester.makepyfile( - """ - def test_func(): - pass - """ - ) - - res = pytester.runpytest_subprocess() - assert res.ret == 2 - msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" - res.stdout.fnmatch_lines([f"*{msg}*", f"*subdirectory{os.sep}conftest.py*"]) - - def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_false_positives( - self, pytester: Pytester - ) -> None: - pytester.makepyfile( - "def test_func(): pass", - **{ - "subdirectory/conftest": "pass", - "conftest": """ - import warnings - warnings.filterwarnings('always', category=DeprecationWarning) - pytest_plugins=['capture'] - """, - }, - ) - res = pytester.runpytest_subprocess() - assert res.ret == 0 - msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" - assert msg not in res.stdout.str() - - -def test_conftest_import_error_repr(tmp_path: Path) -> None: - """`ConftestImportFailure` should use a short error message and readable - path to the failed conftest.py file.""" - path = tmp_path.joinpath("foo/conftest.py") - with pytest.raises( - ConftestImportFailure, - match=re.escape(f"RuntimeError: some error (from {path})"), - ): - try: - raise RuntimeError("some error") - except Exception as exc: - assert exc.__traceback__ is not None - exc_info = (type(exc), exc, exc.__traceback__) - raise ConftestImportFailure(path, exc_info) from exc - - -def test_strtobool() -> None: - assert _strtobool("YES") - assert not _strtobool("NO") - with pytest.raises(ValueError): - _strtobool("unknown") - - -@pytest.mark.parametrize( - "arg, escape, expected", - [ - ("ignore", False, ("ignore", "", Warning, "", 0)), - ( - "ignore::DeprecationWarning", - False, - ("ignore", "", DeprecationWarning, "", 0), - ), - ( - "ignore:some msg:DeprecationWarning", - False, - ("ignore", "some msg", DeprecationWarning, "", 0), - ), - ( - "ignore::DeprecationWarning:mod", - False, - ("ignore", "", DeprecationWarning, "mod", 0), - ), - ( - "ignore::DeprecationWarning:mod:42", - False, - ("ignore", "", DeprecationWarning, "mod", 42), - ), - ("error:some\\msg:::", True, ("error", "some\\\\msg", Warning, "", 0)), - ("error:::mod\\foo:", True, ("error", "", Warning, "mod\\\\foo\\Z", 0)), - ], -) -def test_parse_warning_filter( - arg: str, escape: bool, expected: Tuple[str, str, Type[Warning], str, int] -) -> None: - assert parse_warning_filter(arg, escape=escape) == expected - - -@pytest.mark.parametrize( - "arg", - [ - # Too much parts. - ":" * 5, - # Invalid action. - "FOO::", - # ImportError when importing the warning class. - "::test_parse_warning_filter_failure.NonExistentClass::", - # Class is not a Warning subclass. - "::list::", - # Negative line number. - "::::-1", - # Not a line number. - "::::not-a-number", - ], -) -def test_parse_warning_filter_failure(arg: str) -> None: - with pytest.raises(pytest.UsageError): - parse_warning_filter(arg, escape=True) - - -class TestDebugOptions: - def test_without_debug_does_not_write_log(self, pytester: Pytester) -> None: - result = pytester.runpytest() - result.stderr.no_fnmatch_line( - "*writing pytest debug information to*pytestdebug.log" - ) - result.stderr.no_fnmatch_line( - "*wrote pytest debug information to*pytestdebug.log" - ) - assert not [f.name for f in pytester.path.glob("**/*.log")] - - def test_with_only_debug_writes_pytestdebug_log(self, pytester: Pytester) -> None: - result = pytester.runpytest("--debug") - result.stderr.fnmatch_lines( - [ - "*writing pytest debug information to*pytestdebug.log", - "*wrote pytest debug information to*pytestdebug.log", - ] - ) - assert "pytestdebug.log" in [f.name for f in pytester.path.glob("**/*.log")] - - def test_multiple_custom_debug_logs(self, pytester: Pytester) -> None: - result = pytester.runpytest("--debug", "bar.log") - result.stderr.fnmatch_lines( - [ - "*writing pytest debug information to*bar.log", - "*wrote pytest debug information to*bar.log", - ] - ) - result = pytester.runpytest("--debug", "foo.log") - result.stderr.fnmatch_lines( - [ - "*writing pytest debug information to*foo.log", - "*wrote pytest debug information to*foo.log", - ] - ) - - assert {"bar.log", "foo.log"} == { - f.name for f in pytester.path.glob("**/*.log") - } - - def test_debug_help(self, pytester: Pytester) -> None: - result = pytester.runpytest("-h") - result.stdout.fnmatch_lines( - [ - "*Store internal tracing debug information in this log*", - "*file. This file is opened with 'w' and truncated as a*", - "*Default: pytestdebug.log.", - ] - ) +import dataclasses +import os +import re +import sys +import textwrap +from pathlib import Path +from typing import Dict +from typing import List +from typing import Sequence +from typing import Tuple +from typing import Type +from typing import Union + +import _pytest._code +import pytest +from _pytest.compat import importlib_metadata +from _pytest.config import _get_plugin_specs_as_list +from _pytest.config import _iter_rewritable_modules +from _pytest.config import _strtobool +from _pytest.config import Config +from _pytest.config import ConftestImportFailure +from _pytest.config import ExitCode +from _pytest.config import parse_warning_filter +from _pytest.config.exceptions import UsageError +from _pytest.config.findpaths import determine_setup +from _pytest.config.findpaths import get_common_ancestor +from _pytest.config.findpaths import locate_config +from _pytest.monkeypatch import MonkeyPatch +from _pytest.pathlib import absolutepath +from _pytest.pytester import Pytester + + +class TestParseIni: + @pytest.mark.parametrize( + "section, filename", [("pytest", "pytest.ini"), ("tool:pytest", "setup.cfg")] + ) + def test_getcfg_and_config( + self, + pytester: Pytester, + tmp_path: Path, + section: str, + filename: str, + monkeypatch: MonkeyPatch, + ) -> None: + sub = tmp_path / "sub" + sub.mkdir() + monkeypatch.chdir(sub) + (tmp_path / filename).write_text( + textwrap.dedent( + """\ + [{section}] + name = value + """.format( + section=section + ) + ), + encoding="utf-8", + ) + _, _, cfg = locate_config([sub]) + assert cfg["name"] == "value" + config = pytester.parseconfigure(str(sub)) + assert config.inicfg["name"] == "value" + + def test_setupcfg_uses_toolpytest_with_pytest(self, pytester: Pytester) -> None: + p1 = pytester.makepyfile("def test(): pass") + pytester.makefile( + ".cfg", + setup=""" + [tool:pytest] + testpaths=%s + [pytest] + testpaths=ignored + """ + % p1.name, + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["configfile: setup.cfg", "* 1 passed in *"]) + assert result.ret == 0 + + def test_append_parse_args( + self, pytester: Pytester, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.setenv("PYTEST_ADDOPTS", '--color no -rs --tb="short"') + tmp_path.joinpath("pytest.ini").write_text( + textwrap.dedent( + """\ + [pytest] + addopts = --verbose + """ + ) + ) + config = pytester.parseconfig(tmp_path) + assert config.option.color == "no" + assert config.option.reportchars == "s" + assert config.option.tbstyle == "short" + assert config.option.verbose + + def test_tox_ini_wrong_version(self, pytester: Pytester) -> None: + pytester.makefile( + ".ini", + tox=""" + [pytest] + minversion=999.0 + """, + ) + result = pytester.runpytest() + assert result.ret != 0 + result.stderr.fnmatch_lines( + ["*tox.ini: 'minversion' requires pytest-999.0, actual pytest-*"] + ) + + @pytest.mark.parametrize( + "section, name", + [ + ("tool:pytest", "setup.cfg"), + ("pytest", "tox.ini"), + ("pytest", "pytest.ini"), + ("pytest", ".pytest.ini"), + ], + ) + def test_ini_names(self, pytester: Pytester, name, section) -> None: + pytester.path.joinpath(name).write_text( + textwrap.dedent( + """ + [{section}] + minversion = 3.36 + """.format( + section=section + ) + ) + ) + config = pytester.parseconfig() + assert config.getini("minversion") == "3.36" + + def test_pyproject_toml(self, pytester: Pytester) -> None: + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + minversion = "1.0" + """ + ) + config = pytester.parseconfig() + assert config.getini("minversion") == "1.0" + + def test_toxini_before_lower_pytestini(self, pytester: Pytester) -> None: + sub = pytester.mkdir("sub") + sub.joinpath("tox.ini").write_text( + textwrap.dedent( + """ + [pytest] + minversion = 2.0 + """ + ) + ) + pytester.path.joinpath("pytest.ini").write_text( + textwrap.dedent( + """ + [pytest] + minversion = 1.5 + """ + ) + ) + config = pytester.parseconfigure(sub) + assert config.getini("minversion") == "2.0" + + def test_ini_parse_error(self, pytester: Pytester) -> None: + pytester.path.joinpath("pytest.ini").write_text("addopts = -x") + result = pytester.runpytest() + assert result.ret != 0 + result.stderr.fnmatch_lines("ERROR: *pytest.ini:1: no section header defined") + + def test_toml_parse_error(self, pytester: Pytester) -> None: + pytester.makepyprojecttoml( + """ + \\" + """ + ) + result = pytester.runpytest() + assert result.ret != 0 + result.stderr.fnmatch_lines("ERROR: *pyproject.toml: Invalid statement*") + + @pytest.mark.xfail(reason="probably not needed") + def test_confcutdir(self, pytester: Pytester) -> None: + sub = pytester.mkdir("sub") + os.chdir(sub) + pytester.makeini( + """ + [pytest] + addopts = --qwe + """ + ) + result = pytester.inline_run("--confcutdir=.") + assert result.ret == 0 + + @pytest.mark.parametrize( + "ini_file_text, invalid_keys, warning_output, exception_text", + [ + pytest.param( + """ + [pytest] + unknown_ini = value1 + another_unknown_ini = value2 + """, + ["unknown_ini", "another_unknown_ini"], + [ + "=*= warnings summary =*=", + "*PytestConfigWarning:*Unknown config option: another_unknown_ini", + "*PytestConfigWarning:*Unknown config option: unknown_ini", + ], + "Unknown config option: another_unknown_ini", + id="2-unknowns", + ), + pytest.param( + """ + [pytest] + unknown_ini = value1 + minversion = 5.0.0 + """, + ["unknown_ini"], + [ + "=*= warnings summary =*=", + "*PytestConfigWarning:*Unknown config option: unknown_ini", + ], + "Unknown config option: unknown_ini", + id="1-unknown", + ), + pytest.param( + """ + [some_other_header] + unknown_ini = value1 + [pytest] + minversion = 5.0.0 + """, + [], + [], + "", + id="unknown-in-other-header", + ), + pytest.param( + """ + [pytest] + minversion = 5.0.0 + """, + [], + [], + "", + id="no-unknowns", + ), + pytest.param( + """ + [pytest] + conftest_ini_key = 1 + """, + [], + [], + "", + id="1-known", + ), + ], + ) + @pytest.mark.filterwarnings("default") + def test_invalid_config_options( + self, + pytester: Pytester, + ini_file_text, + invalid_keys, + warning_output, + exception_text, + ) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("conftest_ini_key", "") + """ + ) + pytester.makepyfile("def test(): pass") + pytester.makeini(ini_file_text) + + config = pytester.parseconfig() + assert sorted(config._get_unknown_ini_keys()) == sorted(invalid_keys) + + result = pytester.runpytest() + result.stdout.fnmatch_lines(warning_output) + + result = pytester.runpytest("--strict-config") + if exception_text: + result.stderr.fnmatch_lines("ERROR: " + exception_text) + assert result.ret == pytest.ExitCode.USAGE_ERROR + else: + result.stderr.no_fnmatch_line(exception_text) + assert result.ret == pytest.ExitCode.OK + + @pytest.mark.filterwarnings("default") + def test_silence_unknown_key_warning(self, pytester: Pytester) -> None: + """Unknown config key warnings can be silenced using filterwarnings (#7620)""" + pytester.makeini( + """ + [pytest] + filterwarnings = + ignore:Unknown config option:pytest.PytestConfigWarning + foobar=1 + """ + ) + result = pytester.runpytest() + result.stdout.no_fnmatch_line("*PytestConfigWarning*") + + @pytest.mark.filterwarnings("default::pytest.PytestConfigWarning") + def test_disable_warnings_plugin_disables_config_warnings( + self, pytester: Pytester + ) -> None: + """Disabling 'warnings' plugin also disables config time warnings""" + pytester.makeconftest( + """ + import pytest + def pytest_configure(config): + config.issue_config_time_warning( + pytest.PytestConfigWarning("custom config warning"), + stacklevel=2, + ) + """ + ) + result = pytester.runpytest("-pno:warnings") + result.stdout.no_fnmatch_line("*PytestConfigWarning*") + + @pytest.mark.parametrize( + "ini_file_text, plugin_version, exception_text", + [ + pytest.param( + """ + [pytest] + required_plugins = a z + """, + "1.5", + "Missing required plugins: a, z", + id="2-missing", + ), + pytest.param( + """ + [pytest] + required_plugins = a z myplugin + """, + "1.5", + "Missing required plugins: a, z", + id="2-missing-1-ok", + ), + pytest.param( + """ + [pytest] + required_plugins = myplugin + """, + "1.5", + None, + id="1-ok", + ), + pytest.param( + """ + [pytest] + required_plugins = myplugin==1.5 + """, + "1.5", + None, + id="1-ok-pin-exact", + ), + pytest.param( + """ + [pytest] + required_plugins = myplugin>1.0,<2.0 + """, + "1.5", + None, + id="1-ok-pin-loose", + ), + pytest.param( + """ + [pytest] + required_plugins = myplugin + """, + "1.5a1", + None, + id="1-ok-prerelease", + ), + pytest.param( + """ + [pytest] + required_plugins = myplugin==1.6 + """, + "1.5", + "Missing required plugins: myplugin==1.6", + id="missing-version", + ), + pytest.param( + """ + [pytest] + required_plugins = myplugin==1.6 other==1.0 + """, + "1.5", + "Missing required plugins: myplugin==1.6, other==1.0", + id="missing-versions", + ), + pytest.param( + """ + [some_other_header] + required_plugins = won't be triggered + [pytest] + """, + "1.5", + None, + id="invalid-header", + ), + ], + ) + def test_missing_required_plugins( + self, + pytester: Pytester, + monkeypatch: MonkeyPatch, + ini_file_text: str, + plugin_version: str, + exception_text: str, + ) -> None: + """Check 'required_plugins' option with various settings. + + This test installs a mock "myplugin-1.5" which is used in the parametrized test cases. + """ + + @dataclasses.dataclass + class DummyEntryPoint: + name: str + module: str + group: str = "pytest11" + + def load(self): + __import__(self.module) + return sys.modules[self.module] + + entry_points = [ + DummyEntryPoint("myplugin1", "myplugin1_module"), + ] + + @dataclasses.dataclass + class DummyDist: + entry_points: object + files: object = () + version: str = plugin_version + + @property + def metadata(self): + return {"name": "myplugin"} + + def my_dists(): + return [DummyDist(entry_points)] + + pytester.makepyfile(myplugin1_module="# my plugin module") + pytester.syspathinsert() + + monkeypatch.setattr(importlib_metadata, "distributions", my_dists) + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + pytester.makeini(ini_file_text) + + if exception_text: + with pytest.raises(pytest.UsageError, match=exception_text): + pytester.parseconfig() + else: + pytester.parseconfig() + + def test_early_config_cmdline( + self, pytester: Pytester, monkeypatch: MonkeyPatch + ) -> None: + """early_config contains options registered by third-party plugins. + + This is a regression involving pytest-cov (and possibly others) introduced in #7700. + """ + pytester.makepyfile( + myplugin=""" + def pytest_addoption(parser): + parser.addoption('--foo', default=None, dest='foo') + + def pytest_load_initial_conftests(early_config, parser, args): + assert early_config.known_args_namespace.foo == "1" + """ + ) + monkeypatch.setenv("PYTEST_PLUGINS", "myplugin") + pytester.syspathinsert() + result = pytester.runpytest("--foo=1") + result.stdout.fnmatch_lines("* no tests ran in *") + + +class TestConfigCmdlineParsing: + def test_parsing_again_fails(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + pytest.raises(AssertionError, lambda: config.parse([])) + + def test_explicitly_specified_config_file_is_loaded( + self, pytester: Pytester + ) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("custom", "") + """ + ) + pytester.makeini( + """ + [pytest] + custom = 0 + """ + ) + pytester.makefile( + ".ini", + custom=""" + [pytest] + custom = 1 + """, + ) + config = pytester.parseconfig("-c", "custom.ini") + assert config.getini("custom") == "1" + config = pytester.parseconfig("--config-file", "custom.ini") + assert config.getini("custom") == "1" + + pytester.makefile( + ".cfg", + custom_tool_pytest_section=""" + [tool:pytest] + custom = 1 + """, + ) + config = pytester.parseconfig("-c", "custom_tool_pytest_section.cfg") + assert config.getini("custom") == "1" + config = pytester.parseconfig("--config-file", "custom_tool_pytest_section.cfg") + assert config.getini("custom") == "1" + + pytester.makefile( + ".toml", + custom=""" + [tool.pytest.ini_options] + custom = 1 + value = [ + ] # this is here on purpose, as it makes this an invalid '.ini' file + """, + ) + config = pytester.parseconfig("-c", "custom.toml") + assert config.getini("custom") == "1" + config = pytester.parseconfig("--config-file", "custom.toml") + assert config.getini("custom") == "1" + + def test_absolute_win32_path(self, pytester: Pytester) -> None: + temp_ini_file = pytester.makefile( + ".ini", + custom=""" + [pytest] + addopts = --version + """, + ) + from os.path import normpath + + temp_ini_file_norm = normpath(str(temp_ini_file)) + ret = pytest.main(["-c", temp_ini_file_norm]) + assert ret == ExitCode.OK + ret = pytest.main(["--config-file", temp_ini_file_norm]) + assert ret == ExitCode.OK + + +class TestConfigAPI: + def test_config_trace(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + values: List[str] = [] + config.trace.root.setwriter(values.append) + config.trace("hello") + assert len(values) == 1 + assert values[0] == "hello [config]\n" + + def test_config_getoption(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addoption("--hello", "-X", dest="hello") + """ + ) + config = pytester.parseconfig("--hello=this") + for x in ("hello", "--hello", "-X"): + assert config.getoption(x) == "this" + pytest.raises(ValueError, config.getoption, "qweqwe") + + def test_config_getoption_unicode(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addoption('--hello', type=str) + """ + ) + config = pytester.parseconfig("--hello=this") + assert config.getoption("hello") == "this" + + def test_config_getvalueorskip(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + pytest.raises(pytest.skip.Exception, config.getvalueorskip, "hello") + verbose = config.getvalueorskip("verbose") + assert verbose == config.option.verbose + + def test_config_getvalueorskip_None(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addoption("--hello") + """ + ) + config = pytester.parseconfig() + with pytest.raises(pytest.skip.Exception): + config.getvalueorskip("hello") + + def test_getoption(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + with pytest.raises(ValueError): + config.getvalue("x") + assert config.getoption("x", 1) == 1 + + def test_getconftest_pathlist(self, pytester: Pytester, tmp_path: Path) -> None: + somepath = tmp_path.joinpath("x", "y", "z") + p = tmp_path.joinpath("conftest.py") + p.write_text(f"mylist = {['.', str(somepath)]}") + config = pytester.parseconfigure(p) + assert ( + config._getconftest_pathlist("notexist", path=tmp_path, rootpath=tmp_path) + is None + ) + pl = ( + config._getconftest_pathlist("mylist", path=tmp_path, rootpath=tmp_path) + or [] + ) + print(pl) + assert len(pl) == 2 + assert pl[0] == tmp_path + assert pl[1] == somepath + + @pytest.mark.parametrize("maybe_type", ["not passed", "None", '"string"']) + def test_addini(self, pytester: Pytester, maybe_type: str) -> None: + if maybe_type == "not passed": + type_string = "" + else: + type_string = f", {maybe_type}" + + pytester.makeconftest( + f""" + def pytest_addoption(parser): + parser.addini("myname", "my new ini value"{type_string}) + """ + ) + pytester.makeini( + """ + [pytest] + myname=hello + """ + ) + config = pytester.parseconfig() + val = config.getini("myname") + assert val == "hello" + pytest.raises(ValueError, config.getini, "other") + + @pytest.mark.parametrize("config_type", ["ini", "pyproject"]) + def test_addini_paths(self, pytester: Pytester, config_type: str) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("paths", "my new ini value", type="paths") + parser.addini("abc", "abc value") + """ + ) + if config_type == "ini": + inipath = pytester.makeini( + """ + [pytest] + paths=hello world/sub.py + """ + ) + elif config_type == "pyproject": + inipath = pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + paths=["hello", "world/sub.py"] + """ + ) + config = pytester.parseconfig() + values = config.getini("paths") + assert len(values) == 2 + assert values[0] == inipath.parent.joinpath("hello") + assert values[1] == inipath.parent.joinpath("world/sub.py") + pytest.raises(ValueError, config.getini, "other") + + def make_conftest_for_args(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("args", "new args", type="args") + parser.addini("a2", "", "args", default="1 2 3".split()) + """ + ) + + def test_addini_args_ini_files(self, pytester: Pytester) -> None: + self.make_conftest_for_args(pytester) + pytester.makeini( + """ + [pytest] + args=123 "123 hello" "this" + """ + ) + self.check_config_args(pytester) + + def test_addini_args_pyproject_toml(self, pytester: Pytester) -> None: + self.make_conftest_for_args(pytester) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + args = ["123", "123 hello", "this"] + """ + ) + self.check_config_args(pytester) + + def check_config_args(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + values = config.getini("args") + assert values == ["123", "123 hello", "this"] + values = config.getini("a2") + assert values == list("123") + + def make_conftest_for_linelist(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("xy", "", type="linelist") + parser.addini("a2", "", "linelist") + """ + ) + + def test_addini_linelist_ini_files(self, pytester: Pytester) -> None: + self.make_conftest_for_linelist(pytester) + pytester.makeini( + """ + [pytest] + xy= 123 345 + second line + """ + ) + self.check_config_linelist(pytester) + + def test_addini_linelist_pprojecttoml(self, pytester: Pytester) -> None: + self.make_conftest_for_linelist(pytester) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + xy = ["123 345", "second line"] + """ + ) + self.check_config_linelist(pytester) + + def check_config_linelist(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + values = config.getini("xy") + assert len(values) == 2 + assert values == ["123 345", "second line"] + values = config.getini("a2") + assert values == [] + + @pytest.mark.parametrize( + "str_val, bool_val", [("True", True), ("no", False), ("no-ini", True)] + ) + def test_addini_bool( + self, pytester: Pytester, str_val: str, bool_val: bool + ) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("strip", "", type="bool", default=True) + """ + ) + if str_val != "no-ini": + pytester.makeini( + """ + [pytest] + strip=%s + """ + % str_val + ) + config = pytester.parseconfig() + assert config.getini("strip") is bool_val + + def test_addinivalue_line_existing(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("xy", "", type="linelist") + """ + ) + pytester.makeini( + """ + [pytest] + xy= 123 + """ + ) + config = pytester.parseconfig() + values = config.getini("xy") + assert len(values) == 1 + assert values == ["123"] + config.addinivalue_line("xy", "456") + values = config.getini("xy") + assert len(values) == 2 + assert values == ["123", "456"] + + def test_addinivalue_line_new(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("xy", "", type="linelist") + """ + ) + config = pytester.parseconfig() + assert not config.getini("xy") + config.addinivalue_line("xy", "456") + values = config.getini("xy") + assert len(values) == 1 + assert values == ["456"] + config.addinivalue_line("xy", "123") + values = config.getini("xy") + assert len(values) == 2 + assert values == ["456", "123"] + + def test_confcutdir_check_isdir(self, pytester: Pytester) -> None: + """Give an error if --confcutdir is not a valid directory (#2078)""" + exp_match = r"^--confcutdir must be a directory, given: " + with pytest.raises(pytest.UsageError, match=exp_match): + pytester.parseconfig("--confcutdir", pytester.path.joinpath("file")) + with pytest.raises(pytest.UsageError, match=exp_match): + pytester.parseconfig("--confcutdir", pytester.path.joinpath("nonexistent")) + + p = pytester.mkdir("dir") + config = pytester.parseconfig("--confcutdir", p) + assert config.getoption("confcutdir") == str(p) + + @pytest.mark.parametrize( + "names, expected", + [ + # dist-info based distributions root are files as will be put in PYTHONPATH + (["bar.py"], ["bar"]), + (["foo/bar.py"], ["bar"]), + (["foo/bar.pyc"], []), + (["foo/__init__.py"], ["foo"]), + (["bar/__init__.py", "xz.py"], ["bar", "xz"]), + (["setup.py"], []), + # egg based distributions root contain the files from the dist root + (["src/bar/__init__.py"], ["bar"]), + (["src/bar/__init__.py", "setup.py"], ["bar"]), + (["source/python/bar/__init__.py", "setup.py"], ["bar"]), + # editable installation finder modules + (["__editable___xyz_finder.py"], []), + (["bar/__init__.py", "__editable___xyz_finder.py"], ["bar"]), + ], + ) + def test_iter_rewritable_modules(self, names, expected) -> None: + assert list(_iter_rewritable_modules(names)) == expected + + +class TestConfigFromdictargs: + def test_basic_behavior(self, _sys_snapshot) -> None: + option_dict = {"verbose": 444, "foo": "bar", "capture": "no"} + args = ["a", "b"] + + config = Config.fromdictargs(option_dict, args) + with pytest.raises(AssertionError): + config.parse(["should refuse to parse again"]) + assert config.option.verbose == 444 + assert config.option.foo == "bar" + assert config.option.capture == "no" + assert config.args == args + + def test_invocation_params_args(self, _sys_snapshot) -> None: + """Show that fromdictargs can handle args in their "orig" format""" + option_dict: Dict[str, object] = {} + args = ["-vvvv", "-s", "a", "b"] + + config = Config.fromdictargs(option_dict, args) + assert config.args == ["a", "b"] + assert config.invocation_params.args == tuple(args) + assert config.option.verbose == 4 + assert config.option.capture == "no" + + def test_inifilename(self, tmp_path: Path) -> None: + d1 = tmp_path.joinpath("foo") + d1.mkdir() + p1 = d1.joinpath("bar.ini") + p1.touch() + p1.write_text( + textwrap.dedent( + """\ + [pytest] + name = value + """ + ) + ) + + inifilename = "../../foo/bar.ini" + option_dict = {"inifilename": inifilename, "capture": "no"} + + cwd = tmp_path.joinpath("a/b") + cwd.mkdir(parents=True) + p2 = cwd.joinpath("pytest.ini") + p2.touch() + p2.write_text( + textwrap.dedent( + """\ + [pytest] + name = wrong-value + should_not_be_set = true + """ + ) + ) + with MonkeyPatch.context() as mp: + mp.chdir(cwd) + config = Config.fromdictargs(option_dict, ()) + inipath = absolutepath(inifilename) + + assert config.args == [str(cwd)] + assert config.option.inifilename == inifilename + assert config.option.capture == "no" + + # this indicates this is the file used for getting configuration values + assert config.inipath == inipath + assert config.inicfg.get("name") == "value" + assert config.inicfg.get("should_not_be_set") is None + + +def test_options_on_small_file_do_not_blow_up(pytester: Pytester) -> None: + def runfiletest(opts: Sequence[str]) -> None: + reprec = pytester.inline_run(*opts) + passed, skipped, failed = reprec.countoutcomes() + assert failed == 2 + assert skipped == passed == 0 + + path = str( + pytester.makepyfile( + """ + def test_f1(): assert 0 + def test_f2(): assert 0 + """ + ) + ) + + runfiletest([path]) + runfiletest(["-l", path]) + runfiletest(["-s", path]) + runfiletest(["--tb=no", path]) + runfiletest(["--tb=short", path]) + runfiletest(["--tb=long", path]) + runfiletest(["--fulltrace", path]) + runfiletest(["--traceconfig", path]) + runfiletest(["-v", path]) + runfiletest(["-v", "-v", path]) + + +def test_preparse_ordering_with_setuptools( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + class EntryPoint: + name = "mytestplugin" + group = "pytest11" + + def load(self): + class PseudoPlugin: + x = 42 + + return PseudoPlugin() + + class Dist: + files = () + metadata = {"name": "foo"} + entry_points = (EntryPoint(),) + + def my_dists(): + return (Dist,) + + monkeypatch.setattr(importlib_metadata, "distributions", my_dists) + pytester.makeconftest( + """ + pytest_plugins = "mytestplugin", + """ + ) + monkeypatch.setenv("PYTEST_PLUGINS", "mytestplugin") + config = pytester.parseconfig() + plugin = config.pluginmanager.getplugin("mytestplugin") + assert plugin.x == 42 + + +def test_setuptools_importerror_issue1479( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + class DummyEntryPoint: + name = "mytestplugin" + group = "pytest11" + + def load(self): + raise ImportError("Don't hide me!") + + class Distribution: + version = "1.0" + files = ("foo.txt",) + metadata = {"name": "foo"} + entry_points = (DummyEntryPoint(),) + + def distributions(): + return (Distribution(),) + + monkeypatch.setattr(importlib_metadata, "distributions", distributions) + with pytest.raises(ImportError): + pytester.parseconfig() + + +def test_importlib_metadata_broken_distribution( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + """Integration test for broken distributions with 'files' metadata being None (#5389)""" + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + class DummyEntryPoint: + name = "mytestplugin" + group = "pytest11" + + def load(self): + return object() + + class Distribution: + version = "1.0" + files = None + metadata = {"name": "foo"} + entry_points = (DummyEntryPoint(),) + + def distributions(): + return (Distribution(),) + + monkeypatch.setattr(importlib_metadata, "distributions", distributions) + pytester.parseconfig() + + +@pytest.mark.parametrize("block_it", [True, False]) +def test_plugin_preparse_prevents_setuptools_loading( + pytester: Pytester, monkeypatch: MonkeyPatch, block_it: bool +) -> None: + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + plugin_module_placeholder = object() + + class DummyEntryPoint: + name = "mytestplugin" + group = "pytest11" + + def load(self): + return plugin_module_placeholder + + class Distribution: + version = "1.0" + files = ("foo.txt",) + metadata = {"name": "foo"} + entry_points = (DummyEntryPoint(),) + + def distributions(): + return (Distribution(),) + + monkeypatch.setattr(importlib_metadata, "distributions", distributions) + args = ("-p", "no:mytestplugin") if block_it else () + config = pytester.parseconfig(*args) + config.pluginmanager.import_plugin("mytestplugin") + if block_it: + assert "mytestplugin" not in sys.modules + assert config.pluginmanager.get_plugin("mytestplugin") is None + else: + assert ( + config.pluginmanager.get_plugin("mytestplugin") is plugin_module_placeholder + ) + + +@pytest.mark.parametrize( + "parse_args,should_load", [(("-p", "mytestplugin"), True), ((), False)] +) +def test_disable_plugin_autoload( + pytester: Pytester, + monkeypatch: MonkeyPatch, + parse_args: Union[Tuple[str, str], Tuple[()]], + should_load: bool, +) -> None: + class DummyEntryPoint: + project_name = name = "mytestplugin" + group = "pytest11" + version = "1.0" + + def load(self): + return sys.modules[self.name] + + class Distribution: + metadata = {"name": "foo"} + entry_points = (DummyEntryPoint(),) + files = () + + class PseudoPlugin: + x = 42 + + attrs_used = [] + + def __getattr__(self, name): + assert name == "__loader__" + self.attrs_used.append(name) + return object() + + def distributions(): + return (Distribution(),) + + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + monkeypatch.setattr(importlib_metadata, "distributions", distributions) + monkeypatch.setitem(sys.modules, "mytestplugin", PseudoPlugin()) # type: ignore[misc] + config = pytester.parseconfig(*parse_args) + has_loaded = config.pluginmanager.get_plugin("mytestplugin") is not None + assert has_loaded == should_load + if should_load: + assert PseudoPlugin.attrs_used == ["__loader__"] + else: + assert PseudoPlugin.attrs_used == [] + + +def test_plugin_loading_order(pytester: Pytester) -> None: + """Test order of plugin loading with `-p`.""" + p1 = pytester.makepyfile( + """ + def test_terminal_plugin(request): + import myplugin + assert myplugin.terminal_plugin == [False, True] + """, + **{ + "myplugin": """ + terminal_plugin = [] + + def pytest_configure(config): + terminal_plugin.append(bool(config.pluginmanager.get_plugin("terminalreporter"))) + + def pytest_sessionstart(session): + config = session.config + terminal_plugin.append(bool(config.pluginmanager.get_plugin("terminalreporter"))) + """ + }, + ) + pytester.syspathinsert() + result = pytester.runpytest("-p", "myplugin", str(p1)) + assert result.ret == 0 + + +def test_cmdline_processargs_simple(pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_cmdline_preparse(args): + args.append("-h") + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*pytest*", "*-h*"]) + + +def test_invalid_options_show_extra_information(pytester: Pytester) -> None: + """Display extra information when pytest exits due to unrecognized + options in the command-line.""" + pytester.makeini( + """ + [pytest] + addopts = --invalid-option + """ + ) + result = pytester.runpytest() + result.stderr.fnmatch_lines( + [ + "*error: unrecognized arguments: --invalid-option*", + "* inifile: %s*" % pytester.path.joinpath("tox.ini"), + "* rootdir: %s*" % pytester.path, + ] + ) + + +@pytest.mark.parametrize( + "args", + [ + ["dir1", "dir2", "-v"], + ["dir1", "-v", "dir2"], + ["dir2", "-v", "dir1"], + ["-v", "dir2", "dir1"], + ], +) +def test_consider_args_after_options_for_rootdir( + pytester: Pytester, args: List[str] +) -> None: + """ + Consider all arguments in the command-line for rootdir + discovery, even if they happen to occur after an option. #949 + """ + # replace "dir1" and "dir2" from "args" into their real directory + root = pytester.mkdir("myroot") + d1 = root.joinpath("dir1") + d1.mkdir() + d2 = root.joinpath("dir2") + d2.mkdir() + for i, arg in enumerate(args): + if arg == "dir1": + args[i] = str(d1) + elif arg == "dir2": + args[i] = str(d2) + with MonkeyPatch.context() as mp: + mp.chdir(root) + result = pytester.runpytest(*args) + result.stdout.fnmatch_lines(["*rootdir: *myroot"]) + + +def test_toolongargs_issue224(pytester: Pytester) -> None: + result = pytester.runpytest("-m", "hello" * 500) + assert result.ret == ExitCode.NO_TESTS_COLLECTED + + +def test_config_in_subdirectory_colon_command_line_issue2148( + pytester: Pytester, +) -> None: + conftest_source = """ + def pytest_addoption(parser): + parser.addini('foo', 'foo') + """ + + pytester.makefile( + ".ini", + **{"pytest": "[pytest]\nfoo = root", "subdir/pytest": "[pytest]\nfoo = subdir"}, + ) + + pytester.makepyfile( + **{ + "conftest": conftest_source, + "subdir/conftest": conftest_source, + "subdir/test_foo": """\ + def test_foo(pytestconfig): + assert pytestconfig.getini('foo') == 'subdir' + """, + } + ) + + result = pytester.runpytest("subdir/test_foo.py::test_foo") + assert result.ret == 0 + + +def test_notify_exception(pytester: Pytester, capfd) -> None: + config = pytester.parseconfig() + with pytest.raises(ValueError) as excinfo: + raise ValueError(1) + config.notify_exception(excinfo, config.option) + _, err = capfd.readouterr() + assert "ValueError" in err + + class A: + def pytest_internalerror(self): + return True + + config.pluginmanager.register(A()) + config.notify_exception(excinfo, config.option) + _, err = capfd.readouterr() + assert not err + + config = pytester.parseconfig("-p", "no:terminal") + with pytest.raises(ValueError) as excinfo: + raise ValueError(1) + config.notify_exception(excinfo, config.option) + _, err = capfd.readouterr() + assert "ValueError" in err + + +def test_no_terminal_discovery_error(pytester: Pytester) -> None: + pytester.makepyfile("raise TypeError('oops!')") + result = pytester.runpytest("-p", "no:terminal", "--collect-only") + assert result.ret == ExitCode.INTERRUPTED + + +def test_load_initial_conftest_last_ordering(_config_for_test): + pm = _config_for_test.pluginmanager + + class My: + def pytest_load_initial_conftests(self): + pass + + m = My() + pm.register(m) + hc = pm.hook.pytest_load_initial_conftests + hookimpls = [ + ( + hookimpl.function.__module__, + "wrapper" if hookimpl.hookwrapper else "nonwrapper", + ) + for hookimpl in hc.get_hookimpls() + ] + assert hookimpls == [ + ("_pytest.config", "nonwrapper"), + (m.__module__, "nonwrapper"), + ("_pytest.legacypath", "nonwrapper"), + ("_pytest.python_path", "nonwrapper"), + ("_pytest.capture", "wrapper"), + ("_pytest.warnings", "wrapper"), + ] + + +def test_get_plugin_specs_as_list() -> None: + def exp_match(val: object) -> str: + return ( + "Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: %s" + % re.escape(repr(val)) + ) + + with pytest.raises(pytest.UsageError, match=exp_match({"foo"})): + _get_plugin_specs_as_list({"foo"}) # type: ignore[arg-type] + with pytest.raises(pytest.UsageError, match=exp_match({})): + _get_plugin_specs_as_list(dict()) # type: ignore[arg-type] + + assert _get_plugin_specs_as_list(None) == [] + assert _get_plugin_specs_as_list("") == [] + assert _get_plugin_specs_as_list("foo") == ["foo"] + assert _get_plugin_specs_as_list("foo,bar") == ["foo", "bar"] + assert _get_plugin_specs_as_list(["foo", "bar"]) == ["foo", "bar"] + assert _get_plugin_specs_as_list(("foo", "bar")) == ["foo", "bar"] + + +def test_collect_pytest_prefix_bug_integration(pytester: Pytester) -> None: + """Integration test for issue #3775""" + p = pytester.copy_example("config/collect_pytest_prefix") + result = pytester.runpytest(p) + result.stdout.fnmatch_lines(["* 1 passed *"]) + + +def test_collect_pytest_prefix_bug(pytestconfig): + """Ensure we collect only actual functions from conftest files (#3775)""" + + class Dummy: + class pytest_something: + pass + + pm = pytestconfig.pluginmanager + assert pm.parse_hookimpl_opts(Dummy(), "pytest_something") is None + + +class TestRootdir: + def test_simple_noini(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + assert get_common_ancestor([tmp_path]) == tmp_path + a = tmp_path / "a" + a.mkdir() + assert get_common_ancestor([a, tmp_path]) == tmp_path + assert get_common_ancestor([tmp_path, a]) == tmp_path + monkeypatch.chdir(tmp_path) + assert get_common_ancestor([]) == tmp_path + no_path = tmp_path / "does-not-exist" + assert get_common_ancestor([no_path]) == tmp_path + assert get_common_ancestor([no_path / "a"]) == tmp_path + + @pytest.mark.parametrize( + "name, contents", + [ + pytest.param("pytest.ini", "[pytest]\nx=10", id="pytest.ini"), + pytest.param( + "pyproject.toml", "[tool.pytest.ini_options]\nx=10", id="pyproject.toml" + ), + pytest.param("tox.ini", "[pytest]\nx=10", id="tox.ini"), + pytest.param("setup.cfg", "[tool:pytest]\nx=10", id="setup.cfg"), + ], + ) + def test_with_ini(self, tmp_path: Path, name: str, contents: str) -> None: + inipath = tmp_path / name + inipath.write_text(contents, "utf-8") + + a = tmp_path / "a" + a.mkdir() + b = a / "b" + b.mkdir() + for args in ([str(tmp_path)], [str(a)], [str(b)]): + rootpath, parsed_inipath, _ = determine_setup(None, args) + assert rootpath == tmp_path + assert parsed_inipath == inipath + rootpath, parsed_inipath, ini_config = determine_setup(None, [str(b), str(a)]) + assert rootpath == tmp_path + assert parsed_inipath == inipath + assert ini_config == {"x": "10"} + + @pytest.mark.parametrize("name", ["setup.cfg", "tox.ini"]) + def test_pytestini_overrides_empty_other(self, tmp_path: Path, name: str) -> None: + inipath = tmp_path / "pytest.ini" + inipath.touch() + a = tmp_path / "a" + a.mkdir() + (a / name).touch() + rootpath, parsed_inipath, _ = determine_setup(None, [str(a)]) + assert rootpath == tmp_path + assert parsed_inipath == inipath + + def test_setuppy_fallback(self, tmp_path: Path) -> None: + a = tmp_path / "a" + a.mkdir() + (a / "setup.cfg").touch() + (tmp_path / "setup.py").touch() + rootpath, inipath, inicfg = determine_setup(None, [str(a)]) + assert rootpath == tmp_path + assert inipath is None + assert inicfg == {} + + def test_nothing(self, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + rootpath, inipath, inicfg = determine_setup(None, [str(tmp_path)]) + assert rootpath == tmp_path + assert inipath is None + assert inicfg == {} + + @pytest.mark.parametrize( + "name, contents", + [ + # pytest.param("pytest.ini", "[pytest]\nx=10", id="pytest.ini"), + pytest.param( + "pyproject.toml", "[tool.pytest.ini_options]\nx=10", id="pyproject.toml" + ), + # pytest.param("tox.ini", "[pytest]\nx=10", id="tox.ini"), + # pytest.param("setup.cfg", "[tool:pytest]\nx=10", id="setup.cfg"), + ], + ) + def test_with_specific_inifile( + self, tmp_path: Path, name: str, contents: str + ) -> None: + p = tmp_path / name + p.touch() + p.write_text(contents, "utf-8") + rootpath, inipath, ini_config = determine_setup(str(p), [str(tmp_path)]) + assert rootpath == tmp_path + assert inipath == p + assert ini_config == {"x": "10"} + + def test_explicit_config_file_sets_rootdir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + + monkeypatch.chdir(tmp_path) + + # No config file is explicitly given: rootdir is determined to be cwd. + rootpath, found_inipath, *_ = determine_setup(None, [str(tests_dir)]) + assert rootpath == tmp_path + assert found_inipath is None + + # Config file is explicitly given: rootdir is determined to be inifile's directory. + inipath = tmp_path / "pytest.ini" + inipath.touch() + rootpath, found_inipath, *_ = determine_setup(str(inipath), [str(tests_dir)]) + assert rootpath == tmp_path + assert found_inipath == inipath + + def test_with_arg_outside_cwd_without_inifile( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + a = tmp_path / "a" + a.mkdir() + b = tmp_path / "b" + b.mkdir() + rootpath, inifile, _ = determine_setup(None, [str(a), str(b)]) + assert rootpath == tmp_path + assert inifile is None + + def test_with_arg_outside_cwd_with_inifile(self, tmp_path: Path) -> None: + a = tmp_path / "a" + a.mkdir() + b = tmp_path / "b" + b.mkdir() + inipath = a / "pytest.ini" + inipath.touch() + rootpath, parsed_inipath, _ = determine_setup(None, [str(a), str(b)]) + assert rootpath == a + assert inipath == parsed_inipath + + @pytest.mark.parametrize("dirs", ([], ["does-not-exist"], ["a/does-not-exist"])) + def test_with_non_dir_arg( + self, dirs: Sequence[str], tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + rootpath, inipath, _ = determine_setup(None, dirs) + assert rootpath == tmp_path + assert inipath is None + + def test_with_existing_file_in_subdir( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + a = tmp_path / "a" + a.mkdir() + (a / "exists").touch() + monkeypatch.chdir(tmp_path) + rootpath, inipath, _ = determine_setup(None, ["a/exist"]) + assert rootpath == tmp_path + assert inipath is None + + def test_with_config_also_in_parent_directory( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """Regression test for #7807.""" + (tmp_path / "setup.cfg").write_text("[tool:pytest]\n", "utf-8") + (tmp_path / "myproject").mkdir() + (tmp_path / "myproject" / "setup.cfg").write_text("[tool:pytest]\n", "utf-8") + (tmp_path / "myproject" / "tests").mkdir() + monkeypatch.chdir(tmp_path / "myproject") + + rootpath, inipath, _ = determine_setup(None, ["tests/"]) + + assert rootpath == tmp_path / "myproject" + assert inipath == tmp_path / "myproject" / "setup.cfg" + + +class TestOverrideIniArgs: + @pytest.mark.parametrize("name", "setup.cfg tox.ini pytest.ini".split()) + def test_override_ini_names(self, pytester: Pytester, name: str) -> None: + section = "[pytest]" if name != "setup.cfg" else "[tool:pytest]" + pytester.path.joinpath(name).write_text( + textwrap.dedent( + """ + {section} + custom = 1.0""".format( + section=section + ) + ) + ) + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("custom", "")""" + ) + pytester.makepyfile( + """ + def test_pass(pytestconfig): + ini_val = pytestconfig.getini("custom") + print('\\ncustom_option:%s\\n' % ini_val)""" + ) + + result = pytester.runpytest("--override-ini", "custom=2.0", "-s") + assert result.ret == 0 + result.stdout.fnmatch_lines(["custom_option:2.0"]) + + result = pytester.runpytest( + "--override-ini", "custom=2.0", "--override-ini=custom=3.0", "-s" + ) + assert result.ret == 0 + result.stdout.fnmatch_lines(["custom_option:3.0"]) + + def test_override_ini_paths(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("paths", "my new ini value", type="paths")""" + ) + pytester.makeini( + """ + [pytest] + paths=blah.py""" + ) + pytester.makepyfile( + r""" + def test_overriden(pytestconfig): + config_paths = pytestconfig.getini("paths") + print(config_paths) + for cpf in config_paths: + print('\nuser_path:%s' % cpf.name) + """ + ) + result = pytester.runpytest( + "--override-ini", "paths=foo/bar1.py foo/bar2.py", "-s" + ) + result.stdout.fnmatch_lines(["user_path:bar1.py", "user_path:bar2.py"]) + + def test_override_multiple_and_default(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + addini = parser.addini + addini("custom_option_1", "", default="o1") + addini("custom_option_2", "", default="o2") + addini("custom_option_3", "", default=False, type="bool") + addini("custom_option_4", "", default=True, type="bool")""" + ) + pytester.makeini( + """ + [pytest] + custom_option_1=custom_option_1 + custom_option_2=custom_option_2 + """ + ) + pytester.makepyfile( + """ + def test_multiple_options(pytestconfig): + prefix = "custom_option" + for x in range(1, 5): + ini_value=pytestconfig.getini("%s_%d" % (prefix, x)) + print('\\nini%d:%s' % (x, ini_value)) + """ + ) + result = pytester.runpytest( + "--override-ini", + "custom_option_1=fulldir=/tmp/user1", + "-o", + "custom_option_2=url=/tmp/user2?a=b&d=e", + "-o", + "custom_option_3=True", + "-o", + "custom_option_4=no", + "-s", + ) + result.stdout.fnmatch_lines( + [ + "ini1:fulldir=/tmp/user1", + "ini2:url=/tmp/user2?a=b&d=e", + "ini3:True", + "ini4:False", + ] + ) + + def test_override_ini_usage_error_bad_style(self, pytester: Pytester) -> None: + pytester.makeini( + """ + [pytest] + xdist_strict=False + """ + ) + result = pytester.runpytest("--override-ini", "xdist_strict", "True") + result.stderr.fnmatch_lines( + [ + "ERROR: -o/--override-ini expects option=value style (got: 'xdist_strict').", + ] + ) + + @pytest.mark.parametrize("with_ini", [True, False]) + def test_override_ini_handled_asap( + self, pytester: Pytester, with_ini: bool + ) -> None: + """-o should be handled as soon as possible and always override what's in ini files (#2238)""" + if with_ini: + pytester.makeini( + """ + [pytest] + python_files=test_*.py + """ + ) + pytester.makepyfile( + unittest_ini_handle=""" + def test(): + pass + """ + ) + result = pytester.runpytest("--override-ini", "python_files=unittest_*.py") + result.stdout.fnmatch_lines(["*1 passed in*"]) + + def test_addopts_before_initini( + self, monkeypatch: MonkeyPatch, _config_for_test, _sys_snapshot + ) -> None: + cache_dir = ".custom_cache" + monkeypatch.setenv("PYTEST_ADDOPTS", "-o cache_dir=%s" % cache_dir) + config = _config_for_test + config._preparse([], addopts=True) + assert config._override_ini == ["cache_dir=%s" % cache_dir] + + def test_addopts_from_env_not_concatenated( + self, monkeypatch: MonkeyPatch, _config_for_test + ) -> None: + """PYTEST_ADDOPTS should not take values from normal args (#4265).""" + monkeypatch.setenv("PYTEST_ADDOPTS", "-o") + config = _config_for_test + with pytest.raises(UsageError) as excinfo: + config._preparse(["cache_dir=ignored"], addopts=True) + assert ( + "error: argument -o/--override-ini: expected one argument (via PYTEST_ADDOPTS)" + in excinfo.value.args[0] + ) + + def test_addopts_from_ini_not_concatenated(self, pytester: Pytester) -> None: + """`addopts` from ini should not take values from normal args (#4265).""" + pytester.makeini( + """ + [pytest] + addopts=-o + """ + ) + result = pytester.runpytest("cache_dir=ignored") + result.stderr.fnmatch_lines( + [ + "%s: error: argument -o/--override-ini: expected one argument (via addopts config)" + % (pytester._request.config._parser.optparser.prog,) + ] + ) + assert result.ret == _pytest.config.ExitCode.USAGE_ERROR + + def test_override_ini_does_not_contain_paths( + self, _config_for_test, _sys_snapshot + ) -> None: + """Check that -o no longer swallows all options after it (#3103)""" + config = _config_for_test + config._preparse(["-o", "cache_dir=/cache", "/some/test/path"]) + assert config._override_ini == ["cache_dir=/cache"] + + def test_multiple_override_ini_options(self, pytester: Pytester) -> None: + """Ensure a file path following a '-o' option does not generate an error (#3103)""" + pytester.makepyfile( + **{ + "conftest.py": """ + def pytest_addoption(parser): + parser.addini('foo', default=None, help='some option') + parser.addini('bar', default=None, help='some option') + """, + "test_foo.py": """ + def test(pytestconfig): + assert pytestconfig.getini('foo') == '1' + assert pytestconfig.getini('bar') == '0' + """, + "test_bar.py": """ + def test(): + assert False + """, + } + ) + result = pytester.runpytest("-o", "foo=1", "-o", "bar=0", "test_foo.py") + assert "ERROR:" not in result.stderr.str() + result.stdout.fnmatch_lines(["collected 1 item", "*= 1 passed in *="]) + + +def test_help_via_addopts(pytester: Pytester) -> None: + pytester.makeini( + """ + [pytest] + addopts = --unknown-option-should-allow-for-help --help + """ + ) + result = pytester.runpytest() + assert result.ret == 0 + result.stdout.fnmatch_lines( + [ + "usage: *", + "positional arguments:", + # Displays full/default help. + "to see available markers type: pytest --markers", + ] + ) + + +def test_help_and_version_after_argument_error(pytester: Pytester) -> None: + pytester.makeconftest( + """ + def validate(arg): + raise argparse.ArgumentTypeError("argerror") + + def pytest_addoption(parser): + group = parser.getgroup('cov') + group.addoption( + "--invalid-option-should-allow-for-help", + type=validate, + ) + """ + ) + pytester.makeini( + """ + [pytest] + addopts = --invalid-option-should-allow-for-help + """ + ) + result = pytester.runpytest("--help") + result.stdout.fnmatch_lines( + [ + "usage: *", + "positional arguments:", + "NOTE: displaying only minimal help due to UsageError.", + ] + ) + result.stderr.fnmatch_lines( + [ + "ERROR: usage: *", + "%s: error: argument --invalid-option-should-allow-for-help: expected one argument" + % (pytester._request.config._parser.optparser.prog,), + ] + ) + # Does not display full/default help. + assert "to see available markers type: pytest --markers" not in result.stdout.lines + assert result.ret == ExitCode.USAGE_ERROR + + result = pytester.runpytest("--version") + result.stdout.fnmatch_lines([f"pytest {pytest.__version__}"]) + assert result.ret == ExitCode.USAGE_ERROR + + +def test_help_formatter_uses_py_get_terminal_width(monkeypatch: MonkeyPatch) -> None: + from _pytest.config.argparsing import DropShorterLongHelpFormatter + + monkeypatch.setenv("COLUMNS", "90") + formatter = DropShorterLongHelpFormatter("prog") + assert formatter._width == 90 + + monkeypatch.setattr("_pytest._io.get_terminal_width", lambda: 160) + formatter = DropShorterLongHelpFormatter("prog") + assert formatter._width == 160 + + formatter = DropShorterLongHelpFormatter("prog", width=42) + assert formatter._width == 42 + + +def test_config_does_not_load_blocked_plugin_from_args(pytester: Pytester) -> None: + """This tests that pytest's config setup handles "-p no:X".""" + p = pytester.makepyfile("def test(capfd): pass") + result = pytester.runpytest(str(p), "-pno:capture") + result.stdout.fnmatch_lines(["E fixture 'capfd' not found"]) + assert result.ret == ExitCode.TESTS_FAILED + + result = pytester.runpytest(str(p), "-pno:capture", "-s") + result.stderr.fnmatch_lines(["*: error: unrecognized arguments: -s"]) + assert result.ret == ExitCode.USAGE_ERROR + + result = pytester.runpytest(str(p), "-p no:capture", "-s") + result.stderr.fnmatch_lines(["*: error: unrecognized arguments: -s"]) + assert result.ret == ExitCode.USAGE_ERROR + + +def test_invocation_args(pytester: Pytester) -> None: + """Ensure that Config.invocation_* arguments are correctly defined""" + + class DummyPlugin: + pass + + p = pytester.makepyfile("def test(): pass") + plugin = DummyPlugin() + rec = pytester.inline_run(p, "-v", plugins=[plugin]) + calls = rec.getcalls("pytest_runtest_protocol") + assert len(calls) == 1 + call = calls[0] + config = call.item.config + + assert config.invocation_params.args == (str(p), "-v") + assert config.invocation_params.dir == pytester.path + + plugins = config.invocation_params.plugins + assert len(plugins) == 2 + assert plugins[0] is plugin + assert type(plugins[1]).__name__ == "Collect" # installed by pytester.inline_run() + + # args cannot be None + with pytest.raises(TypeError): + Config.InvocationParams(args=None, plugins=None, dir=Path()) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "plugin", + [ + x + for x in _pytest.config.default_plugins + if x not in _pytest.config.essential_plugins + ], +) +def test_config_blocked_default_plugins(pytester: Pytester, plugin: str) -> None: + if plugin == "debugging": + # Fixed in xdist (after 1.27.0). + # https://github.com/pytest-dev/pytest-xdist/pull/422 + try: + import xdist # noqa: F401 + except ImportError: + pass + else: + pytest.skip("does not work with xdist currently") + + p = pytester.makepyfile("def test(): pass") + result = pytester.runpytest(str(p), "-pno:%s" % plugin) + + if plugin == "python": + assert result.ret == ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + [ + "ERROR: found no collectors for */test_config_blocked_default_plugins.py", + ] + ) + return + + assert result.ret == ExitCode.OK + if plugin != "terminal": + result.stdout.fnmatch_lines(["* 1 passed in *"]) + + p = pytester.makepyfile("def test(): assert 0") + result = pytester.runpytest(str(p), "-pno:%s" % plugin) + assert result.ret == ExitCode.TESTS_FAILED + if plugin != "terminal": + result.stdout.fnmatch_lines(["* 1 failed in *"]) + else: + assert result.stdout.lines == [] + + +class TestSetupCfg: + def test_pytest_setup_cfg_unsupported(self, pytester: Pytester) -> None: + pytester.makefile( + ".cfg", + setup=""" + [pytest] + addopts = --verbose + """, + ) + with pytest.raises(pytest.fail.Exception): + pytester.runpytest() + + def test_pytest_custom_cfg_unsupported(self, pytester: Pytester) -> None: + pytester.makefile( + ".cfg", + custom=""" + [pytest] + addopts = --verbose + """, + ) + with pytest.raises(pytest.fail.Exception): + pytester.runpytest("-c", "custom.cfg") + + with pytest.raises(pytest.fail.Exception): + pytester.runpytest("--config-file", "custom.cfg") + + +class TestPytestPluginsVariable: + def test_pytest_plugins_in_non_top_level_conftest_unsupported( + self, pytester: Pytester + ) -> None: + pytester.makepyfile( + **{ + "subdirectory/conftest.py": """ + pytest_plugins=['capture'] + """ + } + ) + pytester.makepyfile( + """ + def test_func(): + pass + """ + ) + res = pytester.runpytest() + assert res.ret == 2 + msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" + res.stdout.fnmatch_lines([f"*{msg}*", f"*subdirectory{os.sep}conftest.py*"]) + + @pytest.mark.parametrize("use_pyargs", [True, False]) + def test_pytest_plugins_in_non_top_level_conftest_unsupported_pyargs( + self, pytester: Pytester, use_pyargs: bool + ) -> None: + """When using --pyargs, do not emit the warning about non-top-level conftest warnings (#4039, #4044)""" + + files = { + "src/pkg/__init__.py": "", + "src/pkg/conftest.py": "", + "src/pkg/test_root.py": "def test(): pass", + "src/pkg/sub/__init__.py": "", + "src/pkg/sub/conftest.py": "pytest_plugins=['capture']", + "src/pkg/sub/test_bar.py": "def test(): pass", + } + pytester.makepyfile(**files) + pytester.syspathinsert(pytester.path.joinpath("src")) + + args = ("--pyargs", "pkg") if use_pyargs else () + res = pytester.runpytest(*args) + assert res.ret == (0 if use_pyargs else 2) + msg = ( + msg + ) = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" + if use_pyargs: + assert msg not in res.stdout.str() + else: + res.stdout.fnmatch_lines([f"*{msg}*"]) + + def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_top_level_conftest( + self, pytester: Pytester + ) -> None: + subdirectory = pytester.path.joinpath("subdirectory") + subdirectory.mkdir() + pytester.makeconftest( + """ + pytest_plugins=['capture'] + """ + ) + pytester.path.joinpath("conftest.py").rename( + subdirectory.joinpath("conftest.py") + ) + + pytester.makepyfile( + """ + def test_func(): + pass + """ + ) + + res = pytester.runpytest_subprocess() + assert res.ret == 2 + msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" + res.stdout.fnmatch_lines([f"*{msg}*", f"*subdirectory{os.sep}conftest.py*"]) + + def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_false_positives( + self, pytester: Pytester + ) -> None: + pytester.makepyfile( + "def test_func(): pass", + **{ + "subdirectory/conftest": "pass", + "conftest": """ + import warnings + warnings.filterwarnings('always', category=DeprecationWarning) + pytest_plugins=['capture'] + """, + }, + ) + res = pytester.runpytest_subprocess() + assert res.ret == 0 + msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" + assert msg not in res.stdout.str() + + +def test_conftest_import_error_repr(tmp_path: Path) -> None: + """`ConftestImportFailure` should use a short error message and readable + path to the failed conftest.py file.""" + path = tmp_path.joinpath("foo/conftest.py") + with pytest.raises( + ConftestImportFailure, + match=re.escape(f"RuntimeError: some error (from {path})"), + ): + try: + raise RuntimeError("some error") + except Exception as exc: + assert exc.__traceback__ is not None + exc_info = (type(exc), exc, exc.__traceback__) + raise ConftestImportFailure(path, exc_info) from exc + + +def test_strtobool() -> None: + assert _strtobool("YES") + assert not _strtobool("NO") + with pytest.raises(ValueError): + _strtobool("unknown") + + +@pytest.mark.parametrize( + "arg, escape, expected", + [ + ("ignore", False, ("ignore", "", Warning, "", 0)), + ( + "ignore::DeprecationWarning", + False, + ("ignore", "", DeprecationWarning, "", 0), + ), + ( + "ignore:some msg:DeprecationWarning", + False, + ("ignore", "some msg", DeprecationWarning, "", 0), + ), + ( + "ignore::DeprecationWarning:mod", + False, + ("ignore", "", DeprecationWarning, "mod", 0), + ), + ( + "ignore::DeprecationWarning:mod:42", + False, + ("ignore", "", DeprecationWarning, "mod", 42), + ), + ("error:some\\msg:::", True, ("error", "some\\\\msg", Warning, "", 0)), + ("error:::mod\\foo:", True, ("error", "", Warning, "mod\\\\foo\\Z", 0)), + ], +) +def test_parse_warning_filter( + arg: str, escape: bool, expected: Tuple[str, str, Type[Warning], str, int] +) -> None: + assert parse_warning_filter(arg, escape=escape) == expected + + +@pytest.mark.parametrize( + "arg", + [ + # Too much parts. + ":" * 5, + # Invalid action. + "FOO::", + # ImportError when importing the warning class. + "::test_parse_warning_filter_failure.NonExistentClass::", + # Class is not a Warning subclass. + "::list::", + # Negative line number. + "::::-1", + # Not a line number. + "::::not-a-number", + ], +) +def test_parse_warning_filter_failure(arg: str) -> None: + with pytest.raises(pytest.UsageError): + parse_warning_filter(arg, escape=True) + + +class TestDebugOptions: + def test_without_debug_does_not_write_log(self, pytester: Pytester) -> None: + result = pytester.runpytest() + result.stderr.no_fnmatch_line( + "*writing pytest debug information to*pytestdebug.log" + ) + result.stderr.no_fnmatch_line( + "*wrote pytest debug information to*pytestdebug.log" + ) + assert not [f.name for f in pytester.path.glob("**/*.log")] + + def test_with_only_debug_writes_pytestdebug_log(self, pytester: Pytester) -> None: + result = pytester.runpytest("--debug") + result.stderr.fnmatch_lines( + [ + "*writing pytest debug information to*pytestdebug.log", + "*wrote pytest debug information to*pytestdebug.log", + ] + ) + assert "pytestdebug.log" in [f.name for f in pytester.path.glob("**/*.log")] + + def test_multiple_custom_debug_logs(self, pytester: Pytester) -> None: + result = pytester.runpytest("--debug", "bar.log") + result.stderr.fnmatch_lines( + [ + "*writing pytest debug information to*bar.log", + "*wrote pytest debug information to*bar.log", + ] + ) + result = pytester.runpytest("--debug", "foo.log") + result.stderr.fnmatch_lines( + [ + "*writing pytest debug information to*foo.log", + "*wrote pytest debug information to*foo.log", + ] + ) + + assert {"bar.log", "foo.log"} == { + f.name for f in pytester.path.glob("**/*.log") + } + + def test_debug_help(self, pytester: Pytester) -> None: + result = pytester.runpytest("-h") + result.stdout.fnmatch_lines( + [ + "*Store internal tracing debug information in this log*", + "*file. This file is opened with 'w' and truncated as a*", + "*Default: pytestdebug.log.", + ] + )