Skip to content

Commit d7b0e17

Browse files
issue a warning when Item and Collector are used in diamond inheritance (#8447)
* issue a warning when Items and Collector form a diamond addresses #8435 * Apply suggestions from code review Co-authored-by: Ran Benita <[email protected]> * Return support for the broken File/Item hybrids * adds deprecation * ads necessary support code in node construction * fix incorrect mypy based assertions * add docs for deprecation of Item/File inheritance * warn when a non-cooperative ctor is encountered * use getattr instead of cast to get the class __init__ for legacy ctors * update documentation references for node inheritance * clean up file+item inheritance test enhance docs move import upwards Co-authored-by: Ran Benita <[email protected]>
1 parent 942789b commit d7b0e17

File tree

5 files changed

+122
-17
lines changed

5 files changed

+122
-17
lines changed

changelog/8447.deprecation.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Defining a custom pytest node type which is both an item and a collector now issues a warning.
2+
It was never sanely supported and triggers hard to debug errors.
3+
4+
Instead, a separate collector node should be used, which collects the item. See :ref:`non-python tests` for an example.

doc/en/deprecations.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ As pytest tries to move off `py.path.local <https://py.readthedocs.io/en/latest/
4242

4343
Pytest will provide compatibility for quite a while.
4444

45+
Diamond inheritance between :class:`pytest.File` and :class:`pytest.Item`
46+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
47+
48+
.. deprecated:: 6.3
49+
50+
Inheriting from both Item and file at once has never been supported officially,
51+
however some plugins providing linting/code analysis have been using this as a hack.
52+
53+
This practice is now officially deprecated and a common way to fix this is `example pr fixing inheritance`_.
54+
55+
56+
57+
.. _example pr fixing inheritance: https://github.com/asmeurer/pytest-flakes/pull/40/files
58+
4559

4660
Backward compatibilities in ``Parser.addoption``
4761
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

src/_pytest/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ def __init__(self, config: Config) -> None:
484484

485485
@classmethod
486486
def from_config(cls, config: Config) -> "Session":
487-
session: Session = cls._create(config)
487+
session: Session = cls._create(config=config)
488488
return session
489489

490490
def __repr__(self) -> str:

src/_pytest/nodes.py

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import os
22
import warnings
3+
from inspect import signature
34
from pathlib import Path
45
from typing import Any
56
from typing import Callable
7+
from typing import cast
68
from typing import Iterable
79
from typing import Iterator
810
from typing import List
@@ -34,6 +36,7 @@
3436
from _pytest.pathlib import absolutepath
3537
from _pytest.pathlib import commonpath
3638
from _pytest.store import Store
39+
from _pytest.warning_types import PytestWarning
3740

3841
if TYPE_CHECKING:
3942
# Imported here due to circular import.
@@ -125,7 +128,20 @@ def __call__(self, *k, **kw):
125128
fail(msg, pytrace=False)
126129

127130
def _create(self, *k, **kw):
128-
return super().__call__(*k, **kw)
131+
try:
132+
return super().__call__(*k, **kw)
133+
except TypeError:
134+
sig = signature(getattr(self, "__init__"))
135+
known_kw = {k: v for k, v in kw.items() if k in sig.parameters}
136+
from .warning_types import PytestDeprecationWarning
137+
138+
warnings.warn(
139+
PytestDeprecationWarning(
140+
f"{self} is not using a cooperative constructor and only takes {set(known_kw)}"
141+
)
142+
)
143+
144+
return super().__call__(*k, **known_kw)
129145

130146

131147
class Node(metaclass=NodeMeta):
@@ -539,26 +555,39 @@ def _check_initialpaths_for_relpath(session: "Session", path: Path) -> Optional[
539555
class FSCollector(Collector):
540556
def __init__(
541557
self,
542-
fspath: Optional[LEGACY_PATH],
543-
path: Optional[Path],
544-
parent=None,
558+
fspath: Optional[LEGACY_PATH] = None,
559+
path_or_parent: Optional[Union[Path, Node]] = None,
560+
path: Optional[Path] = None,
561+
name: Optional[str] = None,
562+
parent: Optional[Node] = None,
545563
config: Optional[Config] = None,
546564
session: Optional["Session"] = None,
547565
nodeid: Optional[str] = None,
548566
) -> None:
567+
if path_or_parent:
568+
if isinstance(path_or_parent, Node):
569+
assert parent is None
570+
parent = cast(FSCollector, path_or_parent)
571+
elif isinstance(path_or_parent, Path):
572+
assert path is None
573+
path = path_or_parent
574+
549575
path, fspath = _imply_path(path, fspath=fspath)
550-
name = path.name
551-
if parent is not None and parent.path != path:
552-
try:
553-
rel = path.relative_to(parent.path)
554-
except ValueError:
555-
pass
556-
else:
557-
name = str(rel)
558-
name = name.replace(os.sep, SEP)
576+
if name is None:
577+
name = path.name
578+
if parent is not None and parent.path != path:
579+
try:
580+
rel = path.relative_to(parent.path)
581+
except ValueError:
582+
pass
583+
else:
584+
name = str(rel)
585+
name = name.replace(os.sep, SEP)
559586
self.path = path
560587

561-
session = session or parent.session
588+
if session is None:
589+
assert parent is not None
590+
session = parent.session
562591

563592
if nodeid is None:
564593
try:
@@ -570,7 +599,12 @@ def __init__(
570599
nodeid = nodeid.replace(os.sep, SEP)
571600

572601
super().__init__(
573-
name, parent, config, session, nodeid=nodeid, fspath=fspath, path=path
602+
name=name,
603+
parent=parent,
604+
config=config,
605+
session=session,
606+
nodeid=nodeid,
607+
path=path,
574608
)
575609

576610
@classmethod
@@ -610,15 +644,37 @@ class Item(Node):
610644

611645
nextitem = None
612646

647+
def __init_subclass__(cls) -> None:
648+
problems = ", ".join(
649+
base.__name__ for base in cls.__bases__ if issubclass(base, Collector)
650+
)
651+
if problems:
652+
warnings.warn(
653+
f"{cls.__name__} is an Item subclass and should not be a collector, "
654+
f"however its bases {problems} are collectors.\n"
655+
"Please split the Collectors and the Item into separate node types.\n"
656+
"Pytest Doc example: https://docs.pytest.org/en/latest/example/nonpython.html\n"
657+
"example pull request on a plugin: https://github.com/asmeurer/pytest-flakes/pull/40/",
658+
PytestWarning,
659+
)
660+
613661
def __init__(
614662
self,
615663
name,
616664
parent=None,
617665
config: Optional[Config] = None,
618666
session: Optional["Session"] = None,
619667
nodeid: Optional[str] = None,
668+
**kw,
620669
) -> None:
621-
super().__init__(name, parent, config, session, nodeid=nodeid)
670+
super().__init__(
671+
name=name,
672+
parent=parent,
673+
config=config,
674+
session=session,
675+
nodeid=nodeid,
676+
**kw,
677+
)
622678
self._report_sections: List[Tuple[str, str, str]] = []
623679

624680
#: A list of tuples (name, value) that holds user defined properties

testing/test_nodes.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytest
77
from _pytest import nodes
8+
from _pytest.compat import legacy_path
89
from _pytest.pytester import Pytester
910
from _pytest.warning_types import PytestWarning
1011

@@ -39,6 +40,36 @@ def test_node_from_parent_disallowed_arguments() -> None:
3940
nodes.Node.from_parent(None, config=None) # type: ignore[arg-type]
4041

4142

43+
def test_subclassing_both_item_and_collector_deprecated(
44+
request, tmp_path: Path
45+
) -> None:
46+
"""
47+
Verifies we warn on diamond inheritance
48+
as well as correctly managing legacy inheritance ctors with missing args
49+
as found in plugins
50+
"""
51+
52+
with pytest.warns(
53+
PytestWarning,
54+
match=(
55+
"(?m)SoWrong is an Item subclass and should not be a collector, however its bases File are collectors.\n"
56+
"Please split the Collectors and the Item into separate node types.\n.*"
57+
),
58+
):
59+
60+
class SoWrong(nodes.File, nodes.Item):
61+
def __init__(self, fspath, parent):
62+
"""Legacy ctor with legacy call # don't wana see"""
63+
super().__init__(fspath, parent)
64+
65+
with pytest.warns(
66+
PytestWarning, match=".*SoWrong.* not using a cooperative constructor.*"
67+
):
68+
SoWrong.from_parent(
69+
request.session, fspath=legacy_path(tmp_path / "broken.txt")
70+
)
71+
72+
4273
@pytest.mark.parametrize(
4374
"warn_type, msg", [(DeprecationWarning, "deprecated"), (PytestWarning, "pytest")]
4475
)

0 commit comments

Comments
 (0)