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 <ran@unusedvar.com> * 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 <ran@unusedvar.com>
This commit is contained in:
parent
942789bace
commit
d7b0e17205
|
@ -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.
|
|
@ -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.
|
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``
|
Backward compatibilities in ``Parser.addoption``
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
|
@ -484,7 +484,7 @@ class Session(nodes.FSCollector):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config: Config) -> "Session":
|
def from_config(cls, config: Config) -> "Session":
|
||||||
session: Session = cls._create(config)
|
session: Session = cls._create(config=config)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import os
|
import os
|
||||||
import warnings
|
import warnings
|
||||||
|
from inspect import signature
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
from typing import cast
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
from typing import List
|
from typing import List
|
||||||
|
@ -34,6 +36,7 @@ from _pytest.outcomes import fail
|
||||||
from _pytest.pathlib import absolutepath
|
from _pytest.pathlib import absolutepath
|
||||||
from _pytest.pathlib import commonpath
|
from _pytest.pathlib import commonpath
|
||||||
from _pytest.store import Store
|
from _pytest.store import Store
|
||||||
|
from _pytest.warning_types import PytestWarning
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# Imported here due to circular import.
|
# Imported here due to circular import.
|
||||||
|
@ -125,7 +128,20 @@ class NodeMeta(type):
|
||||||
fail(msg, pytrace=False)
|
fail(msg, pytrace=False)
|
||||||
|
|
||||||
def _create(self, *k, **kw):
|
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):
|
class Node(metaclass=NodeMeta):
|
||||||
|
@ -539,26 +555,39 @@ def _check_initialpaths_for_relpath(session: "Session", path: Path) -> Optional[
|
||||||
class FSCollector(Collector):
|
class FSCollector(Collector):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
fspath: Optional[LEGACY_PATH],
|
fspath: Optional[LEGACY_PATH] = None,
|
||||||
path: Optional[Path],
|
path_or_parent: Optional[Union[Path, Node]] = None,
|
||||||
parent=None,
|
path: Optional[Path] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
parent: Optional[Node] = None,
|
||||||
config: Optional[Config] = None,
|
config: Optional[Config] = None,
|
||||||
session: Optional["Session"] = None,
|
session: Optional["Session"] = None,
|
||||||
nodeid: Optional[str] = None,
|
nodeid: Optional[str] = None,
|
||||||
) -> 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)
|
path, fspath = _imply_path(path, fspath=fspath)
|
||||||
name = path.name
|
if name is None:
|
||||||
if parent is not None and parent.path != path:
|
name = path.name
|
||||||
try:
|
if parent is not None and parent.path != path:
|
||||||
rel = path.relative_to(parent.path)
|
try:
|
||||||
except ValueError:
|
rel = path.relative_to(parent.path)
|
||||||
pass
|
except ValueError:
|
||||||
else:
|
pass
|
||||||
name = str(rel)
|
else:
|
||||||
name = name.replace(os.sep, SEP)
|
name = str(rel)
|
||||||
|
name = name.replace(os.sep, SEP)
|
||||||
self.path = path
|
self.path = path
|
||||||
|
|
||||||
session = session or parent.session
|
if session is None:
|
||||||
|
assert parent is not None
|
||||||
|
session = parent.session
|
||||||
|
|
||||||
if nodeid is None:
|
if nodeid is None:
|
||||||
try:
|
try:
|
||||||
|
@ -570,7 +599,12 @@ class FSCollector(Collector):
|
||||||
nodeid = nodeid.replace(os.sep, SEP)
|
nodeid = nodeid.replace(os.sep, SEP)
|
||||||
|
|
||||||
super().__init__(
|
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
|
@classmethod
|
||||||
|
@ -610,6 +644,20 @@ class Item(Node):
|
||||||
|
|
||||||
nextitem = None
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name,
|
name,
|
||||||
|
@ -617,8 +665,16 @@ class Item(Node):
|
||||||
config: Optional[Config] = None,
|
config: Optional[Config] = None,
|
||||||
session: Optional["Session"] = None,
|
session: Optional["Session"] = None,
|
||||||
nodeid: Optional[str] = None,
|
nodeid: Optional[str] = None,
|
||||||
|
**kw,
|
||||||
) -> None:
|
) -> 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]] = []
|
self._report_sections: List[Tuple[str, str, str]] = []
|
||||||
|
|
||||||
#: A list of tuples (name, value) that holds user defined properties
|
#: A list of tuples (name, value) that holds user defined properties
|
||||||
|
|
|
@ -5,6 +5,7 @@ from typing import Type
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from _pytest import nodes
|
from _pytest import nodes
|
||||||
|
from _pytest.compat import legacy_path
|
||||||
from _pytest.pytester import Pytester
|
from _pytest.pytester import Pytester
|
||||||
from _pytest.warning_types import PytestWarning
|
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]
|
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(
|
@pytest.mark.parametrize(
|
||||||
"warn_type, msg", [(DeprecationWarning, "deprecated"), (PytestWarning, "pytest")]
|
"warn_type, msg", [(DeprecationWarning, "deprecated"), (PytestWarning, "pytest")]
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue