|
| 1 | +:orphan: |
| 2 | + |
| 3 | +============================================ |
| 4 | +PROPOSAL: Generic fixture closures for items |
| 5 | +============================================ |
| 6 | + |
| 7 | +.. warning:: |
| 8 | + |
| 9 | + This document is the follow-up half of the ``ItemDefinition`` work: it |
| 10 | + outlines what is needed for *non-Python* items to request fixtures. The |
| 11 | + parametrization half has shipped, see :ref:`non-python parametrization`. |
| 12 | + |
| 13 | +What shipped |
| 14 | +------------ |
| 15 | + |
| 16 | +:class:`~_pytest.nodes.ItemDefinition` lets any collector take part in |
| 17 | +:hook:`pytest_generate_tests`. A definition declares the names it accepts up |
| 18 | +front: |
| 19 | + |
| 20 | +.. code-block:: python |
| 21 | +
|
| 22 | + class YamlDefinition(nodes.ItemDefinition): |
| 23 | + parametrize_argnames = ("value",) |
| 24 | +
|
| 25 | + def make_item(self, parent, *, name, callspec, nodeid, context): |
| 26 | + return YamlItem.from_parent( |
| 27 | + parent, name=name, nodeid=nodeid, spec=dict(callspec.params) |
| 28 | + ) |
| 29 | +
|
| 30 | +and the values chosen for each parameter set arrive on ``item.callspec.params``. |
| 31 | + |
| 32 | +``ParametrizeContext`` deliberately has no fixture machinery: ``_infer_scope()`` |
| 33 | +returns ``Scope.Function``, ``_register_direct_params()`` is a no-op, and |
| 34 | +``_validate_argnames()`` checks against ``parametrize_argnames`` rather than a |
| 35 | +fixture closure. ``FixtureManager.pytest_generate_tests`` returns early for a |
| 36 | +context which is not a ``Metafunc``. |
| 37 | + |
| 38 | +That covers parametrization. It does **not** cover: |
| 39 | + |
| 40 | +- ``indirect=True`` -- there is no fixture to hand ``request.param`` to. |
| 41 | +- scope inference from fixture scopes -- an unspecified ``scope=`` is always |
| 42 | + function scope. |
| 43 | +- a non-Python item requesting a fixture at all, including one at the new |
| 44 | + ``"definition"`` scope. |
| 45 | + |
| 46 | +Why the rest is not a small step |
| 47 | +-------------------------------- |
| 48 | + |
| 49 | +Fixture resolution is written against ``Function``, not against ``Item``. The |
| 50 | +concrete couplings: |
| 51 | + |
| 52 | +**TopRequest assumes a Python function item.** |
| 53 | +It is constructed as ``TopRequest(pyfuncitem=item)`` and reads |
| 54 | +``item._fixtureinfo``, ``item.funcargs``, ``item.fixturenames`` and |
| 55 | +``item.obj``. ``_fillfixtures()`` writes into ``item.funcargs``. |
| 56 | +``request.instance`` walks to a :class:`~pytest.Class` node and calls |
| 57 | +``newinstance()``. |
| 58 | + |
| 59 | +**getfixtureinfo() is item-scoped and signature-driven.** |
| 60 | +Its closure starts from the function's argument names |
| 61 | +(``getfuncargnames(func)``) plus autouse names. A non-Python definition has no |
| 62 | +signature; its "argnames" are whatever it declares. ``FunctionDefinition`` |
| 63 | +already has to ``cast(nodes.Item, self)`` to call it -- the same wart, one level |
| 64 | +up. |
| 65 | + |
| 66 | +**Direct parametrization desugars into DirectParamFixtureDef.** |
| 67 | +That is how a direct param reaches the test at setup time. Without a fixture |
| 68 | +system there is nothing to desugar into, which is why the generic path delivers |
| 69 | +params on the callspec instead. |
| 70 | + |
| 71 | +**Function.setup() drives everything.** |
| 72 | +``nodes.Item.setup()`` is a no-op; the fixture lifecycle only runs because |
| 73 | +``Function.setup()`` calls ``self._request._fillfixtures()``. |
| 74 | + |
| 75 | +Proposed shape |
| 76 | +-------------- |
| 77 | + |
| 78 | +Three steps, each independently useful. |
| 79 | + |
| 80 | +1. Split a node-level closure entry point out of ``getfixtureinfo()`` |
| 81 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 82 | + |
| 83 | +Today ``getfixtureinfo(node, func, cls, ignore_args)`` derives ``initialnames`` |
| 84 | +from ``func``'s signature. Proposed: |
| 85 | + |
| 86 | +.. code-block:: python |
| 87 | +
|
| 88 | + def getfixtureinfo_for_argnames( |
| 89 | + self, node: nodes.Node, argnames: Sequence[str], *, ignore_args=() |
| 90 | + ) -> FuncFixtureInfo: ... |
| 91 | +
|
| 92 | +with the current signature-based version becoming a thin wrapper which computes |
| 93 | +``argnames`` from the function. This removes the ``cast(nodes.Item, self)`` in |
| 94 | +``FunctionDefinition`` as a side effect, and gives ``ItemDefinition`` a closure |
| 95 | +without inventing a fake function. |
| 96 | + |
| 97 | +2. Make the request object item-generic |
| 98 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 99 | + |
| 100 | +Introduce a small protocol for what ``TopRequest`` needs from its item -- |
| 101 | +``fixturenames``, somewhere to store the computed values, ``_fixtureinfo``, and |
| 102 | +an optional ``instance`` -- and give :class:`~pytest.Item` a default |
| 103 | +implementation. :class:`~pytest.Function` keeps its current behaviour |
| 104 | +(``funcargs``, bound instance); a generic item gets a plain dict and |
| 105 | +``instance is None``. |
| 106 | + |
| 107 | +The open question here is ``request.instance``, whose meaning is genuinely |
| 108 | +Python-specific. Returning ``None`` for non-Python items is the honest answer, |
| 109 | +but fixtures in the wild do use ``request.instance`` unconditionally. |
| 110 | + |
| 111 | +3. Opt in per definition |
| 112 | +~~~~~~~~~~~~~~~~~~~~~~~~ |
| 113 | + |
| 114 | +Fixture support should not be forced on every ``ItemDefinition``; a collector |
| 115 | +which only wants parametrization should not pay for closure computation. |
| 116 | +Suggested switch: |
| 117 | + |
| 118 | +.. code-block:: python |
| 119 | +
|
| 120 | + class ItemDefinition(Collector, abc.ABC): |
| 121 | + #: Whether items generated from this definition can request fixtures. |
| 122 | + supports_fixtures: bool = False |
| 123 | +
|
| 124 | +With ``supports_fixtures = True``, ``make_parametrize_context()`` computes a |
| 125 | +closure from ``parametrize_argnames`` via step 1 and returns a fixture-aware |
| 126 | +context, so ``indirect=True``, scope inference and ``"definition"`` scoped |
| 127 | +fixtures all work uniformly. ``FixtureManager.pytest_generate_tests`` would then |
| 128 | +key off the context being fixture-aware rather than off |
| 129 | +``isinstance(metafunc, Metafunc)``. |
| 130 | + |
| 131 | +Open questions |
| 132 | +-------------- |
| 133 | + |
| 134 | +- Does ``Metafunc`` stay a distinct class once the generic context is |
| 135 | + fixture-aware, or does it collapse into ``ParametrizeContext`` plus the |
| 136 | + ``module``/``cls``/``function`` attributes? |
| 137 | +- ``request.instance`` for non-Python items: ``None``, or an error on access? |
| 138 | +- Autouse fixtures currently apply to every item in a subtree. Should they apply |
| 139 | + to non-Python items which opted into fixtures, or only to those which declare |
| 140 | + the corresponding names? |
| 141 | +- Should ``parametrize_argnames`` and the fixture closure be the same list, or |
| 142 | + should a definition be able to accept a name for parametrization which is not |
| 143 | + requestable as a fixture? |
0 commit comments