Skip to content

Commit 7cd892a

Browse files
Add opt-in FunctionDefinition collection tree node (#3926)
Introduce the ``collect_function_definition`` ini option to promote the internal ``FunctionDefinition`` from a transient parametrization helper to a real collector node in the collection tree. The option is tri-state: - ``hidden`` (default): unchanged flat layout; the definition drives parametrization and is kept out of the tree. - ``pedantic``: insert the definition node between the Module/Class and its test functions; function-level markers are scoped to the definition and each invocation owns only its callspec markers. - ``messy``: migration stopgap -- insert the node but transfer the function-level markers back onto each invocation to preserve the legacy marker layout for code not yet prepared for the new scope; emits a header warning. FunctionDefinition becomes a PyCollector (no longer a Function/Item), so it is collected rather than run. Invocation node ids stay flat and identical across all modes, so selection, caching and reporting are unaffected. obj and instance resolution and getmodpath skip an interposed definition node. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ecade5a commit 7cd892a

7 files changed

Lines changed: 557 additions & 65 deletions

File tree

‎changelog/14769.bugfix.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
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.

‎changelog/3926.improvement.rst‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
Added the :confval:`collect_function_definition` option, which can promote the
2+
internal ``FunctionDefinition`` to a real node in the collection tree.
3+
4+
When set to ``pedantic``, a ``FunctionDefinition`` collector node is inserted
5+
between a :class:`~pytest.Module`/:class:`~pytest.Class` and its test functions,
6+
so that the (possibly parametrized) invocations of each test are collected
7+
underneath a shared definition node instead of as flat siblings. Function-level
8+
markers are scoped to the definition node in this mode.
9+
10+
The default ``hidden`` keeps the previous flat layout, and the transitional
11+
``messy`` value keeps the definition node out of marker resolution for code that
12+
is not yet prepared for the new scope.
13+
14+
The node ids of the individual test invocations are unchanged in all modes, so
15+
test selection, caching and reporting behave exactly as before.

‎doc/en/reference/reference.rst‎

Lines changed: 88 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

@@ -1378,6 +1385,79 @@ passed multiple times. The expected format is ``name=value``. For example::
13781385
variables, that will be expanded. For more information about cache plugin
13791386
please refer to :ref:`cache_provider`.
13801387

1388+
.. confval:: collect_function_definition
1389+
:type: ``string``
1390+
:default: ``hidden``
1391+
1392+
.. versionadded:: 9.2
1393+
1394+
Controls whether a ``FunctionDefinition`` collector node is inserted into the
1395+
collection tree between a :class:`~pytest.Module`/:class:`~pytest.Class` and
1396+
its test functions, grouping the (possibly parametrized) invocations of a test
1397+
function *underneath* the definition node instead of as flat siblings.
1398+
1399+
It accepts three values:
1400+
1401+
``hidden`` (default)
1402+
Keep the flat layout. The definition node is used only internally to drive
1403+
parametrization and is kept out of the collection tree.
1404+
1405+
``pedantic``
1406+
Insert the definition node with correct marker scoping: function-level
1407+
markers (such as ``@pytest.mark.parametrize`` or custom marks) belong to
1408+
the definition node, and each invocation only owns its own parameter-set
1409+
markers. This is the intended target layout.
1410+
1411+
``messy``
1412+
A stopgap for migration only. It inserts the definition node but transfers
1413+
the function-level markers back down onto each invocation, reproducing the
1414+
old (duplicated) marker layout so that code which is not yet prepared for
1415+
the definition scope keeps working. It emits a warning in the session
1416+
header and exists purely to buy time; fix the offending code and move to
1417+
``pedantic``.
1418+
1419+
.. tab:: toml
1420+
1421+
.. code-block:: toml
1422+
1423+
[pytest]
1424+
collect_function_definition = "pedantic"
1425+
1426+
.. tab:: ini
1427+
1428+
.. code-block:: ini
1429+
1430+
[pytest]
1431+
collect_function_definition = pedantic
1432+
1433+
For example, given a parametrized test:
1434+
1435+
.. code-block:: python
1436+
1437+
import pytest
1438+
1439+
1440+
@pytest.mark.parametrize("x", [1, 2])
1441+
def test_it(x): ...
1442+
1443+
the default (``hidden``) layout collects the invocations directly under the
1444+
module::
1445+
1446+
<Module test_it.py>
1447+
<Function test_it[1]>
1448+
<Function test_it[2]>
1449+
1450+
while ``pedantic`` and ``messy`` group them under a definition node::
1451+
1452+
<Module test_it.py>
1453+
<FunctionDefinition test_it>
1454+
<Function test_it[1]>
1455+
<Function test_it[2]>
1456+
1457+
This is an opt-in structural change. The node ids of the individual test
1458+
invocations are unchanged (for example ``test_it.py::test_it[1]``), so test
1459+
selection, caching and reporting behave exactly as before.
1460+
13811461
.. confval:: collect_imported_tests
13821462
:type: ``bool``
13831463
:default: ``true``
@@ -3677,6 +3757,14 @@ All the command-line flags can also be obtained by running ``pytest --help``::
36773757
collect_imported_tests (bool):
36783758
Whether to collect tests in imported modules outside
36793759
`testpaths`
3760+
collect_function_definition (string):
3761+
How the function-definition collector node
3762+
participates in the collection tree. - hidden
3763+
(default): keep the flat layout, no node in the tree
3764+
- pedantic: insert the node and scope function-level
3765+
markers to it - messy: insert the node but transfer
3766+
markers down to each invocation to preserve the
3767+
legacy marker layout (emits a warning)
36803768
consider_namespace_packages (bool):
36813769
Consider namespace packages when resolving module
36823770
names during import

‎src/_pytest/main.py‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,17 @@ def pytest_addoption(parser: Parser) -> None:
251251
type="bool",
252252
default=True,
253253
)
254+
parser.addini(
255+
"collect_function_definition",
256+
"How the function-definition collector node participates in the "
257+
"collection tree.\n"
258+
"- hidden (default): keep the flat layout, no node in the tree\n"
259+
"- pedantic: insert the node and scope function-level markers to it\n"
260+
"- messy: insert the node but transfer markers down to each invocation "
261+
"to preserve the legacy marker layout (emits a warning)",
262+
type="string",
263+
default="hidden",
264+
)
254265
parser.addini(
255266
"consider_namespace_packages",
256267
type="bool",
@@ -1007,6 +1018,7 @@ def collect(self) -> Iterator[nodes.Item | nodes.Collector]:
10071018
# Prune this level.
10081019
any_matched_in_collector = False
10091020
for node in reversed(subnodes):
1021+
remaining = matchparts[1:]
10101022
# Path part e.g. `/a/b/` in `/a/b/test_file.py::TestIt::test_it`.
10111023
if isinstance(matchparts[0], Path):
10121024
is_match = node.path == matchparts[0]
@@ -1021,7 +1033,13 @@ def collect(self) -> Iterator[nodes.Item | nodes.Collector]:
10211033
else:
10221034
if len(matchparts) == 1:
10231035
# This the last part, one parametrization goes.
1024-
if parametrization is not None:
1036+
if isinstance(node, nodes.ItemDefinition):
1037+
# A definition node carries the bare name, while
1038+
# the parametrization is on the items below it.
1039+
# Descend and match the same part again there.
1040+
is_match = node.name == matchparts[0]
1041+
remaining = matchparts
1042+
elif parametrization is not None:
10251043
# A parametrized arg must match exactly.
10261044
is_match = node.name == matchparts[0] + parametrization
10271045
else:
@@ -1032,7 +1050,7 @@ def collect(self) -> Iterator[nodes.Item | nodes.Collector]:
10321050
else:
10331051
is_match = node.name == matchparts[0]
10341052
if is_match:
1035-
work.append((node, matchparts[1:]))
1053+
work.append((node, remaining))
10361054
any_matched_in_collector = True
10371055

10381056
if not any_matched_in_collector:

‎src/_pytest/nodes.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,23 @@ class Directory(FSCollector, abc.ABC):
644644
"""
645645

646646

647+
class ItemDefinition(Collector, abc.ABC):
648+
"""Base class for collectors standing for a single test *definition*.
649+
650+
Its children are the (possibly parametrized) items generated from that one
651+
definition. Giving the definition a node of its own makes it addressable:
652+
it owns the definition's markers, and node id matching looks through it --
653+
the definition carries the bare name, while the ``[...]`` parametrization
654+
lives on the items below it.
655+
656+
:class:`~_pytest.python.FunctionDefinition` is the implementation for
657+
Python test functions, inserted into the tree by
658+
:confval:`collect_function_definition`.
659+
660+
.. versionadded:: 9.2
661+
"""
662+
663+
647664
class Item(Node, abc.ABC):
648665
"""Base class of all test invocation items.
649666

0 commit comments

Comments
 (0)