From 99770bca63ca7f9000854c7f1da937497ac4a9dd Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 22 Feb 2026 20:01:23 +0100 Subject: [PATCH 1/6] refactor(nodes): move diamond inheritance check from Item.__init__ to Item.from_parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _check_item_and_collector_diamond_inheritance validation is not data storage — it's a one-time class-level check. Moving it to from_parent keeps __init__ focused on storing values. Also converts the method to a classmethod since it only inspects the class, not the instance. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude --- src/_pytest/nodes.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index bc1dfc90d96..62b64ff459b 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -701,15 +701,22 @@ def __init__( #: for this test. self.user_properties: list[tuple[str, object]] = [] - self._check_item_and_collector_diamond_inheritance() + @classmethod + def from_parent(cls, parent: Node, **kw) -> Self: + """Public constructor for Items. + + This calls the diamond inheritance check before delegating to + :meth:`Node.from_parent`. + """ + cls._check_item_and_collector_diamond_inheritance() + return super().from_parent(parent=parent, **kw) - def _check_item_and_collector_diamond_inheritance(self) -> None: + @classmethod + def _check_item_and_collector_diamond_inheritance(cls) -> None: """ Check if the current type inherits from both File and Collector at the same time, emitting a warning accordingly (#8447). """ - cls = type(self) - # We inject an attribute in the type to avoid issuing this warning # for the same class more than once, which is not helpful. # It is a hack, but was deemed acceptable in order to avoid From d639ddbe66e19b06fea94dd02b3df4b16c57edad Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 22 Feb 2026 20:11:01 +0100 Subject: [PATCH 2/6] refactor(python): remove redundant Package.__init__, add explicit from_parent Package.__init__ only did `session = parent.session` which FSCollector already handles. Removing the __init__ entirely and adding an explicit from_parent with a typed signature for clarity. Also removes the now-unused LEGACY_PATH import from python.py. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude --- src/_pytest/python.py | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 7374fa3cee0..b42fac1dbc2 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -45,7 +45,6 @@ from _pytest.compat import get_real_func from _pytest.compat import getimfunc from _pytest.compat import is_async_function -from _pytest.compat import LEGACY_PATH from _pytest.compat import NOTSET from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass @@ -652,27 +651,20 @@ class Package(nodes.Directory): Now inherits from :class:`~pytest.Directory`. """ - def __init__( - self, - fspath: LEGACY_PATH | None, + @classmethod + def from_parent( # type: ignore[override] + cls, parent: nodes.Collector, - # NOTE: following args are unused: - config=None, - session=None, - nodeid=None, - path: Path | None = None, - ) -> None: - # NOTE: Could be just the following, but kept as-is for compat. - # super().__init__(self, fspath, parent=parent) - session = parent.session - super().__init__( - fspath=fspath, - path=path, - parent=parent, - config=config, - session=session, - nodeid=nodeid, - ) + *, + path: Path, + ) -> Self: + """The public constructor. + + :param parent: The parent collector of this Package. + :param path: The package directory's path. + :type path: pathlib.Path + """ + return super().from_parent(parent=parent, path=path) def setup(self) -> None: init_mod = importtestmodule(self.path / "__init__.py", self.config) From 06f2ce4ca346211c38ddca449dad639011a5fc22 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 22 Feb 2026 20:46:46 +0100 Subject: [PATCH 3/6] refactor(nodes): extract FSCollector name/nodeid derivation into classmethods and from_parent Extracts _derive_name and _derive_nodeid as classmethods on FSCollector. The from_parent method now pre-computes name and nodeid before calling super, so __init__ receives fully resolved values in the normal path. The __init__ retains fallback derivation for backward compatibility with non-cooperative constructors (plugins using positional args). Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude --- src/_pytest/nodes.py | 61 ++++++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 62b64ff459b..93f329ae6e0 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -585,6 +585,7 @@ def __init__( session: Session | None = None, nodeid: str | None = None, ) -> None: + # Legacy path_or_parent handling — kept for non-cooperative constructors. if path_or_parent: if isinstance(path_or_parent, Node): assert parent is None @@ -595,15 +596,7 @@ def __init__( path = _imply_path(type(self), path, fspath=fspath) if name is None: - name = path.name - if parent is not None and parent.path != path: - try: - rel = path.relative_to(parent.path) - except ValueError: - pass - else: - name = str(rel) - name = norm_sep(name) + name = self._derive_name(path, parent) self.path = path if session is None: @@ -611,13 +604,7 @@ def __init__( session = parent.session if nodeid is None: - try: - nodeid = str(self.path.relative_to(session.config.rootpath)) - except ValueError: - nodeid = _check_initialpaths_for_relpath(session._initialpaths, path) - - if nodeid: - nodeid = norm_sep(nodeid) + nodeid = self._derive_nodeid(path, parent, session) super().__init__( name=name, @@ -628,6 +615,35 @@ def __init__( path=path, ) + @classmethod + def _derive_name(cls, path: Path, parent: Node | None) -> str: + """Derive a collector name from its path and parent.""" + name = path.name + if parent is not None and parent.path != path: + try: + rel = path.relative_to(parent.path) + except ValueError: + pass + else: + name = str(rel) + name = norm_sep(name) + return name + + @classmethod + def _derive_nodeid( + cls, path: Path, parent: Node | None, session: Session + ) -> str | None: + """Derive a node ID from its path and the session root.""" + nodeid: str | None + try: + nodeid = str(path.relative_to(session.config.rootpath)) + except ValueError: + nodeid = _check_initialpaths_for_relpath(session._initialpaths, path) + + if nodeid: + nodeid = norm_sep(nodeid) + return nodeid + @classmethod def from_parent( cls, @@ -637,7 +653,18 @@ def from_parent( path: Path | None = None, **kw, ) -> Self: - """The public constructor.""" + """The public constructor. + + Pre-computes name and nodeid from the path so that ``__init__`` + receives fully resolved values. + """ + path = _imply_path(cls, path, fspath=fspath) + + if "name" not in kw: + kw["name"] = cls._derive_name(path, parent) + if "nodeid" not in kw: + kw["nodeid"] = cls._derive_nodeid(path, parent, parent.session) + return super().from_parent(parent=parent, fspath=fspath, path=path, **kw) From 0d507c4a64bfd412cb610844b1bd8dce12115e53 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 22 Feb 2026 21:30:06 +0100 Subject: [PATCH 4/6] refactor(doctest): move fixture resolution from DoctestItem.__init__ to from_parent DoctestItem.__init__ now only stores runner, dtest, and obj. The fixture resolution (getfixtureinfo) and _initrequest() are moved to from_parent, which is the only production entry point for creating DoctestItem instances. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude --- src/_pytest/doctest.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index cd255f5eeb6..8a2f319b0fd 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -31,6 +31,7 @@ from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.fixtures import fixture +from _pytest.fixtures import FuncFixtureInfo from _pytest.fixtures import TopRequest from _pytest.nodes import Collector from _pytest.nodes import Item @@ -249,6 +250,9 @@ def _get_runner( class DoctestItem(Item): + _fixtureinfo: FuncFixtureInfo + fixturenames: list[str] + def __init__( self, name: str, @@ -259,14 +263,7 @@ def __init__( super().__init__(name, parent) self.runner = runner self.dtest = dtest - - # Stuff needed for fixture support. self.obj = None - fm = self.session._fixturemanager - fixtureinfo = fm.getfixtureinfo(node=self, func=None, cls=None) - self._fixtureinfo = fixtureinfo - self.fixturenames = fixtureinfo.names_closure - self._initrequest() @classmethod def from_parent( # type: ignore[override] @@ -279,7 +276,13 @@ def from_parent( # type: ignore[override] ) -> Self: # incompatible signature due to imposed limits on subclass """The public named constructor.""" - return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) + item = super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest) + fm = item.session._fixturemanager + fixtureinfo = fm.getfixtureinfo(node=item, func=None, cls=None) + item._fixtureinfo = fixtureinfo + item.fixturenames = fixtureinfo.names_closure + item._initrequest() + return item def _initrequest(self) -> None: self.funcargs: dict[str, object] = {} From 52aaf3c0352c21f9ce4cb87ce52f6337d3401b31 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 22 Feb 2026 21:32:49 +0100 Subject: [PATCH 5/6] refactor(python): move marker/fixture/request setup from Function.__init__ to from_parent Function.__init__ now only stores _obj, _instance, originalname, and callspec. The marker extension, keyword updates, fixture resolution (getfixtureinfo), and _initrequest() are extracted into a new _setup_markers_and_fixtures method called from from_parent after construction. This keeps __init__ focused on storing values while from_parent handles all post-construction initialization logic. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude --- src/_pytest/python.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index b42fac1dbc2..62ed6157e25 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1599,16 +1599,22 @@ def __init__( #: .. versionadded:: 3.0 self.originalname = originalname or name - # Note: when FunctionDefinition is introduced, we should change ``originalname`` - # to a readonly property that returns FunctionDefinition.name. - - self.own_markers.extend(get_unpacked_marks(self.obj)) if callspec: self.callspec = callspec - self.own_markers.extend(callspec.marks) - # todo: this is a hell of a hack - # https://github.com/pytest-dev/pytest/issues/4569 + def _setup_markers_and_fixtures( + self, + keywords: Mapping[str, Any] | None = None, + fixtureinfo: FuncFixtureInfo | None = None, + ) -> None: + """Set up markers, keywords, fixture info, and the fixture request. + + Called from :meth:`from_parent` after the node is fully constructed. + """ + self.own_markers.extend(get_unpacked_marks(self.obj)) + if hasattr(self, "callspec"): + self.own_markers.extend(self.callspec.marks) + # Note: the order of the updates is important here; indicates what # takes priority (ctor argument over function attributes over markers). # Take own_markers only; NodeKeywords handles parent traversal on its own. @@ -1624,11 +1630,17 @@ def __init__( self.fixturenames = fixtureinfo.names_closure self._initrequest() - # todo: determine sound type limitations @classmethod def from_parent(cls, parent, **kw) -> Self: """The public constructor.""" - return super().from_parent(parent=parent, **kw) + keywords = kw.pop("keywords", None) + fixtureinfo = kw.pop("fixtureinfo", None) + item = super().from_parent(parent=parent, **kw) + item._setup_markers_and_fixtures( + keywords=keywords, + fixtureinfo=fixtureinfo, + ) + return item def _initrequest(self) -> None: self.funcargs: dict[str, object] = {} From 8b109ac397de2c5287244cfb889f33965197719b Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 22 Feb 2026 22:35:10 +0100 Subject: [PATCH 6/6] fix: address review findings from node constructor cleanup 1. FSCollector: fix double _imply_path call that would produce duplicate deprecation warnings when fspath is used. Now __init__ skips the _imply_path call when path is already resolved (by from_parent), using _check_path for consistency validation instead. 2. Function: remove dead keywords/fixtureinfo parameters from __init__ since from_parent now pops them before calling super. Replace with **kw for forward-compatibility with additional kwargs. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude --- src/_pytest/nodes.py | 8 +++++++- src/_pytest/python.py | 5 ++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 93f329ae6e0..c4ef0325d2a 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -594,7 +594,13 @@ def __init__( assert path is None path = path_or_parent - path = _imply_path(type(self), path, fspath=fspath) + if path is not None: + # Path was already resolved (typically by from_parent); skip + # _imply_path to avoid a duplicate deprecation warning for fspath. + if fspath is not None: + _check_path(path, fspath) + else: + path = _imply_path(type(self), path, fspath=fspath) if name is None: name = self._derive_name(path, parent) self.path = path diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 62ed6157e25..6f55be64367 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1580,12 +1580,11 @@ def __init__( config: Config | None = None, callspec: CallSpec2 | None = None, callobj=NOTSET, - keywords: Mapping[str, Any] | None = None, session: Session | None = None, - fixtureinfo: FuncFixtureInfo | None = None, originalname: str | None = None, + **kw, ) -> None: - super().__init__(name, parent, config=config, session=session) + super().__init__(name, parent, config=config, session=session, **kw) if callobj is not NOTSET: self._obj = callobj