python: refactor CallSpec2

This type is semi-private; not documented but many plugins access it
through `item.callspec`. However, plugins access the public fields and
almost none try to construct or monkeypatch it. So we should be allowed
to clean it up some.

- Convert to attrs, add slots and frozen

- Instead of doing `new = old.copy(); new.setmulti2()`, do `new =
  old.setmulti()`. This is cleaner and faster.

- Remove the `metafunc` attribute. This causes a reference cycle
  (multifunc._calls -> callspec -> multifunc) for no good reason --
  neither pytest itself or plugins access this attribute, so let's not
  keep the Metafunc objects alive past their due.

- Some comments.

I would have also like to make the dicts and lists themselves immutable,
however some plugins mess with those so that should be done separately,
if at all.
This commit is contained in:
Ran Benita 2021-10-02 13:36:11 +03:00
parent f65dfc39f3
commit 570b1facb7
1 changed files with 88 additions and 60 deletions

View File

@ -27,6 +27,8 @@ from typing import Tuple
from typing import TYPE_CHECKING
from typing import Union
import attr
import _pytest
from _pytest import fixtures
from _pytest import nodes
@ -37,6 +39,7 @@ 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
from _pytest.compat import final
from _pytest.compat import get_default_arg_names
from _pytest.compat import get_real_func
@ -451,11 +454,12 @@ class PyCollector(PyobjMixin, nodes.Collector):
module = modulecol.obj
clscol = self.getparent(Class)
cls = clscol and clscol.obj or None
fm = self.session._fixturemanager
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,
@ -469,13 +473,13 @@ class PyCollector(PyobjMixin, nodes.Collector):
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
@ -894,26 +898,65 @@ def hasnew(obj: object) -> bool:
@final
@attr.s(frozen=True, slots=True, auto_attribs=True)
class CallSpec2:
def __init__(self, metafunc: "Metafunc") -> None:
self.metafunc = metafunc
self.funcargs: Dict[str, object] = {}
self._idlist: List[str] = []
self.params: Dict[str, object] = {}
# Used for sorting parametrized resources.
self._arg2scope: Dict[str, Scope] = {}
self.marks: List[Mark] = []
self.indices: Dict[str, int] = {}
"""A planned parameterized invocation of a test function.
def copy(self) -> "CallSpec2":
cs = CallSpec2(self.metafunc)
cs.funcargs.update(self.funcargs)
cs.params.update(self.params)
cs.marks.extend(self.marks)
cs.indices.update(self.indices)
cs._arg2scope.update(self._arg2scope)
cs._idlist = list(self._idlist)
return cs
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] = attr.Factory(dict)
# arg name -> arg value which will be passed to a fixture of the same name
# (indirect parametrization).
params: Dict[str, object] = attr.Factory(dict)
# arg name -> arg index.
indices: Dict[str, int] = attr.Factory(dict)
# Used for sorting parametrized resources.
_arg2scope: Dict[str, Scope] = attr.Factory(dict)
# Parts which will be added to the item's name in `[..]` separated by "-".
_idlist: List[str] = attr.Factory(list)
# Marks which will be applied to the item.
marks: List[Mark] = attr.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,
arg2scope=arg2scope,
indices=indices,
idlist=[*self._idlist, id],
marks=[*self.marks, *normalize_mark_list(marks)],
)
def getparam(self, name: str) -> object:
try:
@ -923,32 +966,7 @@ class CallSpec2:
@property
def id(self) -> str:
return "-".join(map(str, self._idlist))
def setmulti2(
self,
valtypes: Mapping[str, "Literal['params', 'funcargs']"],
argnames: Sequence[str],
valset: Iterable[object],
id: str,
marks: Iterable[Union[Mark, MarkDecorator]],
scope: Scope,
param_index: int,
) -> None:
for arg, val in zip(argnames, valset):
if arg in self.params or arg in self.funcargs:
raise ValueError(f"duplicate {arg!r}")
valtype_for_arg = valtypes[arg]
if valtype_for_arg == "params":
self.params[arg] = val
elif valtype_for_arg == "funcargs":
self.funcargs[arg] = val
else: # pragma: no cover
assert False, f"Unhandled valtype for arg: {valtype_for_arg}"
self.indices[arg] = param_index
self._arg2scope[arg] = scope
self._idlist.append(id)
self.marks.extend(normalize_mark_list(marks))
return "-".join(self._idlist)
@final
@ -990,9 +1008,11 @@ class Metafunc:
#: Class object where the test function is defined in or ``None``.
self.cls = cls
self._calls: List[CallSpec2] = []
self._arg2fixturedefs = fixtureinfo.name2fixturedefs
# Result of parametrize().
self._calls: List[CallSpec2] = []
def parametrize(
self,
argnames: Union[str, List[str], Tuple[str, ...]],
@ -1009,9 +1029,18 @@ class Metafunc:
_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 at test setup time.
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
@ -1104,17 +1133,16 @@ class Metafunc:
# more than once) then we accumulate those calls generating the cartesian product
# of all calls.
newcalls = []
for callspec in self._calls or [CallSpec2(self)]:
for callspec in self._calls or [CallSpec2()]:
for param_index, (param_id, param_set) in enumerate(zip(ids, parameters)):
newcallspec = callspec.copy()
newcallspec.setmulti2(
arg_values_types,
argnames,
param_set.values,
param_id,
param_set.marks,
scope_,
param_index,
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