Skip to content

issue a warning when Item and Collector are used in diamond inheritance #8447

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 24, 2021
Merged
4 changes: 4 additions & 0 deletions changelog/8447.deprecation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Defining a custom pytest node type which is both an item and a collector now issues a warning.
It was never sanely supported and triggers hard to debug errors.

Instead, a separate collector node should be used, which collects the item. See :ref:`non-python tests` for an example.
14 changes: 14 additions & 0 deletions doc/en/deprecations.rst
Original file line number Diff line number Diff line change
@@ -42,6 +42,20 @@ As pytest tries to move off `py.path.local <https://py.readthedocs.io/en/latest/

Pytest will provide compatibility for quite a while.

Diamond inheritance between :class:`pytest.File` and :class:`pytest.Item`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. deprecated:: 6.3

Inheriting from both Item and file at once has never been supported officially,
however some plugins providing linting/code analysis have been using this as a hack.

This practice is now officially deprecated and a common way to fix this is `example pr fixing inheritance`_.



.. _example pr fixing inheritance: https://github.com/asmeurer/pytest-flakes/pull/40/files


Backward compatibilities in ``Parser.addoption``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2 changes: 1 addition & 1 deletion src/_pytest/main.py
Original file line number Diff line number Diff line change
@@ -484,7 +484,7 @@ def __init__(self, config: Config) -> None:

@classmethod
def from_config(cls, config: Config) -> "Session":
session: Session = cls._create(config)
session: Session = cls._create(config=config)
return session

def __repr__(self) -> str:
88 changes: 72 additions & 16 deletions src/_pytest/nodes.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import os
import warnings
from inspect import signature
from pathlib import Path
from typing import Any
from typing import Callable
from typing import cast
from typing import Iterable
from typing import Iterator
from typing import List
@@ -34,6 +36,7 @@
from _pytest.pathlib import absolutepath
from _pytest.pathlib import commonpath
from _pytest.store import Store
from _pytest.warning_types import PytestWarning

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

def _create(self, *k, **kw):
return super().__call__(*k, **kw)
try:
return super().__call__(*k, **kw)
except TypeError:
sig = signature(getattr(self, "__init__"))
known_kw = {k: v for k, v in kw.items() if k in sig.parameters}
from .warning_types import PytestDeprecationWarning

warnings.warn(
PytestDeprecationWarning(
f"{self} is not using a cooperative constructor and only takes {set(known_kw)}"
)
)

return super().__call__(*k, **known_kw)


class Node(metaclass=NodeMeta):
@@ -539,26 +555,39 @@ def _check_initialpaths_for_relpath(session: "Session", path: Path) -> Optional[
class FSCollector(Collector):
def __init__(
self,
fspath: Optional[LEGACY_PATH],
path: Optional[Path],
parent=None,
fspath: Optional[LEGACY_PATH] = None,
path_or_parent: Optional[Union[Path, Node]] = None,
path: Optional[Path] = None,
name: Optional[str] = None,
parent: Optional[Node] = None,
config: Optional[Config] = None,
session: Optional["Session"] = None,
nodeid: Optional[str] = None,
) -> None:
if path_or_parent:
if isinstance(path_or_parent, Node):
assert parent is None
parent = cast(FSCollector, path_or_parent)
elif isinstance(path_or_parent, Path):
assert path is None
path = path_or_parent

path, fspath = _imply_path(path, fspath=fspath)
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 = name.replace(os.sep, SEP)
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 = name.replace(os.sep, SEP)
self.path = path

session = session or parent.session
if session is None:
assert parent is not None
session = parent.session

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

super().__init__(
name, parent, config, session, nodeid=nodeid, fspath=fspath, path=path
name=name,
parent=parent,
config=config,
session=session,
nodeid=nodeid,
path=path,
)

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

nextitem = None

def __init_subclass__(cls) -> None:
problems = ", ".join(
base.__name__ for base in cls.__bases__ if issubclass(base, Collector)
)
if problems:
warnings.warn(
f"{cls.__name__} is an Item subclass and should not be a collector, "
f"however its bases {problems} are collectors.\n"
"Please split the Collectors and the Item into separate node types.\n"
"Pytest Doc example: https://docs.pytest.org/en/latest/example/nonpython.html\n"
"example pull request on a plugin: https://github.com/asmeurer/pytest-flakes/pull/40/",
PytestWarning,
)

def __init__(
self,
name,
parent=None,
config: Optional[Config] = None,
session: Optional["Session"] = None,
nodeid: Optional[str] = None,
**kw,
) -> None:
super().__init__(name, parent, config, session, nodeid=nodeid)
super().__init__(
name=name,
parent=parent,
config=config,
session=session,
nodeid=nodeid,
**kw,
)
self._report_sections: List[Tuple[str, str, str]] = []

#: A list of tuples (name, value) that holds user defined properties
31 changes: 31 additions & 0 deletions testing/test_nodes.py
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@

import pytest
from _pytest import nodes
from _pytest.compat import legacy_path
from _pytest.pytester import Pytester
from _pytest.warning_types import PytestWarning

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


def test_subclassing_both_item_and_collector_deprecated(
request, tmp_path: Path
) -> None:
"""
Verifies we warn on diamond inheritance
as well as correctly managing legacy inheritance ctors with missing args
as found in plugins
"""

with pytest.warns(
PytestWarning,
match=(
"(?m)SoWrong is an Item subclass and should not be a collector, however its bases File are collectors.\n"
"Please split the Collectors and the Item into separate node types.\n.*"
),
):

class SoWrong(nodes.File, nodes.Item):
def __init__(self, fspath, parent):
"""Legacy ctor with legacy call # don't wana see"""
super().__init__(fspath, parent)

with pytest.warns(
PytestWarning, match=".*SoWrong.* not using a cooperative constructor.*"
):
SoWrong.from_parent(
request.session, fspath=legacy_path(tmp_path / "broken.txt")
)


@pytest.mark.parametrize(
"warn_type, msg", [(DeprecationWarning, "deprecated"), (PytestWarning, "pytest")]
)