Skip to content

Commit b4e71e9

Browse files
Add a definition scope and a generic ItemDefinition node (#3926)
Build on the opt-in FunctionDefinition collection node in two directions: a fixture scope which targets that node, and a collector-agnostic base so non-Python test definitions can be parametrized through the same hook. Scope.Definition sits between Function and Class and is anchored on the definition node, so one fixture instance is shared by all invocations generated from a single definition: @pytest.fixture(scope="definition") def resource(): ... @pytest.mark.parametrize("n", [1, 2, 3]) def test_it(resource, n): ... It requires the definition to actually be in the tree, i.e. collect_function_definition=pedantic. Without it, both a definition-scoped fixture and parametrize(scope="definition") fail with an explicit message rather than silently degrading to function scope. --setup-show spells the scope "D" and keeps the existing indentation for every other scope. nodes.ItemDefinition is the generic counterpart of FunctionDefinition: a collector standing for one test definition, whose collect() runs the generate-tests protocol and turns each callspec into an item. A non-Python collector declares parametrize_argnames and implements make_item(); the chosen values arrive on item.callspec.params, with ids, pytest.param marks and nodeid selection working as they do for Python tests. There is no fixture resolution on that path -- doc/en/proposals/generic_fixture_closures writes up what that would take. The parametrization core moves out of python.py into the new _pytest.parametrize module: CallSpec, IdMaker and the new ParametrizeContext, of which Metafunc is now the Python-specific subclass adding the fixture closure, signature validation and direct-param desugaring. _pytest.python.CallSpec2 keeps warning and resolving as before. Two bugs fixed along the way, both reachable only once a definition node is in the tree: node-id selection of a parametrized test looked at the definition's bare name and failed to match, and skipping an item whose reportinfo() has no line number crashed with an INTERNALERROR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 708a8e8 commit b4e71e9

21 files changed

Lines changed: 2059 additions & 770 deletions

‎changelog/14769.bugfix.rst‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Selecting a parametrized test by its node id (``pytest test_x.py::test_a[1]``)
2+
now works when the test definition is a node of the collection tree
3+
(:confval:`collect_function_definition` set to ``pedantic`` or ``messy``). The
4+
argument matcher compares names level by level, and the definition node carries
5+
the bare name while the parametrization lives on the items below it; it now
6+
looks through the definition instead of failing to match.
7+
8+
Relatedly, skipping a non-Python item whose ``reportinfo()`` reports no line
9+
number no longer crashes pytest with an ``INTERNALERROR``; the skip location is
10+
reported without a line instead. This became easy to hit now that non-Python
11+
definitions can carry ``pytest.param(..., marks=pytest.mark.skip(...))``.

‎changelog/3926.feature.rst‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
Added the ``"definition"`` fixture scope, which shares one fixture instance
2+
across all invocations generated from a single test definition -- that is,
3+
across the parameter sets of one parametrized test, but not between two
4+
different tests:
5+
6+
.. code-block:: python
7+
8+
@pytest.fixture(scope="definition")
9+
def resource(): ...
10+
11+
12+
@pytest.mark.parametrize("n", [1, 2, 3])
13+
def test_it(resource, n):
14+
# one `resource` for all three parameter sets
15+
...
16+
17+
The scope needs the test definition to be a node of the collection tree, so it
18+
requires :confval:`collect_function_definition` to be set to ``pedantic`` (or
19+
``messy``). Using it without that fails with an explicit error rather than
20+
silently degrading to function scope.
21+
22+
``@pytest.mark.parametrize(..., scope="definition")`` is supported as well and
23+
groups the invocations of one definition by parameter value.
24+
25+
Added :class:`_pytest.nodes.ItemDefinition`, the collector-agnostic base for "a
26+
definition which generates items". Subclassing it lets a non-Python collector
27+
have its test definitions parametrized through :hook:`pytest_generate_tests`,
28+
exactly like Python test functions: declare the accepted names in
29+
``parametrize_argnames``, build one item per parameter set in ``make_item()``,
30+
and the chosen values arrive on ``item.callspec.params``. Parameter ids,
31+
``pytest.param(...)`` marks and node-id selection all work as usual; there is no
32+
fixture resolution on that path. See :ref:`non-python parametrization`.

‎changelog/3926.misc.rst‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Moved the parametrization core out of ``_pytest.python`` into the new
2+
``_pytest.parametrize`` module: ``CallSpec``, ``IdMaker`` and the new
3+
``ParametrizeContext``, of which :class:`pytest.Metafunc` is now the
4+
Python-specific subclass adding the fixture closure, signature validation and
5+
direct-parameter desugaring.
6+
7+
``_pytest.python.CallSpec2`` keeps warning and resolving as before.

‎doc/en/example/nonpython.rst‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,65 @@ interesting to look at the collection tree:
105105
<YamlItem ok>
106106
107107
======================== 2 tests collected in 0.12s ========================
108+
109+
.. _`non-python parametrization`:
110+
111+
Letting non-python tests take part in parametrization
112+
-----------------------------------------------------
113+
114+
.. versionadded:: 9.2
115+
116+
A collector which yields items directly, as ``YamlFile`` above does, produces a
117+
fixed set of tests. To let a test *definition* be parametrized -- by
118+
:hook:`pytest_generate_tests`, exactly like a Python test function -- collect
119+
:class:`~_pytest.nodes.ItemDefinition` nodes instead of items.
120+
121+
Such a node declares the names it accepts in
122+
:attr:`~_pytest.nodes.ItemDefinition.parametrize_argnames` and builds one item
123+
per parameter set in :meth:`~_pytest.nodes.ItemDefinition.make_item`. The values
124+
chosen for a given item arrive on ``item.callspec.params``:
125+
126+
.. code-block:: python
127+
128+
# content of conftest.py
129+
from _pytest import nodes
130+
import pytest
131+
132+
133+
class YamlItem(pytest.Item):
134+
def __init__(self, *, spec, **kwargs):
135+
super().__init__(**kwargs)
136+
self.spec = spec
137+
138+
def runtest(self): ...
139+
140+
def reportinfo(self):
141+
return self.path, 0, self.name
142+
143+
144+
class YamlDefinition(nodes.ItemDefinition):
145+
parametrize_argnames = ("value",)
146+
147+
def make_item(self, parent, *, name, callspec, nodeid, context):
148+
params = dict(callspec.params) if callspec is not None else {}
149+
return YamlItem.from_parent(parent, name=name, nodeid=nodeid, spec=params)
150+
151+
Any ``pytest_generate_tests`` implementation now applies:
152+
153+
.. code-block:: python
154+
155+
def pytest_generate_tests(metafunc):
156+
if "value" in metafunc.fixturenames:
157+
metafunc.parametrize("value", ["good", "bad"])
158+
159+
which collects ``test_simple.yaml::hello[good]`` and
160+
``test_simple.yaml::hello[bad]`` under a single ``<YamlDefinition hello>`` node.
161+
Parameter ids, ``pytest.param(...)`` marks and node-id selection all work as
162+
they do for Python tests.
163+
164+
.. note::
165+
166+
This path covers parametrization only: there is no fixture resolution for
167+
non-Python items, so ``indirect=True`` and requesting fixtures from such an
168+
item are not supported. A ``parametrize()`` call naming anything outside
169+
``parametrize_argnames`` is rejected at collection time.

‎doc/en/how-to/fixtures.rst‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,16 @@ to cause a ``smtp_connection`` fixture function, responsible to create a connect
389389
once per test *module* (the default is to invoke once per test *function*).
390390
Multiple test functions in a test module will thus
391391
each receive the same ``smtp_connection`` fixture instance, thus saving time.
392-
Possible values for ``scope`` are: ``function``, ``class``, ``module``, ``package`` or ``session``.
392+
Possible values for ``scope`` are: ``function``, ``definition``, ``class``,
393+
``module``, ``package`` or ``session``.
394+
395+
.. note::
396+
397+
``definition`` shares one fixture instance across all the invocations
398+
generated from a single test definition -- that is, across the parameter
399+
sets of one parametrized test, but not between two different tests. It
400+
requires the definition to be a node of the collection tree, see
401+
:confval:`collect_function_definition`.
393402

394403
The next example puts the fixture function into a separate ``conftest.py`` file
395404
so that tests from multiple test modules in the directory can
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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?

‎doc/en/reference/reference.rst‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,13 @@ Function
937937
:members:
938938
:show-inheritance:
939939

940+
ItemDefinition
941+
~~~~~~~~~~~~~~
942+
943+
.. autoclass:: _pytest.nodes.ItemDefinition()
944+
:members:
945+
:show-inheritance:
946+
940947
FunctionDefinition
941948
~~~~~~~~~~~~~~~~~~
942949

@@ -963,6 +970,12 @@ CallInfo
963970
.. autoclass:: pytest.CallInfo()
964971
:members:
965972

973+
CallSpec
974+
~~~~~~~~
975+
976+
.. autoclass:: _pytest.parametrize.CallSpec()
977+
:members:
978+
966979
CollectReport
967980
~~~~~~~~~~~~~
968981

@@ -1036,6 +1049,13 @@ Metafunc
10361049

10371050
.. autoclass:: pytest.Metafunc()
10381051
:members:
1052+
:inherited-members:
1053+
1054+
ParametrizeContext
1055+
~~~~~~~~~~~~~~~~~~
1056+
1057+
.. autoclass:: _pytest.parametrize.ParametrizeContext()
1058+
:members:
10391059

10401060
Parser
10411061
~~~~~~

0 commit comments

Comments
 (0)