From 570b1facb7845cb493fe73a94ac47c9704f609f5 Mon Sep 17 00:00:00 2001
From: Ran Benita <ran@unusedvar.com>
Date: Sat, 2 Oct 2021 13:36:11 +0300
Subject: [PATCH] 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.
---
 src/_pytest/python.py | 148 +++++++++++++++++++++++++-----------------
 1 file changed, 88 insertions(+), 60 deletions(-)

diff --git a/src/_pytest/python.py b/src/_pytest/python.py
index d67a98b42..0f45d066f 100644
--- a/src/_pytest/python.py
+++ b/src/_pytest/python.py
@@ -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