From b24e862463e54c779cd51ee55244ad4483a07e40 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 13 May 2023 11:27:45 +0200 Subject: [PATCH 1/5] turn _pytest.python to package to prepare splitting --- src/_pytest/{python.py => python/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/_pytest/{python.py => python/__init__.py} (100%) diff --git a/src/_pytest/python.py b/src/_pytest/python/__init__.py similarity index 100% rename from src/_pytest/python.py rename to src/_pytest/python/__init__.py From c58281b9ae4745ade7609578d40b1a48fe350d93 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 13 May 2023 11:50:28 +0200 Subject: [PATCH 2/5] move show_fixtures code to own module --- src/_pytest/python/__init__.py | 148 ++------------------------- src/_pytest/python/show_fixtures.py | 152 ++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 142 deletions(-) create mode 100644 src/_pytest/python/show_fixtures.py diff --git a/src/_pytest/python/__init__.py b/src/_pytest/python/__init__.py index d04b6fa4d..2bb9e351c 100644 --- a/src/_pytest/python/__init__.py +++ b/src/_pytest/python/__init__.py @@ -35,7 +35,6 @@ from _pytest._code import filter_traceback from _pytest._code import getfslineno from _pytest._code.code import ExceptionInfo from _pytest._code.code import TerminalRepr -from _pytest._io import TerminalWriter from _pytest._io.saferepr import saferepr from _pytest.compat import ascii_escaped from _pytest.compat import assert_never @@ -43,7 +42,6 @@ from _pytest.compat import final from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func from _pytest.compat import getimfunc -from _pytest.compat import getlocation from _pytest.compat import is_async_function from _pytest.compat import is_generator from _pytest.compat import LEGACY_PATH @@ -69,7 +67,6 @@ from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import normalize_mark_list from _pytest.outcomes import fail from _pytest.outcomes import skip -from _pytest.pathlib import bestrelpath from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import import_path from _pytest.pathlib import ImportPathMismatchError @@ -86,9 +83,6 @@ if TYPE_CHECKING: from _pytest.scope import _ScopeName -_PYTEST_DIR = Path(_pytest.__file__).parent - - def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("general") group.addoption( @@ -137,11 +131,13 @@ def pytest_addoption(parser: Parser) -> None: def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: if config.option.showfixtures: - showfixtures(config) - return 0 + from .show_fixtures import showfixtures + + return showfixtures(config) if config.option.show_fixtures_per_test: - show_fixtures_per_test(config) - return 0 + from .show_fixtures import show_fixtures_per_test + + return show_fixtures_per_test(config) return None @@ -1551,138 +1547,6 @@ def _ascii_escaped_by_config(val: Union[str, bytes], config: Optional[Config]) - return val if escape_option else ascii_escaped(val) # type: ignore -def _pretty_fixture_path(func) -> str: - cwd = Path.cwd() - loc = Path(getlocation(func, str(cwd))) - prefix = Path("...", "_pytest") - try: - return str(prefix / loc.relative_to(_PYTEST_DIR)) - except ValueError: - return bestrelpath(cwd, loc) - - -def show_fixtures_per_test(config): - from _pytest.main import wrap_session - - return wrap_session(config, _show_fixtures_per_test) - - -def _show_fixtures_per_test(config: Config, session: Session) -> None: - import _pytest.config - - session.perform_collect() - curdir = Path.cwd() - tw = _pytest.config.create_terminal_writer(config) - verbose = config.getvalue("verbose") - - def get_best_relpath(func) -> str: - loc = getlocation(func, str(curdir)) - return bestrelpath(curdir, Path(loc)) - - def write_fixture(fixture_def: fixtures.FixtureDef[object]) -> None: - argname = fixture_def.argname - if verbose <= 0 and argname.startswith("_"): - return - prettypath = _pretty_fixture_path(fixture_def.func) - tw.write(f"{argname}", green=True) - tw.write(f" -- {prettypath}", yellow=True) - tw.write("\n") - fixture_doc = inspect.getdoc(fixture_def.func) - if fixture_doc: - write_docstring( - tw, fixture_doc.split("\n\n")[0] if verbose <= 0 else fixture_doc - ) - else: - tw.line(" no docstring available", red=True) - - def write_item(item: nodes.Item) -> None: - # Not all items have _fixtureinfo attribute. - info: Optional[FuncFixtureInfo] = getattr(item, "_fixtureinfo", None) - if info is None or not info.name2fixturedefs: - # This test item does not use any fixtures. - return - tw.line() - tw.sep("-", f"fixtures used by {item.name}") - # TODO: Fix this type ignore. - tw.sep("-", f"({get_best_relpath(item.function)})") # type: ignore[attr-defined] - # dict key not used in loop but needed for sorting. - for _, fixturedefs in sorted(info.name2fixturedefs.items()): - assert fixturedefs is not None - if not fixturedefs: - continue - # Last item is expected to be the one used by the test item. - write_fixture(fixturedefs[-1]) - - for session_item in session.items: - write_item(session_item) - - -def showfixtures(config: Config) -> Union[int, ExitCode]: - from _pytest.main import wrap_session - - return wrap_session(config, _showfixtures_main) - - -def _showfixtures_main(config: Config, session: Session) -> None: - import _pytest.config - - session.perform_collect() - curdir = Path.cwd() - tw = _pytest.config.create_terminal_writer(config) - verbose = config.getvalue("verbose") - - fm = session._fixturemanager - - available = [] - seen: Set[Tuple[str, str]] = set() - - for argname, fixturedefs in fm._arg2fixturedefs.items(): - assert fixturedefs is not None - if not fixturedefs: - continue - for fixturedef in fixturedefs: - loc = getlocation(fixturedef.func, str(curdir)) - if (fixturedef.argname, loc) in seen: - continue - seen.add((fixturedef.argname, loc)) - available.append( - ( - len(fixturedef.baseid), - fixturedef.func.__module__, - _pretty_fixture_path(fixturedef.func), - fixturedef.argname, - fixturedef, - ) - ) - - available.sort() - currentmodule = None - for baseid, module, prettypath, argname, fixturedef in available: - if currentmodule != module: - if not module.startswith("_pytest."): - tw.line() - tw.sep("-", f"fixtures defined from {module}") - currentmodule = module - if verbose <= 0 and argname.startswith("_"): - continue - tw.write(f"{argname}", green=True) - if fixturedef.scope != "function": - tw.write(" [%s scope]" % fixturedef.scope, cyan=True) - tw.write(f" -- {prettypath}", yellow=True) - tw.write("\n") - doc = inspect.getdoc(fixturedef.func) - if doc: - write_docstring(tw, doc.split("\n\n")[0] if verbose <= 0 else doc) - else: - tw.line(" no docstring available", red=True) - tw.line() - - -def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None: - for line in doc.split("\n"): - tw.line(indent + line) - - class Function(PyobjMixin, nodes.Item): """An Item responsible for setting up and executing a Python test function. diff --git a/src/_pytest/python/show_fixtures.py b/src/_pytest/python/show_fixtures.py new file mode 100644 index 000000000..1beed4613 --- /dev/null +++ b/src/_pytest/python/show_fixtures.py @@ -0,0 +1,152 @@ +import inspect +from pathlib import Path +from typing import Optional +from typing import Set +from typing import Tuple +from typing import Union + +import _pytest +from _pytest import fixtures +from _pytest import nodes +from _pytest._io import TerminalWriter +from _pytest.compat import getlocation +from _pytest.config import Config +from _pytest.config import ExitCode +from _pytest.fixtures import FuncFixtureInfo +from _pytest.main import Session +from _pytest.pathlib import bestrelpath + + +_PYTEST_DIR = Path(_pytest.__file__).parent + + +def _showfixtures_main(config: Config, session: Session) -> None: + import _pytest.config + + session.perform_collect() + curdir = Path.cwd() + tw = _pytest.config.create_terminal_writer(config) + verbose = config.getvalue("verbose") + + fm = session._fixturemanager + + available = [] + seen: Set[Tuple[str, str]] = set() + + for argname, fixturedefs in fm._arg2fixturedefs.items(): + assert fixturedefs is not None + if not fixturedefs: + continue + for fixturedef in fixturedefs: + loc = getlocation(fixturedef.func, str(curdir)) + if (fixturedef.argname, loc) in seen: + continue + seen.add((fixturedef.argname, loc)) + available.append( + ( + len(fixturedef.baseid), + fixturedef.func.__module__, + _pretty_fixture_path(fixturedef.func), + fixturedef.argname, + fixturedef, + ) + ) + + available.sort() + currentmodule = None + for baseid, module, prettypath, argname, fixturedef in available: + if currentmodule != module: + if not module.startswith("_pytest."): + tw.line() + tw.sep("-", f"fixtures defined from {module}") + currentmodule = module + if verbose <= 0 and argname.startswith("_"): + continue + tw.write(f"{argname}", green=True) + if fixturedef.scope != "function": + tw.write(" [%s scope]" % fixturedef.scope, cyan=True) + tw.write(f" -- {prettypath}", yellow=True) + tw.write("\n") + doc = inspect.getdoc(fixturedef.func) + if doc: + write_docstring(tw, doc.split("\n\n")[0] if verbose <= 0 else doc) + else: + tw.line(" no docstring available", red=True) + tw.line() + + +def _pretty_fixture_path(func) -> str: + cwd = Path.cwd() + loc = Path(getlocation(func, str(cwd))) + prefix = Path("...", "_pytest") + try: + return str(prefix / loc.relative_to(_PYTEST_DIR)) + except ValueError: + return bestrelpath(cwd, loc) + + +def show_fixtures_per_test(config: Config) -> Union[int, ExitCode]: + from _pytest.main import wrap_session + + return wrap_session(config, _show_fixtures_per_test) + + +def _show_fixtures_per_test(config: Config, session: Session) -> None: + import _pytest.config + + session.perform_collect() + curdir = Path.cwd() + tw = _pytest.config.create_terminal_writer(config) + verbose = config.getvalue("verbose") + + def get_best_relpath(func) -> str: + loc = getlocation(func, str(curdir)) + return bestrelpath(curdir, Path(loc)) + + def write_fixture(fixture_def: fixtures.FixtureDef[object]) -> None: + argname = fixture_def.argname + if verbose <= 0 and argname.startswith("_"): + return + prettypath = _pretty_fixture_path(fixture_def.func) + tw.write(f"{argname}", green=True) + tw.write(f" -- {prettypath}", yellow=True) + tw.write("\n") + fixture_doc = inspect.getdoc(fixture_def.func) + if fixture_doc: + write_docstring( + tw, fixture_doc.split("\n\n")[0] if verbose <= 0 else fixture_doc + ) + else: + tw.line(" no docstring available", red=True) + + def write_item(item: nodes.Item) -> None: + # Not all items have _fixtureinfo attribute. + info: Optional[FuncFixtureInfo] = getattr(item, "_fixtureinfo", None) + if info is None or not info.name2fixturedefs: + # This test item does not use any fixtures. + return + tw.line() + tw.sep("-", f"fixtures used by {item.name}") + # TODO: Fix this type ignore. + tw.sep("-", f"({get_best_relpath(item.function)})") # type: ignore[attr-defined] + # dict key not used in loop but needed for sorting. + for _, fixturedefs in sorted(info.name2fixturedefs.items()): + assert fixturedefs is not None + if not fixturedefs: + continue + # Last item is expected to be the one used by the test item. + write_fixture(fixturedefs[-1]) + + for session_item in session.items: + write_item(session_item) + + +def showfixtures(config: Config) -> Union[int, ExitCode]: + from _pytest.main import wrap_session + + return wrap_session(config, _showfixtures_main) + + +def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None: + for line in doc.split("\n"): + tw.line(indent + line) From 2efd0015307bb9eb6c146b54a383ea024891db75 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 13 May 2023 12:31:45 +0200 Subject: [PATCH 3/5] initial split out of metafunc --- src/_pytest/fixtures.py | 4 +- src/_pytest/hookspec.py | 2 +- src/_pytest/python/__init__.py | 622 +------------------------------- src/_pytest/python/metafunc.py | 635 +++++++++++++++++++++++++++++++++ src/pytest/__init__.py | 2 - testing/python/metafunc.py | 11 +- 6 files changed, 647 insertions(+), 629 deletions(-) create mode 100644 src/_pytest/python/metafunc.py diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 007245b24..56d09eea4 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -71,9 +71,7 @@ if TYPE_CHECKING: from _pytest.scope import _ScopeName from _pytest.main import Session - from _pytest.python import CallSpec2 - from _pytest.python import Metafunc - + from _pytest.python.metafunc import CallSpec2, Metafunc # The value of the fixture -- return/yield of the fixture function (type variable). FixtureValue = TypeVar("FixtureValue") diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 95b4265ea..ca6a2892a 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -34,8 +34,8 @@ if TYPE_CHECKING: from _pytest.nodes import Item from _pytest.outcomes import Exit from _pytest.python import Class + from _pytest.python.metafunc import Metafunc from _pytest.python import Function - from _pytest.python import Metafunc from _pytest.python import Module from _pytest.reports import CollectReport from _pytest.reports import TestReport diff --git a/src/_pytest/python/__init__.py b/src/_pytest/python/__init__.py index 2bb9e351c..ad810b582 100644 --- a/src/_pytest/python/__init__.py +++ b/src/_pytest/python/__init__.py @@ -1,19 +1,13 @@ """Python test discovery, setup and run of test functions.""" -import dataclasses -import enum import fnmatch import inspect -import itertools import os import sys import types import warnings -from collections import Counter -from collections import defaultdict from functools import partial from pathlib import Path from typing import Any -from typing import Callable from typing import Dict from typing import Generator from typing import Iterable @@ -21,7 +15,6 @@ from typing import Iterator from typing import List from typing import Mapping from typing import Optional -from typing import Pattern from typing import Sequence from typing import Set from typing import Tuple @@ -35,11 +28,6 @@ from _pytest._code import filter_traceback from _pytest._code import getfslineno from _pytest._code.code import ExceptionInfo from _pytest._code.code import TerminalRepr -from _pytest._io.saferepr import saferepr -from _pytest.compat import ascii_escaped -from _pytest.compat import assert_never -from _pytest.compat import final -from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func from _pytest.compat import getimfunc from _pytest.compat import is_async_function @@ -48,39 +36,31 @@ from _pytest.compat import LEGACY_PATH from _pytest.compat import NOTSET from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass -from _pytest.compat import STRING_TYPES from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config.argparsing import Parser -from _pytest.deprecated import check_ispytest from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH from _pytest.deprecated import INSTANCE_COLLECTOR from _pytest.deprecated import NOSE_SUPPORT_METHOD from _pytest.fixtures import FuncFixtureInfo from _pytest.main import Session from _pytest.mark import MARK_GEN -from _pytest.mark import ParameterSet from _pytest.mark.structures import get_unpacked_marks -from _pytest.mark.structures import Mark -from _pytest.mark.structures import MarkDecorator -from _pytest.mark.structures import normalize_mark_list -from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import import_path from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import parts from _pytest.pathlib import visit -from _pytest.scope import Scope +from _pytest.python.metafunc import CallSpec2 +from _pytest.python.metafunc import Metafunc from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestReturnNotNoneWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning if TYPE_CHECKING: - from typing_extensions import Literal - - from _pytest.scope import _ScopeName + pass def pytest_addoption(parser: Parser) -> None: @@ -951,602 +931,6 @@ def hasnew(obj: object) -> bool: return False -@final -@dataclasses.dataclass(frozen=True) -class IdMaker: - """Make IDs for a parametrization.""" - - __slots__ = ( - "argnames", - "parametersets", - "idfn", - "ids", - "config", - "nodeid", - "func_name", - ) - - # The argnames of the parametrization. - argnames: Sequence[str] - # The ParameterSets of the parametrization. - parametersets: Sequence[ParameterSet] - # Optionally, a user-provided callable to make IDs for parameters in a - # ParameterSet. - idfn: Optional[Callable[[Any], Optional[object]]] - # Optionally, explicit IDs for ParameterSets by index. - ids: Optional[Sequence[Optional[object]]] - # Optionally, the pytest config. - # Used for controlling ASCII escaping, and for calling the - # :hook:`pytest_make_parametrize_id` hook. - config: Optional[Config] - # Optionally, the ID of the node being parametrized. - # Used only for clearer error messages. - nodeid: Optional[str] - # Optionally, the ID of the function being parametrized. - # Used only for clearer error messages. - func_name: Optional[str] - - def make_unique_parameterset_ids(self) -> List[str]: - """Make a unique identifier for each ParameterSet, that may be used to - identify the parametrization in a node ID. - - Format is -...-[counter], where prm_x_token is - - user-provided id, if given - - else an id derived from the value, applicable for certain types - - else - The counter suffix is appended only in case a string wouldn't be unique - otherwise. - """ - resolved_ids = list(self._resolve_ids()) - # All IDs must be unique! - if len(resolved_ids) != len(set(resolved_ids)): - # Record the number of occurrences of each ID. - id_counts = Counter(resolved_ids) - # Map the ID to its next suffix. - id_suffixes: Dict[str, int] = defaultdict(int) - # Suffix non-unique IDs to make them unique. - for index, id in enumerate(resolved_ids): - if id_counts[id] > 1: - resolved_ids[index] = f"{id}{id_suffixes[id]}" - id_suffixes[id] += 1 - return resolved_ids - - def _resolve_ids(self) -> Iterable[str]: - """Resolve IDs for all ParameterSets (may contain duplicates).""" - for idx, parameterset in enumerate(self.parametersets): - if parameterset.id is not None: - # ID provided directly - pytest.param(..., id="...") - yield parameterset.id - elif self.ids and idx < len(self.ids) and self.ids[idx] is not None: - # ID provided in the IDs list - parametrize(..., ids=[...]). - yield self._idval_from_value_required(self.ids[idx], idx) - else: - # ID not provided - generate it. - yield "-".join( - self._idval(val, argname, idx) - for val, argname in zip(parameterset.values, self.argnames) - ) - - def _idval(self, val: object, argname: str, idx: int) -> str: - """Make an ID for a parameter in a ParameterSet.""" - idval = self._idval_from_function(val, argname, idx) - if idval is not None: - return idval - idval = self._idval_from_hook(val, argname) - if idval is not None: - return idval - idval = self._idval_from_value(val) - if idval is not None: - return idval - return self._idval_from_argname(argname, idx) - - def _idval_from_function( - self, val: object, argname: str, idx: int - ) -> Optional[str]: - """Try to make an ID for a parameter in a ParameterSet using the - user-provided id callable, if given.""" - if self.idfn is None: - return None - try: - id = self.idfn(val) - except Exception as e: - prefix = f"{self.nodeid}: " if self.nodeid is not None else "" - msg = "error raised while trying to determine id of parameter '{}' at position {}" - msg = prefix + msg.format(argname, idx) - raise ValueError(msg) from e - if id is None: - return None - return self._idval_from_value(id) - - def _idval_from_hook(self, val: object, argname: str) -> Optional[str]: - """Try to make an ID for a parameter in a ParameterSet by calling the - :hook:`pytest_make_parametrize_id` hook.""" - if self.config: - id: Optional[str] = self.config.hook.pytest_make_parametrize_id( - config=self.config, val=val, argname=argname - ) - return id - return None - - def _idval_from_value(self, val: object) -> Optional[str]: - """Try to make an ID for a parameter in a ParameterSet from its value, - if the value type is supported.""" - if isinstance(val, STRING_TYPES): - return _ascii_escaped_by_config(val, self.config) - elif val is None or isinstance(val, (float, int, bool, complex)): - return str(val) - elif isinstance(val, Pattern): - return ascii_escaped(val.pattern) - elif val is NOTSET: - # Fallback to default. Note that NOTSET is an enum.Enum. - pass - elif isinstance(val, enum.Enum): - return str(val) - elif isinstance(getattr(val, "__name__", None), str): - # Name of a class, function, module, etc. - name: str = getattr(val, "__name__") - return name - return None - - def _idval_from_value_required(self, val: object, idx: int) -> str: - """Like _idval_from_value(), but fails if the type is not supported.""" - id = self._idval_from_value(val) - if id is not None: - return id - - # Fail. - if self.func_name is not None: - prefix = f"In {self.func_name}: " - elif self.nodeid is not None: - prefix = f"In {self.nodeid}: " - else: - prefix = "" - msg = ( - f"{prefix}ids contains unsupported value {saferepr(val)} (type: {type(val)!r}) at index {idx}. " - "Supported types are: str, bytes, int, float, complex, bool, enum, regex or anything with a __name__." - ) - fail(msg, pytrace=False) - - @staticmethod - def _idval_from_argname(argname: str, idx: int) -> str: - """Make an ID for a parameter in a ParameterSet from the argument name - and the index of the ParameterSet.""" - return str(argname) + str(idx) - - -@final -@dataclasses.dataclass(frozen=True) -class CallSpec2: - """A planned parameterized invocation of a test function. - - Calculated during collection for a given test function's Metafunc. - Once collection is over, each callspec is turned into a single Item - and stored in item.callspec. - """ - - # arg name -> arg value which will be passed to the parametrized test - # function (direct parameterization). - funcargs: Dict[str, object] = dataclasses.field(default_factory=dict) - # arg name -> arg value which will be passed to a fixture of the same name - # (indirect parametrization). - params: Dict[str, object] = dataclasses.field(default_factory=dict) - # arg name -> arg index. - indices: Dict[str, int] = dataclasses.field(default_factory=dict) - # Used for sorting parametrized resources. - _arg2scope: Dict[str, Scope] = dataclasses.field(default_factory=dict) - # Parts which will be added to the item's name in `[..]` separated by "-". - _idlist: List[str] = dataclasses.field(default_factory=list) - # Marks which will be applied to the item. - marks: List[Mark] = dataclasses.field(default_factory=list) - - def setmulti( - self, - *, - valtypes: Mapping[str, "Literal['params', 'funcargs']"], - argnames: Iterable[str], - valset: Iterable[object], - id: str, - marks: Iterable[Union[Mark, MarkDecorator]], - scope: Scope, - param_index: int, - ) -> "CallSpec2": - funcargs = self.funcargs.copy() - params = self.params.copy() - indices = self.indices.copy() - arg2scope = self._arg2scope.copy() - for arg, val in zip(argnames, valset): - if arg in params or arg in funcargs: - raise ValueError(f"duplicate {arg!r}") - valtype_for_arg = valtypes[arg] - if valtype_for_arg == "params": - params[arg] = val - elif valtype_for_arg == "funcargs": - funcargs[arg] = val - else: - assert_never(valtype_for_arg) - indices[arg] = param_index - arg2scope[arg] = scope - return CallSpec2( - funcargs=funcargs, - params=params, - indices=indices, - _arg2scope=arg2scope, - _idlist=[*self._idlist, id], - marks=[*self.marks, *normalize_mark_list(marks)], - ) - - def getparam(self, name: str) -> object: - try: - return self.params[name] - except KeyError as e: - raise ValueError(name) from e - - @property - def id(self) -> str: - return "-".join(self._idlist) - - -@final -class Metafunc: - """Objects passed to the :hook:`pytest_generate_tests` hook. - - They help to inspect a test function and to generate tests according to - test configuration or values specified in the class or module where a - test function is defined. - """ - - def __init__( - self, - definition: "FunctionDefinition", - fixtureinfo: fixtures.FuncFixtureInfo, - config: Config, - cls=None, - module=None, - *, - _ispytest: bool = False, - ) -> None: - check_ispytest(_ispytest) - - #: Access to the underlying :class:`_pytest.python.FunctionDefinition`. - self.definition = definition - - #: Access to the :class:`pytest.Config` object for the test session. - self.config = config - - #: The module object where the test function is defined in. - self.module = module - - #: Underlying Python test function. - self.function = definition.obj - - #: Set of fixture names required by the test function. - self.fixturenames = fixtureinfo.names_closure - - #: Class object where the test function is defined in or ``None``. - self.cls = cls - - self._arg2fixturedefs = fixtureinfo.name2fixturedefs - - # Result of parametrize(). - self._calls: List[CallSpec2] = [] - - def parametrize( - self, - argnames: Union[str, Sequence[str]], - argvalues: Iterable[Union[ParameterSet, Sequence[object], object]], - indirect: Union[bool, Sequence[str]] = False, - ids: Optional[ - Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]] - ] = None, - scope: "Optional[_ScopeName]" = None, - *, - _param_mark: Optional[Mark] = None, - ) -> None: - """Add new invocations to the underlying test function using the list - of argvalues for the given argnames. Parametrization is performed - during the collection phase. If you need to setup expensive resources - see about setting indirect to do it rather than at test setup time. - - Can be called multiple times, in which case each call parametrizes all - previous parametrizations, e.g. - - :: - - unparametrized: t - parametrize ["x", "y"]: t[x], t[y] - parametrize [1, 2]: t[x-1], t[x-2], t[y-1], t[y-2] - - :param argnames: - A comma-separated string denoting one or more argument names, or - a list/tuple of argument strings. - - :param argvalues: - The list of argvalues determines how often a test is invoked with - different argument values. - - If only one argname was specified argvalues is a list of values. - If N argnames were specified, argvalues must be a list of - N-tuples, where each tuple-element specifies a value for its - respective argname. - - :param indirect: - A list of arguments' names (subset of argnames) or a boolean. - If True the list contains all names from the argnames. Each - argvalue corresponding to an argname in this list will - be passed as request.param to its respective argname fixture - function so that it can perform more expensive setups during the - setup phase of a test rather than at collection time. - - :param ids: - Sequence of (or generator for) ids for ``argvalues``, - or a callable to return part of the id for each argvalue. - - With sequences (and generators like ``itertools.count()``) the - returned ids should be of type ``string``, ``int``, ``float``, - ``bool``, or ``None``. - They are mapped to the corresponding index in ``argvalues``. - ``None`` means to use the auto-generated id. - - If it is a callable it will be called for each entry in - ``argvalues``, and the return value is used as part of the - auto-generated id for the whole set (where parts are joined with - dashes ("-")). - This is useful to provide more specific ids for certain items, e.g. - dates. Returning ``None`` will use an auto-generated id. - - If no ids are provided they will be generated automatically from - the argvalues. - - :param scope: - If specified it denotes the scope of the parameters. - The scope is used for grouping tests by parameter instances. - It will also override any fixture-function defined scope, allowing - to set a dynamic scope using test context or configuration. - """ - argnames, parametersets = ParameterSet._for_parametrize( - argnames, - argvalues, - self.function, - self.config, - nodeid=self.definition.nodeid, - ) - del argvalues - - if "request" in argnames: - fail( - "'request' is a reserved name and cannot be used in @pytest.mark.parametrize", - pytrace=False, - ) - - if scope is not None: - scope_ = Scope.from_user( - scope, descr=f"parametrize() call in {self.function.__name__}" - ) - else: - scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect) - - self._validate_if_using_arg_names(argnames, indirect) - - arg_values_types = self._resolve_arg_value_types(argnames, indirect) - - # Use any already (possibly) generated ids with parametrize Marks. - if _param_mark and _param_mark._param_ids_from: - generated_ids = _param_mark._param_ids_from._param_ids_generated - if generated_ids is not None: - ids = generated_ids - - ids = self._resolve_parameter_set_ids( - argnames, ids, parametersets, nodeid=self.definition.nodeid - ) - - # Store used (possibly generated) ids with parametrize Marks. - if _param_mark and _param_mark._param_ids_from and generated_ids is None: - object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids) - - # Create the new calls: if we are parametrize() multiple times (by applying the decorator - # more than once) then we accumulate those calls generating the cartesian product - # of all calls. - newcalls = [] - for callspec in self._calls or [CallSpec2()]: - for param_index, (param_id, param_set) in enumerate( - zip(ids, parametersets) - ): - newcallspec = callspec.setmulti( - valtypes=arg_values_types, - argnames=argnames, - valset=param_set.values, - id=param_id, - marks=param_set.marks, - scope=scope_, - param_index=param_index, - ) - newcalls.append(newcallspec) - self._calls = newcalls - - def _resolve_parameter_set_ids( - self, - argnames: Sequence[str], - ids: Optional[ - Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]] - ], - parametersets: Sequence[ParameterSet], - nodeid: str, - ) -> List[str]: - """Resolve the actual ids for the given parameter sets. - - :param argnames: - Argument names passed to ``parametrize()``. - :param ids: - The `ids` parameter of the ``parametrize()`` call (see docs). - :param parametersets: - The parameter sets, each containing a set of values corresponding - to ``argnames``. - :param nodeid str: - The nodeid of the definition item that generated this - parametrization. - :returns: - List with ids for each parameter set given. - """ - if ids is None: - idfn = None - ids_ = None - elif callable(ids): - idfn = ids - ids_ = None - else: - idfn = None - ids_ = self._validate_ids(ids, parametersets, self.function.__name__) - id_maker = IdMaker( - argnames, - parametersets, - idfn, - ids_, - self.config, - nodeid=nodeid, - func_name=self.function.__name__, - ) - return id_maker.make_unique_parameterset_ids() - - def _validate_ids( - self, - ids: Iterable[Optional[object]], - parametersets: Sequence[ParameterSet], - func_name: str, - ) -> List[Optional[object]]: - try: - num_ids = len(ids) # type: ignore[arg-type] - except TypeError: - try: - iter(ids) - except TypeError as e: - raise TypeError("ids must be a callable or an iterable") from e - num_ids = len(parametersets) - - # num_ids == 0 is a special case: https://github.com/pytest-dev/pytest/issues/1849 - if num_ids != len(parametersets) and num_ids != 0: - msg = "In {}: {} parameter sets specified, with different number of ids: {}" - fail(msg.format(func_name, len(parametersets), num_ids), pytrace=False) - - return list(itertools.islice(ids, num_ids)) - - def _resolve_arg_value_types( - self, - argnames: Sequence[str], - indirect: Union[bool, Sequence[str]], - ) -> Dict[str, "Literal['params', 'funcargs']"]: - """Resolve if each parametrized argument must be considered a - parameter to a fixture or a "funcarg" to the function, based on the - ``indirect`` parameter of the parametrized() call. - - :param List[str] argnames: List of argument names passed to ``parametrize()``. - :param indirect: Same as the ``indirect`` parameter of ``parametrize()``. - :rtype: Dict[str, str] - A dict mapping each arg name to either: - * "params" if the argname should be the parameter of a fixture of the same name. - * "funcargs" if the argname should be a parameter to the parametrized test function. - """ - if isinstance(indirect, bool): - valtypes: Dict[str, Literal["params", "funcargs"]] = dict.fromkeys( - argnames, "params" if indirect else "funcargs" - ) - elif isinstance(indirect, Sequence): - valtypes = dict.fromkeys(argnames, "funcargs") - for arg in indirect: - if arg not in argnames: - fail( - "In {}: indirect fixture '{}' doesn't exist".format( - self.function.__name__, arg - ), - pytrace=False, - ) - valtypes[arg] = "params" - else: - fail( - "In {func}: expected Sequence or boolean for indirect, got {type}".format( - type=type(indirect).__name__, func=self.function.__name__ - ), - pytrace=False, - ) - return valtypes - - def _validate_if_using_arg_names( - self, - argnames: Sequence[str], - indirect: Union[bool, Sequence[str]], - ) -> None: - """Check if all argnames are being used, by default values, or directly/indirectly. - - :param List[str] argnames: List of argument names passed to ``parametrize()``. - :param indirect: Same as the ``indirect`` parameter of ``parametrize()``. - :raises ValueError: If validation fails. - """ - default_arg_names = set(get_default_arg_names(self.function)) - func_name = self.function.__name__ - for arg in argnames: - if arg not in self.fixturenames: - if arg in default_arg_names: - fail( - "In {}: function already takes an argument '{}' with a default value".format( - func_name, arg - ), - pytrace=False, - ) - else: - if isinstance(indirect, Sequence): - name = "fixture" if arg in indirect else "argument" - else: - name = "fixture" if indirect else "argument" - fail( - f"In {func_name}: function uses no {name} '{arg}'", - pytrace=False, - ) - - -def _find_parametrized_scope( - argnames: Sequence[str], - arg2fixturedefs: Mapping[str, Sequence[fixtures.FixtureDef[object]]], - indirect: Union[bool, Sequence[str]], -) -> Scope: - """Find the most appropriate scope for a parametrized call based on its arguments. - - When there's at least one direct argument, always use "function" scope. - - When a test function is parametrized and all its arguments are indirect - (e.g. fixtures), return the most narrow scope based on the fixtures used. - - Related to issue #1832, based on code posted by @Kingdread. - """ - if isinstance(indirect, Sequence): - all_arguments_are_fixtures = len(indirect) == len(argnames) - else: - all_arguments_are_fixtures = bool(indirect) - - if all_arguments_are_fixtures: - fixturedefs = arg2fixturedefs or {} - used_scopes = [ - fixturedef[0]._scope - for name, fixturedef in fixturedefs.items() - if name in argnames - ] - # Takes the most narrow scope from used fixtures. - return min(used_scopes, default=Scope.Function) - - return Scope.Function - - -def _ascii_escaped_by_config(val: Union[str, bytes], config: Optional[Config]) -> str: - if config is None: - escape_option = False - else: - escape_option = config.getini( - "disable_test_id_escaping_and_forfeit_all_rights_to_community_support" - ) - # TODO: If escaping is turned off and the user passes bytes, - # will return a bytes. For now we ignore this but the - # code *probably* doesn't handle this case. - return val if escape_option else ascii_escaped(val) # type: ignore - - class Function(PyobjMixin, nodes.Item): """An Item responsible for setting up and executing a Python test function. diff --git a/src/_pytest/python/metafunc.py b/src/_pytest/python/metafunc.py new file mode 100644 index 000000000..db70fe1ae --- /dev/null +++ b/src/_pytest/python/metafunc.py @@ -0,0 +1,635 @@ +import dataclasses +import enum +import itertools +from collections import Counter +from collections import defaultdict +from typing import Any +from typing import Callable +from typing import Dict +from typing import Iterable +from typing import List +from typing import Mapping +from typing import Optional +from typing import Pattern +from typing import Sequence +from typing import Union + +from typing_extensions import Literal + +from _pytest import fixtures +from _pytest import python +from _pytest._io.saferepr import saferepr +from _pytest.compat import ascii_escaped +from _pytest.compat import assert_never +from _pytest.compat import final +from _pytest.compat import get_default_arg_names +from _pytest.compat import NOTSET +from _pytest.compat import STRING_TYPES +from _pytest.config import Config +from _pytest.deprecated import check_ispytest +from _pytest.mark import Mark +from _pytest.mark import MarkDecorator +from _pytest.mark import ParameterSet +from _pytest.mark.structures import normalize_mark_list +from _pytest.outcomes import fail +from _pytest.scope import _ScopeName +from _pytest.scope import Scope + + +@final +@dataclasses.dataclass(frozen=True) +class IdMaker: + """Make IDs for a parametrization.""" + + __slots__ = ( + "argnames", + "parametersets", + "idfn", + "ids", + "config", + "nodeid", + "func_name", + ) + + # The argnames of the parametrization. + argnames: Sequence[str] + # The ParameterSets of the parametrization. + parametersets: Sequence[ParameterSet] + # Optionally, a user-provided callable to make IDs for parameters in a + # ParameterSet. + idfn: Optional[Callable[[Any], Optional[object]]] + # Optionally, explicit IDs for ParameterSets by index. + ids: Optional[Sequence[Optional[object]]] + # Optionally, the pytest config. + # Used for controlling ASCII escaping, and for calling the + # :hook:`pytest_make_parametrize_id` hook. + config: Optional[Config] + # Optionally, the ID of the node being parametrized. + # Used only for clearer error messages. + nodeid: Optional[str] + # Optionally, the ID of the function being parametrized. + # Used only for clearer error messages. + func_name: Optional[str] + + def make_unique_parameterset_ids(self) -> List[str]: + """Make a unique identifier for each ParameterSet, that may be used to + identify the parametrization in a node ID. + + Format is -...-[counter], where prm_x_token is + - user-provided id, if given + - else an id derived from the value, applicable for certain types + - else + The counter suffix is appended only in case a string wouldn't be unique + otherwise. + """ + resolved_ids = list(self._resolve_ids()) + # All IDs must be unique! + if len(resolved_ids) != len(set(resolved_ids)): + # Record the number of occurrences of each ID. + id_counts = Counter(resolved_ids) + # Map the ID to its next suffix. + id_suffixes: Dict[str, int] = defaultdict(int) + # Suffix non-unique IDs to make them unique. + for index, id in enumerate(resolved_ids): + if id_counts[id] > 1: + resolved_ids[index] = f"{id}{id_suffixes[id]}" + id_suffixes[id] += 1 + return resolved_ids + + def _resolve_ids(self) -> Iterable[str]: + """Resolve IDs for all ParameterSets (may contain duplicates).""" + for idx, parameterset in enumerate(self.parametersets): + if parameterset.id is not None: + # ID provided directly - pytest.param(..., id="...") + yield parameterset.id + elif self.ids and idx < len(self.ids) and self.ids[idx] is not None: + # ID provided in the IDs list - parametrize(..., ids=[...]). + yield self._idval_from_value_required(self.ids[idx], idx) + else: + # ID not provided - generate it. + yield "-".join( + self._idval(val, argname, idx) + for val, argname in zip(parameterset.values, self.argnames) + ) + + def _idval(self, val: object, argname: str, idx: int) -> str: + """Make an ID for a parameter in a ParameterSet.""" + idval = self._idval_from_function(val, argname, idx) + if idval is not None: + return idval + idval = self._idval_from_hook(val, argname) + if idval is not None: + return idval + idval = self._idval_from_value(val) + if idval is not None: + return idval + return self._idval_from_argname(argname, idx) + + def _idval_from_function( + self, val: object, argname: str, idx: int + ) -> Optional[str]: + """Try to make an ID for a parameter in a ParameterSet using the + user-provided id callable, if given.""" + if self.idfn is None: + return None + try: + id = self.idfn(val) + except Exception as e: + prefix = f"{self.nodeid}: " if self.nodeid is not None else "" + msg = "error raised while trying to determine id of parameter '{}' at position {}" + msg = prefix + msg.format(argname, idx) + raise ValueError(msg) from e + if id is None: + return None + return self._idval_from_value(id) + + def _idval_from_hook(self, val: object, argname: str) -> Optional[str]: + """Try to make an ID for a parameter in a ParameterSet by calling the + :hook:`pytest_make_parametrize_id` hook.""" + if self.config: + id: Optional[str] = self.config.hook.pytest_make_parametrize_id( + config=self.config, val=val, argname=argname + ) + return id + return None + + def _idval_from_value(self, val: object) -> Optional[str]: + """Try to make an ID for a parameter in a ParameterSet from its value, + if the value type is supported.""" + if isinstance(val, STRING_TYPES): + return _ascii_escaped_by_config(val, self.config) + elif val is None or isinstance(val, (float, int, bool, complex)): + return str(val) + elif isinstance(val, Pattern): + return ascii_escaped(val.pattern) + elif val is NOTSET: + # Fallback to default. Note that NOTSET is an enum.Enum. + pass + elif isinstance(val, enum.Enum): + return str(val) + elif isinstance(getattr(val, "__name__", None), str): + # Name of a class, function, module, etc. + name: str = getattr(val, "__name__") + return name + return None + + def _idval_from_value_required(self, val: object, idx: int) -> str: + """Like _idval_from_value(), but fails if the type is not supported.""" + id = self._idval_from_value(val) + if id is not None: + return id + + # Fail. + if self.func_name is not None: + prefix = f"In {self.func_name}: " + elif self.nodeid is not None: + prefix = f"In {self.nodeid}: " + else: + prefix = "" + msg = ( + f"{prefix}ids contains unsupported value {saferepr(val)} (type: {type(val)!r}) at index {idx}. " + "Supported types are: str, bytes, int, float, complex, bool, enum, regex or anything with a __name__." + ) + fail(msg, pytrace=False) + + @staticmethod + def _idval_from_argname(argname: str, idx: int) -> str: + """Make an ID for a parameter in a ParameterSet from the argument name + and the index of the ParameterSet.""" + return str(argname) + str(idx) + + +def _ascii_escaped_by_config(val: Union[str, bytes], config: Optional[Config]) -> str: + if config is None: + escape_option = False + else: + escape_option = config.getini( + "disable_test_id_escaping_and_forfeit_all_rights_to_community_support" + ) + # TODO: If escaping is turned off and the user passes bytes, + # will return a bytes. For now we ignore this but the + # code *probably* doesn't handle this case. + return val if escape_option else ascii_escaped(val) # type: ignore + + +@final +@dataclasses.dataclass(frozen=True) +class CallSpec2: + """A planned parameterized invocation of a test function. + + Calculated during collection for a given test function's Metafunc. + Once collection is over, each callspec is turned into a single Item + and stored in item.callspec. + """ + + # arg name -> arg value which will be passed to the parametrized test + # function (direct parameterization). + funcargs: Dict[str, object] = dataclasses.field(default_factory=dict) + # arg name -> arg value which will be passed to a fixture of the same name + # (indirect parametrization). + params: Dict[str, object] = dataclasses.field(default_factory=dict) + # arg name -> arg index. + indices: Dict[str, int] = dataclasses.field(default_factory=dict) + # Used for sorting parametrized resources. + _arg2scope: Dict[str, Scope] = dataclasses.field(default_factory=dict) + # Parts which will be added to the item's name in `[..]` separated by "-". + _idlist: List[str] = dataclasses.field(default_factory=list) + # Marks which will be applied to the item. + marks: List[Mark] = dataclasses.field(default_factory=list) + + def setmulti( + self, + *, + valtypes: Mapping[str, "Literal['params', 'funcargs']"], + argnames: Iterable[str], + valset: Iterable[object], + id: str, + marks: Iterable[Union[Mark, MarkDecorator]], + scope: Scope, + param_index: int, + ) -> "CallSpec2": + funcargs = self.funcargs.copy() + params = self.params.copy() + indices = self.indices.copy() + arg2scope = self._arg2scope.copy() + for arg, val in zip(argnames, valset): + if arg in params or arg in funcargs: + raise ValueError(f"duplicate {arg!r}") + valtype_for_arg = valtypes[arg] + if valtype_for_arg == "params": + params[arg] = val + elif valtype_for_arg == "funcargs": + funcargs[arg] = val + else: + assert_never(valtype_for_arg) + indices[arg] = param_index + arg2scope[arg] = scope + return CallSpec2( + funcargs=funcargs, + params=params, + indices=indices, + _arg2scope=arg2scope, + _idlist=[*self._idlist, id], + marks=[*self.marks, *normalize_mark_list(marks)], + ) + + def getparam(self, name: str) -> object: + try: + return self.params[name] + except KeyError as e: + raise ValueError(name) from e + + @property + def id(self) -> str: + return "-".join(self._idlist) + + +@final +class Metafunc: + """Objects passed to the :hook:`pytest_generate_tests` hook. + + They help to inspect a test function and to generate tests according to + test configuration or values specified in the class or module where a + test function is defined. + """ + + fixturenames: List[str] + _arg2fixturedefs: Dict[str, Sequence["fixtures.FixtureDef[Any]"]] + + def __init__( + self, + definition: "python.FunctionDefinition", + fixtureinfo: fixtures.FuncFixtureInfo, + config: Config, + cls=None, + module=None, + *, + _ispytest: bool = False, + ) -> None: + check_ispytest(_ispytest) + + #: Access to the underlying :class:`_pytest.python.FunctionDefinition`. + self.definition = definition + + #: Access to the :class:`pytest.Config` object for the test session. + self.config = config + + #: The module object where the test function is defined in. + self.module = module + + #: Underlying Python test function. + self.function = definition.obj + + #: Set of fixture names required by the test function. + self.fixturenames = fixtureinfo.names_closure + + #: Class object where the test function is defined in or ``None``. + self.cls = cls + + self._arg2fixturedefs = fixtureinfo.name2fixturedefs + + # Result of parametrize(). + self._calls: List[CallSpec2] = [] + + def parametrize( + self, + argnames: Union[str, Sequence[str]], + argvalues: Iterable[Union[ParameterSet, Sequence[object], object]], + indirect: Union[bool, Sequence[str]] = False, + ids: Optional[ + Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]] + ] = None, + scope: "Optional[_ScopeName]" = None, + *, + _param_mark: Optional[Mark] = None, + ) -> None: + """Add new invocations to the underlying test function using the list + of argvalues for the given argnames. Parametrization is performed + during the collection phase. If you need to setup expensive resources + see about setting indirect to do it rather than at test setup time. + + Can be called multiple times, in which case each call parametrizes all + previous parametrizations, e.g. + + :: + + unparametrized: t + parametrize ["x", "y"]: t[x], t[y] + parametrize [1, 2]: t[x-1], t[x-2], t[y-1], t[y-2] + + :param argnames: + A comma-separated string denoting one or more argument names, or + a list/tuple of argument strings. + + :param argvalues: + The list of argvalues determines how often a test is invoked with + different argument values. + + If only one argname was specified argvalues is a list of values. + If N argnames were specified, argvalues must be a list of + N-tuples, where each tuple-element specifies a value for its + respective argname. + + :param indirect: + A list of arguments' names (subset of argnames) or a boolean. + If True the list contains all names from the argnames. Each + argvalue corresponding to an argname in this list will + be passed as request.param to its respective argname fixture + function so that it can perform more expensive setups during the + setup phase of a test rather than at collection time. + + :param ids: + Sequence of (or generator for) ids for ``argvalues``, + or a callable to return part of the id for each argvalue. + + With sequences (and generators like ``itertools.count()``) the + returned ids should be of type ``string``, ``int``, ``float``, + ``bool``, or ``None``. + They are mapped to the corresponding index in ``argvalues``. + ``None`` means to use the auto-generated id. + + If it is a callable it will be called for each entry in + ``argvalues``, and the return value is used as part of the + auto-generated id for the whole set (where parts are joined with + dashes ("-")). + This is useful to provide more specific ids for certain items, e.g. + dates. Returning ``None`` will use an auto-generated id. + + If no ids are provided they will be generated automatically from + the argvalues. + + :param scope: + If specified it denotes the scope of the parameters. + The scope is used for grouping tests by parameter instances. + It will also override any fixture-function defined scope, allowing + to set a dynamic scope using test context or configuration. + """ + argnames, parametersets = ParameterSet._for_parametrize( + argnames, + argvalues, + self.function, + self.config, + nodeid=self.definition.nodeid, + ) + del argvalues + + if "request" in argnames: + fail( + "'request' is a reserved name and cannot be used in @pytest.mark.parametrize", + pytrace=False, + ) + + if scope is not None: + scope_ = Scope.from_user( + scope, descr=f"parametrize() call in {self.function.__name__}" + ) + else: + scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect) + + self._validate_if_using_arg_names(argnames, indirect) + + arg_values_types = self._resolve_arg_value_types(argnames, indirect) + + # Use any already (possibly) generated ids with parametrize Marks. + if _param_mark and _param_mark._param_ids_from: + generated_ids = _param_mark._param_ids_from._param_ids_generated + if generated_ids is not None: + ids = generated_ids + + ids = self._resolve_parameter_set_ids( + argnames, ids, parametersets, nodeid=self.definition.nodeid + ) + + # Store used (possibly generated) ids with parametrize Marks. + if _param_mark and _param_mark._param_ids_from and generated_ids is None: + object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids) + + # Create the new calls: if we are parametrize() multiple times (by applying the decorator + # more than once) then we accumulate those calls generating the cartesian product + # of all calls. + newcalls = [] + for callspec in self._calls or [CallSpec2()]: + for param_index, (param_id, param_set) in enumerate( + zip(ids, parametersets) + ): + newcallspec = callspec.setmulti( + valtypes=arg_values_types, + argnames=argnames, + valset=param_set.values, + id=param_id, + marks=param_set.marks, + scope=scope_, + param_index=param_index, + ) + newcalls.append(newcallspec) + self._calls = newcalls + + def _resolve_parameter_set_ids( + self, + argnames: Sequence[str], + ids: Optional[ + Union[Iterable[Optional[object]], Callable[[Any], Optional[object]]] + ], + parametersets: Sequence[ParameterSet], + nodeid: str, + ) -> List[str]: + """Resolve the actual ids for the given parameter sets. + + :param argnames: + Argument names passed to ``parametrize()``. + :param ids: + The `ids` parameter of the ``parametrize()`` call (see docs). + :param parametersets: + The parameter sets, each containing a set of values corresponding + to ``argnames``. + :param nodeid str: + The nodeid of the definition item that generated this + parametrization. + :returns: + List with ids for each parameter set given. + """ + if ids is None: + idfn = None + ids_ = None + elif callable(ids): + idfn = ids + ids_ = None + else: + idfn = None + ids_ = self._validate_ids(ids, parametersets, self.function.__name__) + id_maker = IdMaker( + argnames, + parametersets, + idfn, + ids_, + self.config, + nodeid=nodeid, + func_name=self.function.__name__, + ) + return id_maker.make_unique_parameterset_ids() + + def _validate_ids( + self, + ids: Iterable[Optional[object]], + parametersets: Sequence[ParameterSet], + func_name: str, + ) -> List[Optional[object]]: + try: + num_ids = len(ids) # type: ignore[arg-type] + except TypeError: + try: + iter(ids) + except TypeError as e: + raise TypeError("ids must be a callable or an iterable") from e + num_ids = len(parametersets) + + # num_ids == 0 is a special case: https://github.com/pytest-dev/pytest/issues/1849 + if num_ids != len(parametersets) and num_ids != 0: + msg = "In {}: {} parameter sets specified, with different number of ids: {}" + fail(msg.format(func_name, len(parametersets), num_ids), pytrace=False) + + return list(itertools.islice(ids, num_ids)) + + def _resolve_arg_value_types( + self, + argnames: Sequence[str], + indirect: Union[bool, Sequence[str]], + ) -> Dict[str, "Literal['params', 'funcargs']"]: + """Resolve if each parametrized argument must be considered a + parameter to a fixture or a "funcarg" to the function, based on the + ``indirect`` parameter of the parametrized() call. + + :param List[str] argnames: List of argument names passed to ``parametrize()``. + :param indirect: Same as the ``indirect`` parameter of ``parametrize()``. + :rtype: Dict[str, str] + A dict mapping each arg name to either: + * "params" if the argname should be the parameter of a fixture of the same name. + * "funcargs" if the argname should be a parameter to the parametrized test function. + """ + if isinstance(indirect, bool): + valtypes: Dict[str, Literal["params", "funcargs"]] = dict.fromkeys( + argnames, "params" if indirect else "funcargs" + ) + elif isinstance(indirect, Sequence): + valtypes = dict.fromkeys(argnames, "funcargs") + for arg in indirect: + if arg not in argnames: + fail( + "In {}: indirect fixture '{}' doesn't exist".format( + self.function.__name__, arg + ), + pytrace=False, + ) + valtypes[arg] = "params" + else: + fail( + "In {func}: expected Sequence or boolean for indirect, got {type}".format( + type=type(indirect).__name__, func=self.function.__name__ + ), + pytrace=False, + ) + return valtypes + + def _validate_if_using_arg_names( + self, + argnames: Sequence[str], + indirect: Union[bool, Sequence[str]], + ) -> None: + """Check if all argnames are being used, by default values, or directly/indirectly. + + :param List[str] argnames: List of argument names passed to ``parametrize()``. + :param indirect: Same as the ``indirect`` parameter of ``parametrize()``. + :raises ValueError: If validation fails. + """ + default_arg_names = set(get_default_arg_names(self.function)) + func_name = self.function.__name__ + for arg in argnames: + if arg not in self.fixturenames: + if arg in default_arg_names: + fail( + "In {}: function already takes an argument '{}' with a default value".format( + func_name, arg + ), + pytrace=False, + ) + else: + if isinstance(indirect, Sequence): + name = "fixture" if arg in indirect else "argument" + else: + name = "fixture" if indirect else "argument" + fail( + f"In {func_name}: function uses no {name} '{arg}'", + pytrace=False, + ) + + +def _find_parametrized_scope( + argnames: Sequence[str], + arg2fixturedefs: Mapping[str, Sequence[fixtures.FixtureDef[object]]], + indirect: Union[bool, Sequence[str]], +) -> Scope: + """Find the most appropriate scope for a parametrized call based on its arguments. + + When there's at least one direct argument, always use "function" scope. + + When a test function is parametrized and all its arguments are indirect + (e.g. fixtures), return the most narrow scope based on the fixtures used. + + Related to issue #1832, based on code posted by @Kingdread. + """ + if isinstance(indirect, Sequence): + all_arguments_are_fixtures = len(indirect) == len(argnames) + else: + all_arguments_are_fixtures = bool(indirect) + + if all_arguments_are_fixtures: + fixturedefs = arg2fixturedefs or {} + used_scopes = [ + fixturedef[0]._scope + for name, fixturedef in fixturedefs.items() + if name in argnames + ] + # Takes the most narrow scope from used fixtures. + return min(used_scopes, default=Scope.Function) + + return Scope.Function diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index f25ecde9c..ebf132c4b 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -49,7 +49,6 @@ from _pytest.pytester import RecordedHookCall from _pytest.pytester import RunResult from _pytest.python import Class from _pytest.python import Function -from _pytest.python import Metafunc from _pytest.python import Module from _pytest.python import Package from _pytest.python_api import approx @@ -116,7 +115,6 @@ __all__ = [ "Mark", "MarkDecorator", "MarkGenerator", - "Metafunc", "Module", "MonkeyPatch", "OptionGroup", diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index c1cc9c3d3..b1ff53ac2 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -16,6 +16,7 @@ from typing import Union import hypothesis from hypothesis import strategies +import _pytest.python.metafunc import pytest from _pytest import fixtures from _pytest import python @@ -24,12 +25,12 @@ from _pytest.compat import getfuncargnames from _pytest.compat import NOTSET from _pytest.outcomes import fail from _pytest.pytester import Pytester -from _pytest.python import IdMaker +from _pytest.python.metafunc import IdMaker from _pytest.scope import Scope class TestMetafunc: - def Metafunc(self, func, config=None) -> python.Metafunc: + def Metafunc(self, func, config=None) -> _pytest.python.metafunc.Metafunc: # The unit tests of this class check if things work correctly # on the funcarg level, so we don't need a full blown # initialization. @@ -47,7 +48,9 @@ class TestMetafunc: names = getfuncargnames(func) fixtureinfo: Any = FuncFixtureInfoMock(names) definition: Any = DefinitionMock._create(obj=func, _nodeid="mock::nodeid") - return python.Metafunc(definition, fixtureinfo, config, _ispytest=True) + return _pytest.python.metafunc.Metafunc( + definition, fixtureinfo, config, _ispytest=True + ) def test_no_funcargs(self) -> None: def function(): @@ -138,7 +141,7 @@ class TestMetafunc: def test_find_parametrized_scope(self) -> None: """Unit test for _find_parametrized_scope (#3941).""" - from _pytest.python import _find_parametrized_scope + from _pytest.python.metafunc import _find_parametrized_scope @dataclasses.dataclass class DummyFixtureDef: From e1596f44a273962eab5541585aa88f252b5dc852 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 13 May 2023 13:46:26 +0200 Subject: [PATCH 4/5] fixup typing + imports --- src/_pytest/python/metafunc.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/_pytest/python/metafunc.py b/src/_pytest/python/metafunc.py index db70fe1ae..3c0b59822 100644 --- a/src/_pytest/python/metafunc.py +++ b/src/_pytest/python/metafunc.py @@ -12,10 +12,9 @@ from typing import Mapping from typing import Optional from typing import Pattern from typing import Sequence +from typing import TYPE_CHECKING from typing import Union -from typing_extensions import Literal - from _pytest import fixtures from _pytest import python from _pytest._io.saferepr import saferepr @@ -32,9 +31,12 @@ from _pytest.mark import MarkDecorator from _pytest.mark import ParameterSet from _pytest.mark.structures import normalize_mark_list from _pytest.outcomes import fail -from _pytest.scope import _ScopeName from _pytest.scope import Scope +if TYPE_CHECKING: + from _pytest.scope import _ScopeName + from typing_extensions import Literal + @final @dataclasses.dataclass(frozen=True) From 9d26781cb66a8b23695b575b8fe48dbf23bc366b Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 13 May 2023 21:57:02 +0200 Subject: [PATCH 5/5] move python nodes to own module --- .../global_testmodule_config/conftest.py | 6 +- src/_pytest/cacheprovider.py | 4 +- src/_pytest/doctest.py | 2 +- src/_pytest/fixtures.py | 18 +- src/_pytest/hookspec.py | 4 +- src/_pytest/main.py | 2 +- src/_pytest/nose.py | 2 +- src/_pytest/python/__init__.py | 893 +----------------- src/_pytest/python/metafunc.py | 4 +- src/_pytest/python/nodes.py | 881 +++++++++++++++++ src/_pytest/tmpdir.py | 4 +- src/_pytest/unittest.py | 6 +- src/pytest/__init__.py | 8 - testing/deprecated_test.py | 5 +- testing/python/collect.py | 19 +- testing/python/fixtures.py | 5 +- testing/python/integration.py | 2 +- testing/test_collection.py | 33 +- 18 files changed, 963 insertions(+), 935 deletions(-) create mode 100644 src/_pytest/python/nodes.py diff --git a/doc/en/example/assertion/global_testmodule_config/conftest.py b/doc/en/example/assertion/global_testmodule_config/conftest.py index 7cdf18cdb..ee3117ea2 100644 --- a/doc/en/example/assertion/global_testmodule_config/conftest.py +++ b/doc/en/example/assertion/global_testmodule_config/conftest.py @@ -1,14 +1,14 @@ import os.path -import pytest +import _pytest.python.nodes mydir = os.path.dirname(__file__) def pytest_runtest_setup(item): - if isinstance(item, pytest.Function): + if isinstance(item, _pytest.python.nodes.Function): if not item.fspath.relto(mydir): return - mod = item.getparent(pytest.Module).obj + mod = item.getparent(_pytest.python.nodes.Module).obj if hasattr(mod, "hello"): print(f"mod.hello {mod.hello!r}") diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 719b32f7e..18bfe6890 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -27,8 +27,8 @@ from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session -from _pytest.python import Module -from _pytest.python import Package +from _pytest.python.nodes import Module +from _pytest.python.nodes import Package from _pytest.reports import TestReport README_CONTENT = """\ diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 455ad62cc..a5c46649d 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -39,7 +39,7 @@ from _pytest.outcomes import OutcomeException from _pytest.outcomes import skip from _pytest.pathlib import fnmatch_ex from _pytest.pathlib import import_path -from _pytest.python import Module +from _pytest.python.nodes import Module from _pytest.python_api import approx from _pytest.warning_types import PytestWarning diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 56d09eea4..8223834f8 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -29,7 +29,7 @@ from typing import TYPE_CHECKING from typing import TypeVar from typing import Union -import _pytest +import _pytest.python.nodes from _pytest import nodes from _pytest._code import getfslineno from _pytest._code.code import FormattedExcinfo @@ -111,9 +111,9 @@ def pytest_sessionstart(session: "Session") -> None: def get_scope_package(node, fixturedef: "FixtureDef[object]"): - import pytest + pass - cls = pytest.Package + cls = _pytest.python.nodes.Package current = node fixture_package_name = "{}/{}".format(fixturedef.baseid, "__init__.py") while current and ( @@ -133,11 +133,11 @@ def get_scope_node( if scope is Scope.Function: return node.getparent(nodes.Item) elif scope is Scope.Class: - return node.getparent(_pytest.python.Class) + return node.getparent(_pytest.python.nodes.Class) elif scope is Scope.Module: - return node.getparent(_pytest.python.Module) + return node.getparent(_pytest.python.nodes.Module) elif scope is Scope.Package: - return node.getparent(_pytest.python.Package) + return node.getparent(_pytest.python.nodes.Package) elif scope is Scope.Session: return node.getparent(_pytest.main.Session) else: @@ -189,7 +189,7 @@ def add_funcarg_pseudo_fixture_def( node = get_scope_node(collector, scope) if node is None: assert scope is Scope.Class and isinstance( - collector, _pytest.python.Module + collector, _pytest.python.nodes.Module ) # Use module-level collector for class-scope (for now). node = collector @@ -472,7 +472,7 @@ class FixtureRequest: """Class (can be None) where the test function was collected.""" if self.scope not in ("class", "function"): raise AttributeError(f"cls not available in {self.scope}-scoped context") - clscol = self._pyfuncitem.getparent(_pytest.python.Class) + clscol = self._pyfuncitem.getparent(_pytest.python.nodes.Class) if clscol: return clscol.obj @@ -491,7 +491,7 @@ class FixtureRequest: """Python module object where the test function was collected.""" if self.scope not in ("function", "class", "module"): raise AttributeError(f"module not available in {self.scope}-scoped context") - return self._pyfuncitem.getparent(_pytest.python.Module).obj + return self._pyfuncitem.getparent(_pytest.python.nodes.Module).obj @property def path(self) -> Path: diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index ca6a2892a..98d0adc74 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -33,10 +33,8 @@ if TYPE_CHECKING: from _pytest.nodes import Collector from _pytest.nodes import Item from _pytest.outcomes import Exit - from _pytest.python import Class + from _pytest.python.nodes import Class, Module, Function from _pytest.python.metafunc import Metafunc - from _pytest.python import Function - from _pytest.python import Module from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.runner import CallInfo diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 5f8ac4689..526435a88 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -675,7 +675,7 @@ class Session(nodes.FSCollector): return items def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]: - from _pytest.python import Package + from _pytest.python.nodes import Package # Keep track of any collected nodes in here, so we don't duplicate fixtures. node_cache1: Dict[Path, Sequence[nodes.Collector]] = {} diff --git a/src/_pytest/nose.py b/src/_pytest/nose.py index 273bd045f..707d15bd4 100644 --- a/src/_pytest/nose.py +++ b/src/_pytest/nose.py @@ -5,7 +5,7 @@ from _pytest.config import hookimpl from _pytest.deprecated import NOSE_SUPPORT from _pytest.fixtures import getfixturemarker from _pytest.nodes import Item -from _pytest.python import Function +from _pytest.python.nodes import Function from _pytest.unittest import TestCaseFunction diff --git a/src/_pytest/python/__init__.py b/src/_pytest/python/__init__.py index ad810b582..520eb51de 100644 --- a/src/_pytest/python/__init__.py +++ b/src/_pytest/python/__init__.py @@ -1,60 +1,28 @@ """Python test discovery, setup and run of test functions.""" -import fnmatch import inspect -import os -import sys -import types import warnings -from functools import partial from pathlib import Path -from typing import Any -from typing import Dict -from typing import Generator -from typing import Iterable -from typing import Iterator from typing import List -from typing import Mapping from typing import Optional -from typing import Sequence -from typing import Set -from typing import Tuple from typing import TYPE_CHECKING from typing import Union -import _pytest -from _pytest import fixtures -from _pytest import nodes -from _pytest._code import filter_traceback +from . import nodes +from .. import nodes as base_nodes from _pytest._code import getfslineno -from _pytest._code.code import ExceptionInfo -from _pytest._code.code import TerminalRepr from _pytest.compat import get_real_func -from _pytest.compat import getimfunc from _pytest.compat import is_async_function from _pytest.compat import is_generator -from _pytest.compat import LEGACY_PATH -from _pytest.compat import NOTSET -from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config.argparsing import Parser -from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH from _pytest.deprecated import INSTANCE_COLLECTOR -from _pytest.deprecated import NOSE_SUPPORT_METHOD -from _pytest.fixtures import FuncFixtureInfo -from _pytest.main import Session from _pytest.mark import MARK_GEN -from _pytest.mark.structures import get_unpacked_marks from _pytest.outcomes import skip -from _pytest.pathlib import fnmatch_ex -from _pytest.pathlib import import_path -from _pytest.pathlib import ImportPathMismatchError -from _pytest.pathlib import parts -from _pytest.pathlib import visit -from _pytest.python.metafunc import CallSpec2 from _pytest.python.metafunc import Metafunc +from _pytest.python.nodes import path_matches_patterns from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestReturnNotNoneWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning @@ -161,7 +129,7 @@ def async_warn_and_skip(nodeid: str) -> None: @hookimpl(trylast=True) -def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: +def pytest_pyfunc_call(pyfuncitem: "nodes.Function") -> Optional[object]: testfunction = pyfuncitem.obj if is_async_function(testfunction): async_warn_and_skip(pyfuncitem.nodeid) @@ -180,7 +148,9 @@ def pytest_pyfunc_call(pyfuncitem: "Function") -> Optional[object]: return True -def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> Optional["Module"]: +def pytest_collect_file( + file_path: Path, parent: base_nodes.Collector +) -> Optional["nodes.Module"]: if file_path.suffix == ".py": if not parent.session.isinitpath(file_path): if not path_matches_patterns( @@ -188,35 +158,35 @@ def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> Optional["M ): return None ihook = parent.session.gethookproxy(file_path) - module: Module = ihook.pytest_pycollect_makemodule( + module: nodes.Module = ihook.pytest_pycollect_makemodule( module_path=file_path, parent=parent ) return module return None -def path_matches_patterns(path: Path, patterns: Iterable[str]) -> bool: - """Return whether path matches any of the patterns in the list of globs given.""" - return any(fnmatch_ex(pattern, path) for pattern in patterns) - - -def pytest_pycollect_makemodule(module_path: Path, parent) -> "Module": +def pytest_pycollect_makemodule(module_path: Path, parent) -> "nodes.Module": if module_path.name == "__init__.py": - pkg: Package = Package.from_parent(parent, path=module_path) + pkg: nodes.Package = nodes.Package.from_parent(parent, path=module_path) return pkg - mod: Module = Module.from_parent(parent, path=module_path) + mod: nodes.Module = nodes.Module.from_parent(parent, path=module_path) return mod @hookimpl(trylast=True) def pytest_pycollect_makeitem( - collector: Union["Module", "Class"], name: str, obj: object -) -> Union[None, nodes.Item, nodes.Collector, List[Union[nodes.Item, nodes.Collector]]]: - assert isinstance(collector, (Class, Module)), type(collector) + collector: Union["nodes.Module", "nodes.Class"], name: str, obj: object +) -> Union[ + None, + base_nodes.Item, + base_nodes.Collector, + List[Union[base_nodes.Item, base_nodes.Collector]], +]: + assert isinstance(collector, (nodes.Class, nodes.Module)), type(collector) # Nothing was collected elsewhere, let's do it here. if safe_isclass(obj): if collector.istestclass(obj, name): - klass: Class = Class.from_parent(collector, name=name, obj=obj) + klass: nodes.Class = nodes.Class.from_parent(collector, name=name, obj=obj) return klass elif collector.istestfunction(obj, name): # mock seems to store unbound methods (issue473), normalize it. @@ -236,7 +206,7 @@ def pytest_pycollect_makeitem( ) elif getattr(obj, "__test__", True): if is_generator(obj): - res: Function = Function.from_parent(collector, name=name) + res: nodes.Function = nodes.Function.from_parent(collector, name=name) reason = "yield tests were removed in pytest 4.0 - {name} will be ignored".format( name=name ) @@ -248,661 +218,6 @@ def pytest_pycollect_makeitem( return None -class PyobjMixin(nodes.Node): - """this mix-in inherits from Node to carry over the typing information - - as its intended to always mix in before a node - its position in the mro is unaffected""" - - _ALLOW_MARKERS = True - - @property - def module(self): - """Python module object this node was collected from (can be None).""" - node = self.getparent(Module) - return node.obj if node is not None else None - - @property - def cls(self): - """Python class object this node was collected from (can be None).""" - node = self.getparent(Class) - return node.obj if node is not None else None - - @property - def instance(self): - """Python instance object the function is bound to. - - Returns None if not a test method, e.g. for a standalone test function, - a staticmethod, a class or a module. - """ - node = self.getparent(Function) - return getattr(node.obj, "__self__", None) if node is not None else None - - @property - def obj(self): - """Underlying Python object.""" - obj = getattr(self, "_obj", None) - if obj is None: - self._obj = obj = self._getobj() - # XXX evil hack - # used to avoid Function marker duplication - if self._ALLOW_MARKERS: - self.own_markers.extend(get_unpacked_marks(self.obj)) - # This assumes that `obj` is called before there is a chance - # to add custom keys to `self.keywords`, so no fear of overriding. - self.keywords.update((mark.name, mark) for mark in self.own_markers) - return obj - - @obj.setter - def obj(self, value): - self._obj = value - - def _getobj(self): - """Get the underlying Python object. May be overwritten by subclasses.""" - # TODO: Improve the type of `parent` such that assert/ignore aren't needed. - assert self.parent is not None - obj = self.parent.obj # type: ignore[attr-defined] - return getattr(obj, self.name) - - def getmodpath(self, stopatmodule: bool = True, includemodule: bool = False) -> str: - """Return Python path relative to the containing module.""" - chain = self.listchain() - chain.reverse() - parts = [] - for node in chain: - name = node.name - if isinstance(node, Module): - name = os.path.splitext(name)[0] - if stopatmodule: - if includemodule: - parts.append(name) - break - parts.append(name) - parts.reverse() - return ".".join(parts) - - def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]: - # XXX caching? - obj = self.obj - compat_co_firstlineno = getattr(obj, "compat_co_firstlineno", None) - if isinstance(compat_co_firstlineno, int): - # nose compatibility - file_path = sys.modules[obj.__module__].__file__ - assert file_path is not None - if file_path.endswith(".pyc"): - file_path = file_path[:-1] - path: Union["os.PathLike[str]", str] = file_path - lineno = compat_co_firstlineno - else: - path, lineno = getfslineno(obj) - modpath = self.getmodpath() - assert isinstance(lineno, int) - return path, lineno, modpath - - -# As an optimization, these builtin attribute names are pre-ignored when -# iterating over an object during collection -- the pytest_pycollect_makeitem -# hook is not called for them. -# fmt: off -class _EmptyClass: pass # noqa: E701 -IGNORED_ATTRIBUTES = frozenset.union( # noqa: E305 - frozenset(), - # Module. - dir(types.ModuleType("empty_module")), - # Some extra module attributes the above doesn't catch. - {"__builtins__", "__file__", "__cached__"}, - # Class. - dir(_EmptyClass), - # Instance. - dir(_EmptyClass()), -) -del _EmptyClass -# fmt: on - - -class PyCollector(PyobjMixin, nodes.Collector): - def funcnamefilter(self, name: str) -> bool: - return self._matches_prefix_or_glob_option("python_functions", name) - - def isnosetest(self, obj: object) -> bool: - """Look for the __test__ attribute, which is applied by the - @nose.tools.istest decorator. - """ - # We explicitly check for "is True" here to not mistakenly treat - # classes with a custom __getattr__ returning something truthy (like a - # function) as test classes. - return safe_getattr(obj, "__test__", False) is True - - def classnamefilter(self, name: str) -> bool: - return self._matches_prefix_or_glob_option("python_classes", name) - - def istestfunction(self, obj: object, name: str) -> bool: - if self.funcnamefilter(name) or self.isnosetest(obj): - if isinstance(obj, (staticmethod, classmethod)): - # staticmethods and classmethods need to be unwrapped. - obj = safe_getattr(obj, "__func__", False) - return callable(obj) and fixtures.getfixturemarker(obj) is None - else: - return False - - def istestclass(self, obj: object, name: str) -> bool: - return self.classnamefilter(name) or self.isnosetest(obj) - - def _matches_prefix_or_glob_option(self, option_name: str, name: str) -> bool: - """Check if the given name matches the prefix or glob-pattern defined - in ini configuration.""" - for option in self.config.getini(option_name): - if name.startswith(option): - return True - # Check that name looks like a glob-string before calling fnmatch - # because this is called for every name in each collected module, - # and fnmatch is somewhat expensive to call. - elif ("*" in option or "?" in option or "[" in option) and fnmatch.fnmatch( - name, option - ): - return True - return False - - def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: - if not getattr(self.obj, "__test__", True): - return [] - - # Avoid random getattrs and peek in the __dict__ instead. - dicts = [getattr(self.obj, "__dict__", {})] - if isinstance(self.obj, type): - for basecls in self.obj.__mro__: - dicts.append(basecls.__dict__) - - # In each class, nodes should be definition ordered. - # __dict__ is definition ordered. - seen: Set[str] = set() - dict_values: List[List[Union[nodes.Item, nodes.Collector]]] = [] - ihook = self.ihook - for dic in dicts: - values: List[Union[nodes.Item, nodes.Collector]] = [] - # Note: seems like the dict can change during iteration - - # be careful not to remove the list() without consideration. - for name, obj in list(dic.items()): - if name in IGNORED_ATTRIBUTES: - continue - if name in seen: - continue - seen.add(name) - res = ihook.pytest_pycollect_makeitem( - collector=self, name=name, obj=obj - ) - if res is None: - continue - elif isinstance(res, list): - values.extend(res) - else: - values.append(res) - dict_values.append(values) - - # Between classes in the class hierarchy, reverse-MRO order -- nodes - # inherited from base classes should come before subclasses. - result = [] - for values in reversed(dict_values): - result.extend(values) - return result - - def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]: - modulecol = self.getparent(Module) - assert modulecol is not None - module = modulecol.obj - clscol = self.getparent(Class) - cls = clscol and clscol.obj or None - - definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj) - fixtureinfo = definition._fixtureinfo - - # pytest_generate_tests impls call metafunc.parametrize() which fills - # metafunc._calls, the outcome of the hook. - metafunc = Metafunc( - definition=definition, - fixtureinfo=fixtureinfo, - config=self.config, - cls=cls, - module=module, - _ispytest=True, - ) - methods = [] - if hasattr(module, "pytest_generate_tests"): - methods.append(module.pytest_generate_tests) - if cls is not None and hasattr(cls, "pytest_generate_tests"): - methods.append(cls().pytest_generate_tests) - self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc)) - - if not metafunc._calls: - yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) - else: - # Add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs. - fm = self.session._fixturemanager - fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm) - - # Add_funcarg_pseudo_fixture_def may have shadowed some fixtures - # with direct parametrization, so make sure we update what the - # function really needs. - fixtureinfo.prune_dependency_tree() - - for callspec in metafunc._calls: - subname = f"{name}[{callspec.id}]" - yield Function.from_parent( - self, - name=subname, - callspec=callspec, - fixtureinfo=fixtureinfo, - keywords={callspec.id: True}, - originalname=name, - ) - - -class Module(nodes.File, PyCollector): - """Collector for test classes and functions.""" - - def _getobj(self): - return self._importtestmodule() - - def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: - self._inject_setup_module_fixture() - self._inject_setup_function_fixture() - self.session._fixturemanager.parsefactories(self) - return super().collect() - - def _inject_setup_module_fixture(self) -> None: - """Inject a hidden autouse, module scoped fixture into the collected module object - that invokes setUpModule/tearDownModule if either or both are available. - - Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with - other fixtures (#517). - """ - has_nose = self.config.pluginmanager.has_plugin("nose") - setup_module = _get_first_non_fixture_func( - self.obj, ("setUpModule", "setup_module") - ) - if setup_module is None and has_nose: - # The name "setup" is too common - only treat as fixture if callable. - setup_module = _get_first_non_fixture_func(self.obj, ("setup",)) - if not callable(setup_module): - setup_module = None - teardown_module = _get_first_non_fixture_func( - self.obj, ("tearDownModule", "teardown_module") - ) - if teardown_module is None and has_nose: - teardown_module = _get_first_non_fixture_func(self.obj, ("teardown",)) - # Same as "setup" above - only treat as fixture if callable. - if not callable(teardown_module): - teardown_module = None - - if setup_module is None and teardown_module is None: - return - - @fixtures.fixture( - autouse=True, - scope="module", - # Use a unique name to speed up lookup. - name=f"_xunit_setup_module_fixture_{self.obj.__name__}", - ) - def xunit_setup_module_fixture(request) -> Generator[None, None, None]: - if setup_module is not None: - _call_with_optional_argument(setup_module, request.module) - yield - if teardown_module is not None: - _call_with_optional_argument(teardown_module, request.module) - - self.obj.__pytest_setup_module = xunit_setup_module_fixture - - def _inject_setup_function_fixture(self) -> None: - """Inject a hidden autouse, function scoped fixture into the collected module object - that invokes setup_function/teardown_function if either or both are available. - - Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with - other fixtures (#517). - """ - setup_function = _get_first_non_fixture_func(self.obj, ("setup_function",)) - teardown_function = _get_first_non_fixture_func( - self.obj, ("teardown_function",) - ) - if setup_function is None and teardown_function is None: - return - - @fixtures.fixture( - autouse=True, - scope="function", - # Use a unique name to speed up lookup. - name=f"_xunit_setup_function_fixture_{self.obj.__name__}", - ) - def xunit_setup_function_fixture(request) -> Generator[None, None, None]: - if request.instance is not None: - # in this case we are bound to an instance, so we need to let - # setup_method handle this - yield - return - if setup_function is not None: - _call_with_optional_argument(setup_function, request.function) - yield - if teardown_function is not None: - _call_with_optional_argument(teardown_function, request.function) - - self.obj.__pytest_setup_function = xunit_setup_function_fixture - - def _importtestmodule(self): - # We assume we are only called once per module. - importmode = self.config.getoption("--import-mode") - try: - mod = import_path(self.path, mode=importmode, root=self.config.rootpath) - except SyntaxError as e: - raise self.CollectError( - ExceptionInfo.from_current().getrepr(style="short") - ) from e - except ImportPathMismatchError as e: - raise self.CollectError( - "import file mismatch:\n" - "imported module %r has this __file__ attribute:\n" - " %s\n" - "which is not the same as the test file we want to collect:\n" - " %s\n" - "HINT: remove __pycache__ / .pyc files and/or use a " - "unique basename for your test file modules" % e.args - ) from e - except ImportError as e: - exc_info = ExceptionInfo.from_current() - if self.config.getoption("verbose") < 2: - exc_info.traceback = exc_info.traceback.filter(filter_traceback) - exc_repr = ( - exc_info.getrepr(style="short") - if exc_info.traceback - else exc_info.exconly() - ) - formatted_tb = str(exc_repr) - raise self.CollectError( - "ImportError while importing test module '{path}'.\n" - "Hint: make sure your test modules/packages have valid Python names.\n" - "Traceback:\n" - "{traceback}".format(path=self.path, traceback=formatted_tb) - ) from e - except skip.Exception as e: - if e.allow_module_level: - raise - raise self.CollectError( - "Using pytest.skip outside of a test will skip the entire module. " - "If that's your intention, pass `allow_module_level=True`. " - "If you want to skip a specific test or an entire class, " - "use the @pytest.mark.skip or @pytest.mark.skipif decorators." - ) from e - self.config.pluginmanager.consider_module(mod) - return mod - - -class Package(Module): - def __init__( - self, - fspath: Optional[LEGACY_PATH], - parent: nodes.Collector, - # NOTE: following args are unused: - config=None, - session=None, - nodeid=None, - path=Optional[Path], - ) -> None: - # NOTE: Could be just the following, but kept as-is for compat. - # nodes.FSCollector.__init__(self, fspath, parent=parent) - session = parent.session - nodes.FSCollector.__init__( - self, - fspath=fspath, - path=path, - parent=parent, - config=config, - session=session, - nodeid=nodeid, - ) - self.name = self.path.parent.name - - def setup(self) -> None: - # Not using fixtures to call setup_module here because autouse fixtures - # from packages are not called automatically (#4085). - setup_module = _get_first_non_fixture_func( - self.obj, ("setUpModule", "setup_module") - ) - if setup_module is not None: - _call_with_optional_argument(setup_module, self.obj) - - teardown_module = _get_first_non_fixture_func( - self.obj, ("tearDownModule", "teardown_module") - ) - if teardown_module is not None: - func = partial(_call_with_optional_argument, teardown_module, self.obj) - self.addfinalizer(func) - - def gethookproxy(self, fspath: "os.PathLike[str]"): - warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) - return self.session.gethookproxy(fspath) - - def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool: - warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) - return self.session.isinitpath(path) - - def _recurse(self, direntry: "os.DirEntry[str]") -> bool: - if direntry.name == "__pycache__": - return False - fspath = Path(direntry.path) - ihook = self.session.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.session.gethookproxy(fspath) - if not self.session.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] - - def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: - this_path = self.path.parent - init_module = this_path / "__init__.py" - if init_module.is_file() and path_matches_patterns( - init_module, self.config.getini("python_files") - ): - yield Module.from_parent(self, path=init_module) - pkg_prefixes: Set[Path] = set() - for direntry in visit(str(this_path), recurse=self._recurse): - path = Path(direntry.path) - - # We will visit our own __init__.py file, in which case we skip it. - if direntry.is_file(): - if direntry.name == "__init__.py" and path.parent == this_path: - continue - - parts_ = parts(direntry.path) - if any( - str(pkg_prefix) in parts_ and pkg_prefix / "__init__.py" != path - for pkg_prefix in pkg_prefixes - ): - continue - - if direntry.is_file(): - yield from self._collectfile(path) - elif not direntry.is_dir(): - # Broken symlink or invalid/missing file. - continue - elif path.joinpath("__init__.py").is_file(): - pkg_prefixes.add(path) - - -def _call_with_optional_argument(func, arg) -> None: - """Call the given function with the given argument if func accepts one argument, otherwise - calls func without arguments.""" - arg_count = func.__code__.co_argcount - if inspect.ismethod(func): - arg_count -= 1 - if arg_count: - func(arg) - else: - func() - - -def _get_first_non_fixture_func(obj: object, names: Iterable[str]) -> Optional[object]: - """Return the attribute from the given object to be used as a setup/teardown - xunit-style function, but only if not marked as a fixture to avoid calling it twice. - """ - for name in names: - meth: Optional[object] = getattr(obj, name, None) - if meth is not None and fixtures.getfixturemarker(meth) is None: - return meth - return None - - -class Class(PyCollector): - """Collector for test methods.""" - - @classmethod - def from_parent(cls, parent, *, name, obj=None, **kw): - """The public constructor.""" - return super().from_parent(name=name, parent=parent, **kw) - - def newinstance(self): - return self.obj() - - def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: - if not safe_getattr(self.obj, "__test__", True): - return [] - if hasinit(self.obj): - assert self.parent is not None - self.warn( - PytestCollectionWarning( - "cannot collect test class %r because it has a " - "__init__ constructor (from: %s)" - % (self.obj.__name__, self.parent.nodeid) - ) - ) - return [] - elif hasnew(self.obj): - assert self.parent is not None - self.warn( - PytestCollectionWarning( - "cannot collect test class %r because it has a " - "__new__ constructor (from: %s)" - % (self.obj.__name__, self.parent.nodeid) - ) - ) - return [] - - self._inject_setup_class_fixture() - self._inject_setup_method_fixture() - - self.session._fixturemanager.parsefactories(self.newinstance(), self.nodeid) - - return super().collect() - - def _inject_setup_class_fixture(self) -> None: - """Inject a hidden autouse, class scoped fixture into the collected class object - that invokes setup_class/teardown_class if either or both are available. - - Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with - other fixtures (#517). - """ - setup_class = _get_first_non_fixture_func(self.obj, ("setup_class",)) - teardown_class = _get_first_non_fixture_func(self.obj, ("teardown_class",)) - if setup_class is None and teardown_class is None: - return - - @fixtures.fixture( - autouse=True, - scope="class", - # Use a unique name to speed up lookup. - name=f"_xunit_setup_class_fixture_{self.obj.__qualname__}", - ) - def xunit_setup_class_fixture(cls) -> Generator[None, None, None]: - if setup_class is not None: - func = getimfunc(setup_class) - _call_with_optional_argument(func, self.obj) - yield - if teardown_class is not None: - func = getimfunc(teardown_class) - _call_with_optional_argument(func, self.obj) - - self.obj.__pytest_setup_class = xunit_setup_class_fixture - - def _inject_setup_method_fixture(self) -> None: - """Inject a hidden autouse, function scoped fixture into the collected class object - that invokes setup_method/teardown_method if either or both are available. - - Using a fixture to invoke these methods ensures we play nicely and unsurprisingly with - other fixtures (#517). - """ - has_nose = self.config.pluginmanager.has_plugin("nose") - setup_name = "setup_method" - setup_method = _get_first_non_fixture_func(self.obj, (setup_name,)) - emit_nose_setup_warning = False - if setup_method is None and has_nose: - setup_name = "setup" - emit_nose_setup_warning = True - setup_method = _get_first_non_fixture_func(self.obj, (setup_name,)) - teardown_name = "teardown_method" - teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,)) - emit_nose_teardown_warning = False - if teardown_method is None and has_nose: - teardown_name = "teardown" - emit_nose_teardown_warning = True - teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,)) - if setup_method is None and teardown_method is None: - return - - @fixtures.fixture( - autouse=True, - scope="function", - # Use a unique name to speed up lookup. - name=f"_xunit_setup_method_fixture_{self.obj.__qualname__}", - ) - def xunit_setup_method_fixture(self, request) -> Generator[None, None, None]: - method = request.function - if setup_method is not None: - func = getattr(self, setup_name) - _call_with_optional_argument(func, method) - if emit_nose_setup_warning: - warnings.warn( - NOSE_SUPPORT_METHOD.format( - nodeid=request.node.nodeid, method="setup" - ), - stacklevel=2, - ) - yield - if teardown_method is not None: - func = getattr(self, teardown_name) - _call_with_optional_argument(func, method) - if emit_nose_teardown_warning: - warnings.warn( - NOSE_SUPPORT_METHOD.format( - nodeid=request.node.nodeid, method="teardown" - ), - stacklevel=2, - ) - - self.obj.__pytest_setup_method = xunit_setup_method_fixture - - class InstanceDummy: """Instance used to be a node type between Class and Function. It has been removed in pytest 7.0. Some plugins exist which reference `pytest.Instance` @@ -917,171 +232,7 @@ def __getattr__(name: str) -> object: raise AttributeError(f"module {__name__} has no attribute {name}") -def hasinit(obj: object) -> bool: - init: object = getattr(obj, "__init__", None) - if init: - return init != object.__init__ - return False - - -def hasnew(obj: object) -> bool: - new: object = getattr(obj, "__new__", None) - if new: - return new != object.__new__ - return False - - -class Function(PyobjMixin, nodes.Item): - """An Item responsible for setting up and executing a Python test function. - - :param name: - The full function name, including any decorations like those - added by parametrization (``my_func[my_param]``). - :param parent: - The parent Node. - :param config: - The pytest Config object. - :param callspec: - If given, this is function has been parametrized and the callspec contains - meta information about the parametrization. - :param callobj: - If given, the object which will be called when the Function is invoked, - otherwise the callobj will be obtained from ``parent`` using ``originalname``. - :param keywords: - Keywords bound to the function object for "-k" matching. - :param session: - The pytest Session object. - :param fixtureinfo: - Fixture information already resolved at this fixture node.. - :param originalname: - The attribute name to use for accessing the underlying function object. - Defaults to ``name``. Set this if name is different from the original name, - for example when it contains decorations like those added by parametrization - (``my_func[my_param]``). - """ - - # Disable since functions handle it themselves. - _ALLOW_MARKERS = False - - def __init__( - self, - name: str, - parent, - config: Optional[Config] = None, - callspec: Optional[CallSpec2] = None, - callobj=NOTSET, - keywords: Optional[Mapping[str, Any]] = None, - session: Optional[Session] = None, - fixtureinfo: Optional[FuncFixtureInfo] = None, - originalname: Optional[str] = None, - ) -> None: - super().__init__(name, parent, config=config, session=session) - - if callobj is not NOTSET: - self.obj = callobj - - #: Original function name, without any decorations (for example - #: parametrization adds a ``"[...]"`` suffix to function names), used to access - #: the underlying function object from ``parent`` (in case ``callobj`` is not given - #: explicitly). - #: - #: .. versionadded:: 3.0 - self.originalname = originalname or name - - # Note: when FunctionDefinition is introduced, we should change ``originalname`` - # to a readonly property that returns FunctionDefinition.name. - - self.own_markers.extend(get_unpacked_marks(self.obj)) - if callspec: - self.callspec = callspec - self.own_markers.extend(callspec.marks) - - # todo: this is a hell of a hack - # https://github.com/pytest-dev/pytest/issues/4569 - # Note: the order of the updates is important here; indicates what - # takes priority (ctor argument over function attributes over markers). - # Take own_markers only; NodeKeywords handles parent traversal on its own. - self.keywords.update((mark.name, mark) for mark in self.own_markers) - self.keywords.update(self.obj.__dict__) - if keywords: - self.keywords.update(keywords) - - if fixtureinfo is None: - fixtureinfo = self.session._fixturemanager.getfixtureinfo( - self, self.obj, self.cls, funcargs=True - ) - self._fixtureinfo: FuncFixtureInfo = fixtureinfo - self.fixturenames = fixtureinfo.names_closure - self._initrequest() - - @classmethod - def from_parent(cls, parent, **kw): # todo: determine sound type limitations - """The public constructor.""" - return super().from_parent(parent=parent, **kw) - - def _initrequest(self) -> None: - self.funcargs: Dict[str, object] = {} - self._request = fixtures.FixtureRequest(self, _ispytest=True) - - @property - def function(self): - """Underlying python 'function' object.""" - return getimfunc(self.obj) - - def _getobj(self): - assert self.parent is not None - if isinstance(self.parent, Class): - # Each Function gets a fresh class instance. - parent_obj = self.parent.newinstance() - else: - parent_obj = self.parent.obj # type: ignore[attr-defined] - return getattr(parent_obj, self.originalname) - - @property - def _pyfuncitem(self): - """(compatonly) for code expecting pytest-2.2 style request objects.""" - return self - - def runtest(self) -> None: - """Execute the underlying test function.""" - self.ihook.pytest_pyfunc_call(pyfuncitem=self) - - def setup(self) -> None: - self._request._fillfixtures() - - def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None: - if hasattr(self, "_obj") and not self.config.getoption("fulltrace", False): - code = _pytest._code.Code.from_function(get_real_func(self.obj)) - path, firstlineno = code.path, code.firstlineno - traceback = excinfo.traceback - ntraceback = traceback.cut(path=path, firstlineno=firstlineno) - if ntraceback == traceback: - ntraceback = ntraceback.cut(path=path) - if ntraceback == traceback: - ntraceback = ntraceback.filter(filter_traceback) - if not ntraceback: - ntraceback = traceback - - excinfo.traceback = ntraceback.filter() - # issue364: mark all but first and last frames to - # only show a single-line message for each frame. - if self.config.getoption("tbstyle", "auto") == "auto": - if len(excinfo.traceback) > 2: - for entry in excinfo.traceback[1:-1]: - entry.set_repr_style("short") - - # TODO: Type ignored -- breaks Liskov Substitution. - def repr_failure( # type: ignore[override] - self, - excinfo: ExceptionInfo[BaseException], - ) -> Union[str, TerminalRepr]: - style = self.config.getoption("tbstyle", "auto") - if style == "auto": - style = "long" - return self._repr_failure_py(excinfo, style=style) - - -class FunctionDefinition(Function): +class FunctionDefinition(nodes.Function): """ This class is a step gap solution until we evolve to have actual function definition nodes and manage to get rid of ``metafunc``. diff --git a/src/_pytest/python/metafunc.py b/src/_pytest/python/metafunc.py index 3c0b59822..52ae9bed0 100644 --- a/src/_pytest/python/metafunc.py +++ b/src/_pytest/python/metafunc.py @@ -15,8 +15,8 @@ from typing import Sequence from typing import TYPE_CHECKING from typing import Union +from . import nodes from _pytest import fixtures -from _pytest import python from _pytest._io.saferepr import saferepr from _pytest.compat import ascii_escaped from _pytest.compat import assert_never @@ -300,7 +300,7 @@ class Metafunc: def __init__( self, - definition: "python.FunctionDefinition", + definition: nodes.FunctionDefinition, fixtureinfo: fixtures.FuncFixtureInfo, config: Config, cls=None, diff --git a/src/_pytest/python/nodes.py b/src/_pytest/python/nodes.py new file mode 100644 index 000000000..226a95cf4 --- /dev/null +++ b/src/_pytest/python/nodes.py @@ -0,0 +1,881 @@ +import fnmatch +import inspect +import os +import sys +import types +import warnings +from functools import partial +from pathlib import Path +from typing import Any +from typing import Dict +from typing import Generator +from typing import Iterable +from typing import Iterator +from typing import List +from typing import Mapping +from typing import Optional +from typing import Sequence +from typing import Set +from typing import Tuple +from typing import Union + +import _pytest +from .metafunc import CallSpec2 +from .metafunc import Metafunc +from _pytest import fixtures +from _pytest import nodes +from _pytest._code import ExceptionInfo +from _pytest._code import filter_traceback +from _pytest._code import getfslineno +from _pytest._code.code import TerminalRepr +from _pytest.compat import get_real_func +from _pytest.compat import getimfunc +from _pytest.compat import LEGACY_PATH +from _pytest.compat import NOTSET +from _pytest.compat import safe_getattr +from _pytest.config import Config +from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH +from _pytest.deprecated import NOSE_SUPPORT_METHOD +from _pytest.fixtures import FuncFixtureInfo +from _pytest.main import Session +from _pytest.mark.structures import get_unpacked_marks +from _pytest.outcomes import skip +from _pytest.pathlib import fnmatch_ex +from _pytest.pathlib import import_path +from _pytest.pathlib import ImportPathMismatchError +from _pytest.pathlib import parts +from _pytest.pathlib import visit +from _pytest.warning_types import PytestCollectionWarning + + +def path_matches_patterns(path: Path, patterns: Iterable[str]) -> bool: + """Return whether path matches any of the patterns in the list of globs given.""" + return any(fnmatch_ex(pattern, path) for pattern in patterns) + + +class PyobjMixin(nodes.Node): + """this mix-in inherits from Node to carry over the typing information + + as its intended to always mix in before a node + its position in the mro is unaffected""" + + _ALLOW_MARKERS = True + + @property + def module(self): + """Python module object this node was collected from (can be None).""" + node = self.getparent(Module) + return node.obj if node is not None else None + + @property + def cls(self): + """Python class object this node was collected from (can be None).""" + node = self.getparent(Class) + return node.obj if node is not None else None + + @property + def instance(self): + """Python instance object the function is bound to. + + Returns None if not a test method, e.g. for a standalone test function, + a staticmethod, a class or a module. + """ + node = self.getparent(Function) + return getattr(node.obj, "__self__", None) if node is not None else None + + @property + def obj(self): + """Underlying Python object.""" + obj = getattr(self, "_obj", None) + if obj is None: + self._obj = obj = self._getobj() + # XXX evil hack + # used to avoid Function marker duplication + if self._ALLOW_MARKERS: + self.own_markers.extend(get_unpacked_marks(self.obj)) + # This assumes that `obj` is called before there is a chance + # to add custom keys to `self.keywords`, so no fear of overriding. + self.keywords.update((mark.name, mark) for mark in self.own_markers) + return obj + + @obj.setter + def obj(self, value): + self._obj = value + + def _getobj(self): + """Get the underlying Python object. May be overwritten by subclasses.""" + # TODO: Improve the type of `parent` such that assert/ignore aren't needed. + assert self.parent is not None + obj = self.parent.obj # type: ignore[attr-defined] + return getattr(obj, self.name) + + def getmodpath(self, stopatmodule: bool = True, includemodule: bool = False) -> str: + """Return Python path relative to the containing module.""" + chain = self.listchain() + chain.reverse() + parts = [] + for node in chain: + name = node.name + if isinstance(node, Module): + name = os.path.splitext(name)[0] + if stopatmodule: + if includemodule: + parts.append(name) + break + parts.append(name) + parts.reverse() + return ".".join(parts) + + def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]: + # XXX caching? + obj = self.obj + compat_co_firstlineno = getattr(obj, "compat_co_firstlineno", None) + if isinstance(compat_co_firstlineno, int): + # nose compatibility + file_path = sys.modules[obj.__module__].__file__ + assert file_path is not None + if file_path.endswith(".pyc"): + file_path = file_path[:-1] + path: Union["os.PathLike[str]", str] = file_path + lineno = compat_co_firstlineno + else: + path, lineno = getfslineno(obj) + modpath = self.getmodpath() + assert isinstance(lineno, int) + return path, lineno, modpath + + +class _EmptyClass: + pass # noqa: E701 + + +IGNORED_ATTRIBUTES = frozenset.union( # noqa: E305 + frozenset(), + # Module. + dir(types.ModuleType("empty_module")), + # Some extra module attributes the above doesn't catch. + {"__builtins__", "__file__", "__cached__"}, + # Class. + dir(_EmptyClass), + # Instance. + dir(_EmptyClass()), +) + + +class PyCollector(PyobjMixin, nodes.Collector): + def funcnamefilter(self, name: str) -> bool: + return self._matches_prefix_or_glob_option("python_functions", name) + + def isnosetest(self, obj: object) -> bool: + """Look for the __test__ attribute, which is applied by the + @nose.tools.istest decorator. + """ + # We explicitly check for "is True" here to not mistakenly treat + # classes with a custom __getattr__ returning something truthy (like a + # function) as test classes. + return safe_getattr(obj, "__test__", False) is True + + def classnamefilter(self, name: str) -> bool: + return self._matches_prefix_or_glob_option("python_classes", name) + + def istestfunction(self, obj: object, name: str) -> bool: + if self.funcnamefilter(name) or self.isnosetest(obj): + if isinstance(obj, (staticmethod, classmethod)): + # staticmethods and classmethods need to be unwrapped. + obj = safe_getattr(obj, "__func__", False) + return callable(obj) and fixtures.getfixturemarker(obj) is None + else: + return False + + def istestclass(self, obj: object, name: str) -> bool: + return self.classnamefilter(name) or self.isnosetest(obj) + + def _matches_prefix_or_glob_option(self, option_name: str, name: str) -> bool: + """Check if the given name matches the prefix or glob-pattern defined + in ini configuration.""" + for option in self.config.getini(option_name): + if name.startswith(option): + return True + # Check that name looks like a glob-string before calling fnmatch + # because this is called for every name in each collected module, + # and fnmatch is somewhat expensive to call. + elif ("*" in option or "?" in option or "[" in option) and fnmatch.fnmatch( + name, option + ): + return True + return False + + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: + if not getattr(self.obj, "__test__", True): + return [] + + # Avoid random getattrs and peek in the __dict__ instead. + dicts = [getattr(self.obj, "__dict__", {})] + if isinstance(self.obj, type): + for basecls in self.obj.__mro__: + dicts.append(basecls.__dict__) + + # In each class, nodes should be definition ordered. + # __dict__ is definition ordered. + seen: Set[str] = set() + dict_values: List[List[Union[nodes.Item, nodes.Collector]]] = [] + ihook = self.ihook + for dic in dicts: + values: List[Union[nodes.Item, nodes.Collector]] = [] + # Note: seems like the dict can change during iteration - + # be careful not to remove the list() without consideration. + for name, obj in list(dic.items()): + if name in IGNORED_ATTRIBUTES: + continue + if name in seen: + continue + seen.add(name) + res = ihook.pytest_pycollect_makeitem( + collector=self, name=name, obj=obj + ) + if res is None: + continue + elif isinstance(res, list): + values.extend(res) + else: + values.append(res) + dict_values.append(values) + + # Between classes in the class hierarchy, reverse-MRO order -- nodes + # inherited from base classes should come before subclasses. + result = [] + for values in reversed(dict_values): + result.extend(values) + return result + + def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]: + modulecol = self.getparent(Module) + assert modulecol is not None + module = modulecol.obj + clscol = self.getparent(Class) + cls = clscol and clscol.obj or None + + definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj) + fixtureinfo = definition._fixtureinfo + + # pytest_generate_tests impls call metafunc.parametrize() which fills + # metafunc._calls, the outcome of the hook. + metafunc = Metafunc( + definition=definition, + fixtureinfo=fixtureinfo, + config=self.config, + cls=cls, + module=module, + _ispytest=True, + ) + methods = [] + if hasattr(module, "pytest_generate_tests"): + methods.append(module.pytest_generate_tests) + if cls is not None and hasattr(cls, "pytest_generate_tests"): + methods.append(cls().pytest_generate_tests) + self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc)) + + if not metafunc._calls: + yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) + else: + # Add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs. + fm = self.session._fixturemanager + fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm) + + # Add_funcarg_pseudo_fixture_def may have shadowed some fixtures + # with direct parametrization, so make sure we update what the + # function really needs. + fixtureinfo.prune_dependency_tree() + + for callspec in metafunc._calls: + subname = f"{name}[{callspec.id}]" + yield Function.from_parent( + self, + name=subname, + callspec=callspec, + fixtureinfo=fixtureinfo, + keywords={callspec.id: True}, + originalname=name, + ) + + +class Module(nodes.File, PyCollector): + """Collector for test classes and functions.""" + + def _getobj(self): + return self._importtestmodule() + + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: + self._inject_setup_module_fixture() + self._inject_setup_function_fixture() + self.session._fixturemanager.parsefactories(self) + return super().collect() + + def _inject_setup_module_fixture(self) -> None: + """Inject a hidden autouse, module scoped fixture into the collected module object + that invokes setUpModule/tearDownModule if either or both are available. + + Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with + other fixtures (#517). + """ + has_nose = self.config.pluginmanager.has_plugin("nose") + setup_module = _get_first_non_fixture_func( + self.obj, ("setUpModule", "setup_module") + ) + if setup_module is None and has_nose: + # The name "setup" is too common - only treat as fixture if callable. + setup_module = _get_first_non_fixture_func(self.obj, ("setup",)) + if not callable(setup_module): + setup_module = None + teardown_module = _get_first_non_fixture_func( + self.obj, ("tearDownModule", "teardown_module") + ) + if teardown_module is None and has_nose: + teardown_module = _get_first_non_fixture_func(self.obj, ("teardown",)) + # Same as "setup" above - only treat as fixture if callable. + if not callable(teardown_module): + teardown_module = None + + if setup_module is None and teardown_module is None: + return + + @fixtures.fixture( + autouse=True, + scope="module", + # Use a unique name to speed up lookup. + name=f"_xunit_setup_module_fixture_{self.obj.__name__}", + ) + def xunit_setup_module_fixture(request) -> Generator[None, None, None]: + if setup_module is not None: + _call_with_optional_argument(setup_module, request.module) + yield + if teardown_module is not None: + _call_with_optional_argument(teardown_module, request.module) + + self.obj.__pytest_setup_module = xunit_setup_module_fixture + + def _inject_setup_function_fixture(self) -> None: + """Inject a hidden autouse, function scoped fixture into the collected module object + that invokes setup_function/teardown_function if either or both are available. + + Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with + other fixtures (#517). + """ + setup_function = _get_first_non_fixture_func(self.obj, ("setup_function",)) + teardown_function = _get_first_non_fixture_func( + self.obj, ("teardown_function",) + ) + if setup_function is None and teardown_function is None: + return + + @fixtures.fixture( + autouse=True, + scope="function", + # Use a unique name to speed up lookup. + name=f"_xunit_setup_function_fixture_{self.obj.__name__}", + ) + def xunit_setup_function_fixture(request) -> Generator[None, None, None]: + if request.instance is not None: + # in this case we are bound to an instance, so we need to let + # setup_method handle this + yield + return + if setup_function is not None: + _call_with_optional_argument(setup_function, request.function) + yield + if teardown_function is not None: + _call_with_optional_argument(teardown_function, request.function) + + self.obj.__pytest_setup_function = xunit_setup_function_fixture + + def _importtestmodule(self): + # We assume we are only called once per module. + importmode = self.config.getoption("--import-mode") + try: + mod = import_path(self.path, mode=importmode, root=self.config.rootpath) + except SyntaxError as e: + raise self.CollectError( + ExceptionInfo.from_current().getrepr(style="short") + ) from e + except ImportPathMismatchError as e: + raise self.CollectError( + "import file mismatch:\n" + "imported module %r has this __file__ attribute:\n" + " %s\n" + "which is not the same as the test file we want to collect:\n" + " %s\n" + "HINT: remove __pycache__ / .pyc files and/or use a " + "unique basename for your test file modules" % e.args + ) from e + except ImportError as e: + exc_info = ExceptionInfo.from_current() + if self.config.getoption("verbose") < 2: + exc_info.traceback = exc_info.traceback.filter(filter_traceback) + exc_repr = ( + exc_info.getrepr(style="short") + if exc_info.traceback + else exc_info.exconly() + ) + formatted_tb = str(exc_repr) + raise self.CollectError( + "ImportError while importing test module '{path}'.\n" + "Hint: make sure your test modules/packages have valid Python names.\n" + "Traceback:\n" + "{traceback}".format(path=self.path, traceback=formatted_tb) + ) from e + except skip.Exception as e: + if e.allow_module_level: + raise + raise self.CollectError( + "Using pytest.skip outside of a test will skip the entire module. " + "If that's your intention, pass `allow_module_level=True`. " + "If you want to skip a specific test or an entire class, " + "use the @pytest.mark.skip or @pytest.mark.skipif decorators." + ) from e + self.config.pluginmanager.consider_module(mod) + return mod + + +class Package(Module): + def __init__( + self, + fspath: Optional[LEGACY_PATH], + parent: nodes.Collector, + # NOTE: following args are unused: + config=None, + session=None, + nodeid=None, + path=Optional[Path], + ) -> None: + # NOTE: Could be just the following, but kept as-is for compat. + # nodes.FSCollector.__init__(self, fspath, parent=parent) + session = parent.session + nodes.FSCollector.__init__( + self, + fspath=fspath, + path=path, + parent=parent, + config=config, + session=session, + nodeid=nodeid, + ) + self.name = self.path.parent.name + + def setup(self) -> None: + # Not using fixtures to call setup_module here because autouse fixtures + # from packages are not called automatically (#4085). + setup_module = _get_first_non_fixture_func( + self.obj, ("setUpModule", "setup_module") + ) + if setup_module is not None: + _call_with_optional_argument(setup_module, self.obj) + + teardown_module = _get_first_non_fixture_func( + self.obj, ("tearDownModule", "teardown_module") + ) + if teardown_module is not None: + func = partial(_call_with_optional_argument, teardown_module, self.obj) + self.addfinalizer(func) + + def gethookproxy(self, fspath: "os.PathLike[str]"): + warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) + return self.session.gethookproxy(fspath) + + def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool: + warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2) + return self.session.isinitpath(path) + + def _recurse(self, direntry: "os.DirEntry[str]") -> bool: + if direntry.name == "__pycache__": + return False + fspath = Path(direntry.path) + ihook = self.session.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.session.gethookproxy(fspath) + if not self.session.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] + + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: + this_path = self.path.parent + init_module = this_path / "__init__.py" + if init_module.is_file() and path_matches_patterns( + init_module, self.config.getini("python_files") + ): + yield Module.from_parent(self, path=init_module) + pkg_prefixes: Set[Path] = set() + for direntry in visit(str(this_path), recurse=self._recurse): + path = Path(direntry.path) + + # We will visit our own __init__.py file, in which case we skip it. + if direntry.is_file(): + if direntry.name == "__init__.py" and path.parent == this_path: + continue + + parts_ = parts(direntry.path) + if any( + str(pkg_prefix) in parts_ and pkg_prefix / "__init__.py" != path + for pkg_prefix in pkg_prefixes + ): + continue + + if direntry.is_file(): + yield from self._collectfile(path) + elif not direntry.is_dir(): + # Broken symlink or invalid/missing file. + continue + elif path.joinpath("__init__.py").is_file(): + pkg_prefixes.add(path) + + +def _call_with_optional_argument(func, arg) -> None: + """Call the given function with the given argument if func accepts one argument, otherwise + calls func without arguments.""" + arg_count = func.__code__.co_argcount + if inspect.ismethod(func): + arg_count -= 1 + if arg_count: + func(arg) + else: + func() + + +def _get_first_non_fixture_func(obj: object, names: Iterable[str]) -> Optional[object]: + """Return the attribute from the given object to be used as a setup/teardown + xunit-style function, but only if not marked as a fixture to avoid calling it twice. + """ + for name in names: + meth: Optional[object] = getattr(obj, name, None) + if meth is not None and fixtures.getfixturemarker(meth) is None: + return meth + return None + + +class Class(PyCollector): + """Collector for test methods.""" + + @classmethod + def from_parent(cls, parent, *, name, obj=None, **kw): + """The public constructor.""" + return super().from_parent(name=name, parent=parent, **kw) + + def newinstance(self): + return self.obj() + + def collect(self) -> Iterable[Union[nodes.Item, nodes.Collector]]: + if not safe_getattr(self.obj, "__test__", True): + return [] + if hasinit(self.obj): + assert self.parent is not None + self.warn( + PytestCollectionWarning( + "cannot collect test class %r because it has a " + "__init__ constructor (from: %s)" + % (self.obj.__name__, self.parent.nodeid) + ) + ) + return [] + elif hasnew(self.obj): + assert self.parent is not None + self.warn( + PytestCollectionWarning( + "cannot collect test class %r because it has a " + "__new__ constructor (from: %s)" + % (self.obj.__name__, self.parent.nodeid) + ) + ) + return [] + + self._inject_setup_class_fixture() + self._inject_setup_method_fixture() + + self.session._fixturemanager.parsefactories(self.newinstance(), self.nodeid) + + return super().collect() + + def _inject_setup_class_fixture(self) -> None: + """Inject a hidden autouse, class scoped fixture into the collected class object + that invokes setup_class/teardown_class if either or both are available. + + Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with + other fixtures (#517). + """ + setup_class = _get_first_non_fixture_func(self.obj, ("setup_class",)) + teardown_class = _get_first_non_fixture_func(self.obj, ("teardown_class",)) + if setup_class is None and teardown_class is None: + return + + @fixtures.fixture( + autouse=True, + scope="class", + # Use a unique name to speed up lookup. + name=f"_xunit_setup_class_fixture_{self.obj.__qualname__}", + ) + def xunit_setup_class_fixture(cls) -> Generator[None, None, None]: + if setup_class is not None: + func = getimfunc(setup_class) + _call_with_optional_argument(func, self.obj) + yield + if teardown_class is not None: + func = getimfunc(teardown_class) + _call_with_optional_argument(func, self.obj) + + self.obj.__pytest_setup_class = xunit_setup_class_fixture + + def _inject_setup_method_fixture(self) -> None: + """Inject a hidden autouse, function scoped fixture into the collected class object + that invokes setup_method/teardown_method if either or both are available. + + Using a fixture to invoke these methods ensures we play nicely and unsurprisingly with + other fixtures (#517). + """ + has_nose = self.config.pluginmanager.has_plugin("nose") + setup_name = "setup_method" + setup_method = _get_first_non_fixture_func(self.obj, (setup_name,)) + emit_nose_setup_warning = False + if setup_method is None and has_nose: + setup_name = "setup" + emit_nose_setup_warning = True + setup_method = _get_first_non_fixture_func(self.obj, (setup_name,)) + teardown_name = "teardown_method" + teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,)) + emit_nose_teardown_warning = False + if teardown_method is None and has_nose: + teardown_name = "teardown" + emit_nose_teardown_warning = True + teardown_method = _get_first_non_fixture_func(self.obj, (teardown_name,)) + if setup_method is None and teardown_method is None: + return + + @fixtures.fixture( + autouse=True, + scope="function", + # Use a unique name to speed up lookup. + name=f"_xunit_setup_method_fixture_{self.obj.__qualname__}", + ) + def xunit_setup_method_fixture(self, request) -> Generator[None, None, None]: + method = request.function + if setup_method is not None: + func = getattr(self, setup_name) + _call_with_optional_argument(func, method) + if emit_nose_setup_warning: + warnings.warn( + NOSE_SUPPORT_METHOD.format( + nodeid=request.node.nodeid, method="setup" + ), + stacklevel=2, + ) + yield + if teardown_method is not None: + func = getattr(self, teardown_name) + _call_with_optional_argument(func, method) + if emit_nose_teardown_warning: + warnings.warn( + NOSE_SUPPORT_METHOD.format( + nodeid=request.node.nodeid, method="teardown" + ), + stacklevel=2, + ) + + self.obj.__pytest_setup_method = xunit_setup_method_fixture + + +def hasinit(obj: object) -> bool: + init: object = getattr(obj, "__init__", None) + if init: + return init != object.__init__ + return False + + +def hasnew(obj: object) -> bool: + new: object = getattr(obj, "__new__", None) + if new: + return new != object.__new__ + return False + + +class Function(PyobjMixin, nodes.Item): + """An Item responsible for setting up and executing a Python test function. + + :param name: + The full function name, including any decorations like those + added by parametrization (``my_func[my_param]``). + :param parent: + The parent Node. + :param config: + The pytest Config object. + :param callspec: + If given, this is function has been parametrized and the callspec contains + meta information about the parametrization. + :param callobj: + If given, the object which will be called when the Function is invoked, + otherwise the callobj will be obtained from ``parent`` using ``originalname``. + :param keywords: + Keywords bound to the function object for "-k" matching. + :param session: + The pytest Session object. + :param fixtureinfo: + Fixture information already resolved at this fixture node.. + :param originalname: + The attribute name to use for accessing the underlying function object. + Defaults to ``name``. Set this if name is different from the original name, + for example when it contains decorations like those added by parametrization + (``my_func[my_param]``). + """ + + # Disable since functions handle it themselves. + _ALLOW_MARKERS = False + + def __init__( + self, + name: str, + parent, + config: Optional[Config] = None, + callspec: Optional[CallSpec2] = None, + callobj=NOTSET, + keywords: Optional[Mapping[str, Any]] = None, + session: Optional[Session] = None, + fixtureinfo: Optional[FuncFixtureInfo] = None, + originalname: Optional[str] = None, + ) -> None: + super().__init__(name, parent, config=config, session=session) + + if callobj is not NOTSET: + self.obj = callobj + + #: Original function name, without any decorations (for example + #: parametrization adds a ``"[...]"`` suffix to function names), used to access + #: the underlying function object from ``parent`` (in case ``callobj`` is not given + #: explicitly). + #: + #: .. versionadded:: 3.0 + self.originalname = originalname or name + + # Note: when FunctionDefinition is introduced, we should change ``originalname`` + # to a readonly property that returns FunctionDefinition.name. + + self.own_markers.extend(get_unpacked_marks(self.obj)) + if callspec: + self.callspec = callspec + self.own_markers.extend(callspec.marks) + + # todo: this is a hell of a hack + # https://github.com/pytest-dev/pytest/issues/4569 + # Note: the order of the updates is important here; indicates what + # takes priority (ctor argument over function attributes over markers). + # Take own_markers only; NodeKeywords handles parent traversal on its own. + self.keywords.update((mark.name, mark) for mark in self.own_markers) + self.keywords.update(self.obj.__dict__) + if keywords: + self.keywords.update(keywords) + + if fixtureinfo is None: + fixtureinfo = self.session._fixturemanager.getfixtureinfo( + self, self.obj, self.cls, funcargs=True + ) + self._fixtureinfo: FuncFixtureInfo = fixtureinfo + self.fixturenames = fixtureinfo.names_closure + self._initrequest() + + @classmethod + def from_parent(cls, parent, **kw): # todo: determine sound type limitations + """The public constructor.""" + return super().from_parent(parent=parent, **kw) + + def _initrequest(self) -> None: + self.funcargs: Dict[str, object] = {} + self._request = fixtures.FixtureRequest(self, _ispytest=True) + + @property + def function(self): + """Underlying python 'function' object.""" + return getimfunc(self.obj) + + def _getobj(self): + assert self.parent is not None + if isinstance(self.parent, Class): + # Each Function gets a fresh class instance. + parent_obj = self.parent.newinstance() + else: + parent_obj = self.parent.obj # type: ignore[attr-defined] + return getattr(parent_obj, self.originalname) + + @property + def _pyfuncitem(self): + """(compatonly) for code expecting pytest-2.2 style request objects.""" + return self + + def runtest(self) -> None: + """Execute the underlying test function.""" + self.ihook.pytest_pyfunc_call(pyfuncitem=self) + + def setup(self) -> None: + self._request._fillfixtures() + + def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None: + if hasattr(self, "_obj") and not self.config.getoption("fulltrace", False): + code = _pytest._code.Code.from_function(get_real_func(self.obj)) + path, firstlineno = code.path, code.firstlineno + traceback = excinfo.traceback + ntraceback = traceback.cut(path=path, firstlineno=firstlineno) + if ntraceback == traceback: + ntraceback = ntraceback.cut(path=path) + if ntraceback == traceback: + ntraceback = ntraceback.filter(filter_traceback) + if not ntraceback: + ntraceback = traceback + + excinfo.traceback = ntraceback.filter() + # issue364: mark all but first and last frames to + # only show a single-line message for each frame. + if self.config.getoption("tbstyle", "auto") == "auto": + if len(excinfo.traceback) > 2: + for entry in excinfo.traceback[1:-1]: + entry.set_repr_style("short") + + # TODO: Type ignored -- breaks Liskov Substitution. + def repr_failure( # type: ignore[override] + self, + excinfo: ExceptionInfo[BaseException], + ) -> Union[str, TerminalRepr]: + style = self.config.getoption("tbstyle", "auto") + if style == "auto": + style = "long" + return self._repr_failure_py(excinfo, style=style) + + +class FunctionDefinition(Function): + """ + This class is a step gap solution until we evolve to have actual function definition nodes + and manage to get rid of ``metafunc``. + """ + + def runtest(self) -> None: + raise RuntimeError("function definitions are not supposed to be run as tests") + + setup = runtest diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index d7f5ab9b4..f13f28e23 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -39,6 +39,7 @@ from _pytest.fixtures import FixtureRequest from _pytest.monkeypatch import MonkeyPatch tmppath_result_key = StashKey[Dict[str, bool]]() +tmppath_factory_key = StashKey["TempPathFactory"]() @final @@ -220,6 +221,7 @@ def pytest_configure(config: Config) -> None: available at pytest_configure time, but ideally should be moved entirely to the tmp_path_factory session fixture. """ + config.stash[tmppath_factory_key] mp = MonkeyPatch() config.add_cleanup(mp.undo) _tmp_path_factory = TempPathFactory.from_config(config, _ispytest=True) @@ -245,7 +247,7 @@ def pytest_addoption(parser: Parser) -> None: def tmp_path_factory(request: FixtureRequest) -> TempPathFactory: """Return a :class:`pytest.TempPathFactory` instance for the test session.""" # Set dynamically by pytest_configure() above. - return request.config._tmp_path_factory # type: ignore + return request.config.stash[tmppath_factory_key] def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path: diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index c660aa75d..c054d7b46 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -25,9 +25,9 @@ from _pytest.outcomes import exit from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail -from _pytest.python import Class -from _pytest.python import Function -from _pytest.python import Module +from _pytest.python.nodes import Class +from _pytest.python.nodes import Function +from _pytest.python.nodes import Module from _pytest.runner import CallInfo from _pytest.scope import Scope diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index ebf132c4b..b73b46c1a 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -47,10 +47,6 @@ from _pytest.pytester import LineMatcher from _pytest.pytester import Pytester from _pytest.pytester import RecordedHookCall from _pytest.pytester import RunResult -from _pytest.python import Class -from _pytest.python import Function -from _pytest.python import Module -from _pytest.python import Package from _pytest.python_api import approx from _pytest.python_api import raises from _pytest.recwarn import deprecated_call @@ -85,7 +81,6 @@ __all__ = [ "Cache", "CallInfo", "CaptureFixture", - "Class", "cmdline", "Collector", "CollectReport", @@ -102,7 +97,6 @@ __all__ = [ "FixtureLookupError", "FixtureRequest", "freeze_includes", - "Function", "hookimpl", "HookRecorder", "hookspec", @@ -115,10 +109,8 @@ __all__ = [ "Mark", "MarkDecorator", "MarkGenerator", - "Module", "MonkeyPatch", "OptionGroup", - "Package", "param", "Parser", "PytestAssertRewriteWarning", diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 3ceed7f5a..9c51ad65e 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -3,6 +3,7 @@ import sys import warnings from pathlib import Path +import _pytest.python.nodes import pytest from _pytest import deprecated from _pytest.compat import legacy_path @@ -75,9 +76,9 @@ def test_fscollector_gethookproxy_isinitpath(pytester: Pytester) -> None: """, withinit=True, ) - assert isinstance(module, pytest.Module) + assert isinstance(module, _pytest.python.nodes.Module) package = module.parent - assert isinstance(package, pytest.Package) + assert isinstance(package, _pytest.python.nodes.Package) with pytest.warns(pytest.PytestDeprecationWarning, match="gethookproxy"): package.gethookproxy(pytester.path) diff --git a/testing/python/collect.py b/testing/python/collect.py index ac3edd395..575ee6f5f 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -5,14 +5,15 @@ from typing import Any from typing import Dict import _pytest._code +import _pytest.python.nodes import pytest from _pytest.config import ExitCode from _pytest.main import Session from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector from _pytest.pytester import Pytester -from _pytest.python import Class -from _pytest.python import Function +from _pytest.python.nodes import Class +from _pytest.python.nodes import Function class TestModule: @@ -266,8 +267,8 @@ class TestClass: class TestFunction: def test_getmodulecollector(self, pytester: Pytester) -> None: item = pytester.getitem("def test_func(): pass") - modcol = item.getparent(pytest.Module) - assert isinstance(modcol, pytest.Module) + modcol = item.getparent(_pytest.python.nodes.Module) + assert isinstance(modcol, _pytest.python.nodes.Module) assert hasattr(modcol.obj, "test_func") @pytest.mark.filterwarnings("default") @@ -298,7 +299,7 @@ class TestFunction: session = Session.from_config(config) session._fixturemanager = FixtureManager(session) - return pytest.Function.from_parent(parent=session, **kwargs) + return _pytest.python.nodes.Function.from_parent(parent=session, **kwargs) def test_function_equality(self, pytester: Pytester) -> None: def func1(): @@ -696,7 +697,7 @@ class TestFunction: ) originalnames = [] for x in items: - assert isinstance(x, pytest.Function) + assert isinstance(x, _pytest.python.nodes.Function) originalnames.append(x.originalname) assert originalnames == [ "test_func", @@ -729,16 +730,16 @@ class TestSorting: """ ) fn1 = pytester.collect_by_name(modcol, "test_pass") - assert isinstance(fn1, pytest.Function) + assert isinstance(fn1, _pytest.python.nodes.Function) fn2 = pytester.collect_by_name(modcol, "test_pass") - assert isinstance(fn2, pytest.Function) + assert isinstance(fn2, _pytest.python.nodes.Function) assert fn1 == fn2 assert fn1 != modcol assert hash(fn1) == hash(fn2) fn3 = pytester.collect_by_name(modcol, "test_fail") - assert isinstance(fn3, pytest.Function) + assert isinstance(fn3, _pytest.python.nodes.Function) assert not (fn1 == fn3) assert fn1 != fn3 diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index d996f80bb..c11a0352a 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -3,6 +3,7 @@ import sys import textwrap from pathlib import Path +import _pytest.python.nodes import pytest from _pytest import fixtures from _pytest.compat import getfuncargnames @@ -11,7 +12,7 @@ from _pytest.fixtures import FixtureRequest from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import get_public_names from _pytest.pytester import Pytester -from _pytest.python import Function +from _pytest.python.nodes import Function def test_getfuncargnames_functions(): @@ -854,7 +855,7 @@ class TestRequestBasic: item.session._setupstate.setup(item) item._request._fillfixtures() # successively check finalization calls - parent = item.getparent(pytest.Module) + parent = item.getparent(_pytest.python.nodes.Module) assert parent is not None teardownlist = parent.obj.teardownlist ss = item.session._setupstate diff --git a/testing/python/integration.py b/testing/python/integration.py index 054c14a39..c5ca478b3 100644 --- a/testing/python/integration.py +++ b/testing/python/integration.py @@ -2,7 +2,7 @@ import pytest from _pytest._code import getfslineno from _pytest.fixtures import getfixturemarker from _pytest.pytester import Pytester -from _pytest.python import Function +from _pytest.python.nodes import Function def test_wrapped_getfslineno() -> None: diff --git a/testing/test_collection.py b/testing/test_collection.py index bbcb358b6..dfa961f22 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -6,6 +6,7 @@ import textwrap from pathlib import Path from typing import List +import _pytest.python.nodes import pytest from _pytest.config import ExitCode from _pytest.fixtures import FixtureRequest @@ -41,21 +42,21 @@ class TestCollector: """ ) fn1 = pytester.collect_by_name(modcol, "test_pass") - assert isinstance(fn1, pytest.Function) + assert isinstance(fn1, _pytest.python.nodes.Function) fn2 = pytester.collect_by_name(modcol, "test_pass") - assert isinstance(fn2, pytest.Function) + assert isinstance(fn2, _pytest.python.nodes.Function) assert fn1 == fn2 assert fn1 != modcol assert hash(fn1) == hash(fn2) fn3 = pytester.collect_by_name(modcol, "test_fail") - assert isinstance(fn3, pytest.Function) + assert isinstance(fn3, _pytest.python.nodes.Function) assert not (fn1 == fn3) assert fn1 != fn3 for fn in fn1, fn2, fn3: - assert isinstance(fn, pytest.Function) + assert isinstance(fn, _pytest.python.nodes.Function) assert fn != 3 # type: ignore[comparison-overlap] assert fn != modcol assert fn != [1, 2, 3] # type: ignore[comparison-overlap] @@ -73,21 +74,21 @@ class TestCollector: """ ) cls = pytester.collect_by_name(modcol, "TestClass") - assert isinstance(cls, pytest.Class) + assert isinstance(cls, _pytest.python.nodes.Class) fn = pytester.collect_by_name(cls, "test_foo") - assert isinstance(fn, pytest.Function) + assert isinstance(fn, _pytest.python.nodes.Function) - assert fn.getparent(pytest.Module) is modcol + assert fn.getparent(_pytest.python.nodes.Module) is modcol assert modcol.module is not None assert modcol.cls is None assert modcol.instance is None - assert fn.getparent(pytest.Class) is cls + assert fn.getparent(_pytest.python.nodes.Class) is cls assert cls.module is not None assert cls.cls is not None assert cls.instance is None - assert fn.getparent(pytest.Function) is fn + assert fn.getparent(_pytest.python.nodes.Function) is fn assert fn.module is not None assert fn.cls is not None assert fn.instance is not None @@ -648,7 +649,7 @@ class Test_getinitialnodes: x = ensure_file(tmp_path / "x.py") config = pytester.parseconfigure(x) col = pytester.getnode(config, x) - assert isinstance(col, pytest.Module) + assert isinstance(col, _pytest.python.nodes.Module) assert col.name == "x.py" assert col.parent is not None assert col.parent.parent is None @@ -670,8 +671,8 @@ class Test_getinitialnodes: col = pytester.getnode(config, x) assert col is not None assert col.name == "x.py" - assert isinstance(col, pytest.Module) - assert isinstance(col.parent, pytest.Package) + assert isinstance(col, _pytest.python.nodes.Module) + assert isinstance(col.parent, _pytest.python.nodes.Package) assert isinstance(col.parent.parent, pytest.Session) # session is batman (has no parents) assert col.parent.parent.parent is None @@ -912,10 +913,10 @@ class TestNodeKeywords: """, "test_method", ) - assert isinstance(item, pytest.Function) - cls = item.getparent(pytest.Class) + assert isinstance(item, _pytest.python.nodes.Function) + cls = item.getparent(_pytest.python.nodes.Class) assert cls is not None - mod = item.getparent(pytest.Module) + mod = item.getparent(_pytest.python.nodes.Module) assert mod is not None assert item.keywords["foo"] == pytest.mark.foo.mark @@ -1478,7 +1479,7 @@ def test_fscollector_from_parent(pytester: Pytester, request: FixtureRequest) -> def test_class_from_parent(pytester: Pytester, request: FixtureRequest) -> None: """Ensure Class.from_parent can forward custom arguments to the constructor.""" - class MyCollector(pytest.Class): + class MyCollector(_pytest.python.nodes.Class): def __init__(self, name, parent, x): super().__init__(name, parent) self.x = x