deprecate direct node construction and introduce Node.from_parent

This commit is contained in:
Ronny Pfannschmidt 2019-10-16 21:52:04 +02:00 committed by Ronny Pfannschmidt
parent 886b8d27c6
commit c99c7d0f95
15 changed files with 108 additions and 35 deletions

View File

@ -0,0 +1,6 @@
Deprecate using direct constructors for ``Nodes``.
Instead they are new constructed via ``Node.from_parent``.
This transitional mechanism enables us to detangle the very intensely
entangled ``Node`` relationships by enforcing more controlled creation/configruation patterns.

View File

@ -9,6 +9,7 @@ All constants defined in this module should be either PytestWarning instances or
in case of warnings which need to format their messages. in case of warnings which need to format their messages.
""" """
from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import PytestDeprecationWarning
from _pytest.warning_types import UnformattedWarning
# set of plugins which have been integrated into the core; we use this list to ignore # set of plugins which have been integrated into the core; we use this list to ignore
# them during registration to avoid conflicts # them during registration to avoid conflicts
@ -35,6 +36,11 @@ FIXTURE_POSITIONAL_ARGUMENTS = PytestDeprecationWarning(
"as a keyword argument instead." "as a keyword argument instead."
) )
NODE_USE_FROM_PARENT = UnformattedWarning(
PytestDeprecationWarning,
"direct construction of {name} has been deprecated, please use {name}.from_parent",
)
JUNIT_XML_DEFAULT_FAMILY = PytestDeprecationWarning( JUNIT_XML_DEFAULT_FAMILY = PytestDeprecationWarning(
"The 'junit_family' default value will change to 'xunit2' in pytest 6.0.\n" "The 'junit_family' default value will change to 'xunit2' in pytest 6.0.\n"
"Add 'junit_family=legacy' to your pytest.ini file to silence this warning and make your suite compatible." "Add 'junit_family=legacy' to your pytest.ini file to silence this warning and make your suite compatible."

View File

@ -108,9 +108,9 @@ def pytest_collect_file(path, parent):
config = parent.config config = parent.config
if path.ext == ".py": if path.ext == ".py":
if config.option.doctestmodules and not _is_setup_py(config, path, parent): if config.option.doctestmodules and not _is_setup_py(config, path, parent):
return DoctestModule(path, parent) return DoctestModule.from_parent(parent, fspath=path)
elif _is_doctest(config, path, parent): elif _is_doctest(config, path, parent):
return DoctestTextfile(path, parent) return DoctestTextfile.from_parent(parent, fspath=path)
def _is_setup_py(config, path, parent): def _is_setup_py(config, path, parent):
@ -215,6 +215,10 @@ class DoctestItem(pytest.Item):
self.obj = None self.obj = None
self.fixture_request = None self.fixture_request = None
@classmethod
def from_parent(cls, parent, *, name, runner, dtest):
return cls._create(name=name, parent=parent, runner=runner, dtest=dtest)
def setup(self): def setup(self):
if self.dtest is not None: if self.dtest is not None:
self.fixture_request = _setup_fixtures(self) self.fixture_request = _setup_fixtures(self)
@ -370,7 +374,9 @@ class DoctestTextfile(pytest.Module):
parser = doctest.DocTestParser() parser = doctest.DocTestParser()
test = parser.get_doctest(text, globs, name, filename, 0) test = parser.get_doctest(text, globs, name, filename, 0)
if test.examples: if test.examples:
yield DoctestItem(test.name, self, runner, test) yield DoctestItem.from_parent(
self, name=test.name, runner=runner, dtest=test
)
def _check_all_skipped(test): def _check_all_skipped(test):
@ -467,7 +473,9 @@ class DoctestModule(pytest.Module):
for test in finder.find(module, module.__name__): for test in finder.find(module, module.__name__):
if test.examples: # skip empty doctests if test.examples: # skip empty doctests
yield DoctestItem(test.name, self, runner, test) yield DoctestItem.from_parent(
self, name=test.name, runner=runner, dtest=test
)
def _setup_fixtures(doctest_item): def _setup_fixtures(doctest_item):

View File

@ -184,7 +184,7 @@ def pytest_addoption(parser):
def wrap_session(config, doit): def wrap_session(config, doit):
"""Skeleton command line program""" """Skeleton command line program"""
session = Session(config) session = Session.from_config(config)
session.exitstatus = ExitCode.OK session.exitstatus = ExitCode.OK
initstate = 0 initstate = 0
try: try:
@ -395,6 +395,10 @@ class Session(nodes.FSCollector):
self.config.pluginmanager.register(self, name="session") self.config.pluginmanager.register(self, name="session")
@classmethod
def from_config(cls, config):
return cls._create(config)
def __repr__(self): def __repr__(self):
return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % ( return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % (
self.__class__.__name__, self.__class__.__name__,

View File

@ -18,6 +18,7 @@ from _pytest._code.code import ReprExceptionInfo
from _pytest.compat import cached_property from _pytest.compat import cached_property
from _pytest.compat import getfslineno from _pytest.compat import getfslineno
from _pytest.config import Config from _pytest.config import Config
from _pytest.deprecated import NODE_USE_FROM_PARENT
from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureDef
from _pytest.fixtures import FixtureLookupError from _pytest.fixtures import FixtureLookupError
from _pytest.fixtures import FixtureLookupErrorRepr from _pytest.fixtures import FixtureLookupErrorRepr
@ -73,7 +74,16 @@ def ischildnode(baseid, nodeid):
return node_parts[: len(base_parts)] == base_parts return node_parts[: len(base_parts)] == base_parts
class Node: class NodeMeta(type):
def __call__(self, *k, **kw):
warnings.warn(NODE_USE_FROM_PARENT.format(name=self.__name__), stacklevel=2)
return super().__call__(*k, **kw)
def _create(self, *k, **kw):
return super().__call__(*k, **kw)
class Node(metaclass=NodeMeta):
""" base class for Collector and Item the test collection tree. """ base class for Collector and Item the test collection tree.
Collector subclasses have children, Items are terminal nodes.""" Collector subclasses have children, Items are terminal nodes."""
@ -133,6 +143,10 @@ class Node:
if self.name != "()": if self.name != "()":
self._nodeid += "::" + self.name self._nodeid += "::" + self.name
@classmethod
def from_parent(cls, parent, *, name):
return cls._create(parent=parent, name=name)
@property @property
def ihook(self): def ihook(self):
""" fspath sensitive hook proxy used to call pytest hooks""" """ fspath sensitive hook proxy used to call pytest hooks"""
@ -418,6 +432,10 @@ class FSCollector(Collector):
super().__init__(name, parent, config, session, nodeid=nodeid, fspath=fspath) super().__init__(name, parent, config, session, nodeid=nodeid, fspath=fspath)
@classmethod
def from_parent(cls, parent, *, fspath):
return cls._create(parent=parent, fspath=fspath)
class File(FSCollector): class File(FSCollector):
""" base class for collecting tests from a file. """ """ base class for collecting tests from a file. """

View File

@ -744,7 +744,7 @@ class Testdir:
:param arg: a :py:class:`py.path.local` instance of the file :param arg: a :py:class:`py.path.local` instance of the file
""" """
session = Session(config) session = Session.from_config(config)
assert "::" not in str(arg) assert "::" not in str(arg)
p = py.path.local(arg) p = py.path.local(arg)
config.hook.pytest_sessionstart(session=session) config.hook.pytest_sessionstart(session=session)
@ -762,7 +762,7 @@ class Testdir:
""" """
config = self.parseconfigure(path) config = self.parseconfigure(path)
session = Session(config) session = Session.from_config(config)
x = session.fspath.bestrelpath(path) x = session.fspath.bestrelpath(path)
config.hook.pytest_sessionstart(session=session) config.hook.pytest_sessionstart(session=session)
res = session.perform_collect([x], genitems=False)[0] res = session.perform_collect([x], genitems=False)[0]

View File

@ -190,8 +190,8 @@ def path_matches_patterns(path, patterns):
def pytest_pycollect_makemodule(path, parent): def pytest_pycollect_makemodule(path, parent):
if path.basename == "__init__.py": if path.basename == "__init__.py":
return Package(path, parent) return Package.from_parent(parent, fspath=path)
return Module(path, parent) return Module.from_parent(parent, fspath=path)
@hookimpl(hookwrapper=True) @hookimpl(hookwrapper=True)
@ -203,7 +203,7 @@ def pytest_pycollect_makeitem(collector, name, obj):
# nothing was collected elsewhere, let's do it here # nothing was collected elsewhere, let's do it here
if safe_isclass(obj): if safe_isclass(obj):
if collector.istestclass(obj, name): if collector.istestclass(obj, name):
outcome.force_result(Class(name, parent=collector)) outcome.force_result(Class.from_parent(collector, name=name, obj=obj))
elif collector.istestfunction(obj, name): elif collector.istestfunction(obj, name):
# mock seems to store unbound methods (issue473), normalize it # mock seems to store unbound methods (issue473), normalize it
obj = getattr(obj, "__func__", obj) obj = getattr(obj, "__func__", obj)
@ -222,7 +222,7 @@ def pytest_pycollect_makeitem(collector, name, obj):
) )
elif getattr(obj, "__test__", True): elif getattr(obj, "__test__", True):
if is_generator(obj): if is_generator(obj):
res = Function(name, parent=collector) res = Function.from_parent(collector, name=name)
reason = "yield tests were removed in pytest 4.0 - {name} will be ignored".format( reason = "yield tests were removed in pytest 4.0 - {name} will be ignored".format(
name=name name=name
) )
@ -381,7 +381,7 @@ class PyCollector(PyobjMixin, nodes.Collector):
cls = clscol and clscol.obj or None cls = clscol and clscol.obj or None
fm = self.session._fixturemanager fm = self.session._fixturemanager
definition = FunctionDefinition(name=name, parent=self, callobj=funcobj) definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj)
fixtureinfo = fm.getfixtureinfo(definition, funcobj, cls) fixtureinfo = fm.getfixtureinfo(definition, funcobj, cls)
metafunc = Metafunc( metafunc = Metafunc(
@ -396,7 +396,7 @@ class PyCollector(PyobjMixin, nodes.Collector):
self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc)) self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc))
if not metafunc._calls: if not metafunc._calls:
yield Function(name, parent=self, fixtureinfo=fixtureinfo) yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo)
else: else:
# add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs # add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs
fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm) fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm)
@ -408,9 +408,9 @@ class PyCollector(PyobjMixin, nodes.Collector):
for callspec in metafunc._calls: for callspec in metafunc._calls:
subname = "{}[{}]".format(name, callspec.id) subname = "{}[{}]".format(name, callspec.id)
yield Function( yield Function.from_parent(
self,
name=subname, name=subname,
parent=self,
callspec=callspec, callspec=callspec,
callobj=funcobj, callobj=funcobj,
fixtureinfo=fixtureinfo, fixtureinfo=fixtureinfo,
@ -626,7 +626,7 @@ class Package(Module):
if init_module.check(file=1) and path_matches_patterns( if init_module.check(file=1) and path_matches_patterns(
init_module, self.config.getini("python_files") init_module, self.config.getini("python_files")
): ):
yield Module(init_module, self) yield Module.from_parent(self, fspath=init_module)
pkg_prefixes = set() pkg_prefixes = set()
for path in this_path.visit(rec=self._recurse, bf=True, sort=True): for path in this_path.visit(rec=self._recurse, bf=True, sort=True):
# We will visit our own __init__.py file, in which case we skip it. # We will visit our own __init__.py file, in which case we skip it.
@ -677,6 +677,10 @@ def _get_first_non_fixture_func(obj, names):
class Class(PyCollector): class Class(PyCollector):
""" Collector for test methods. """ """ Collector for test methods. """
@classmethod
def from_parent(cls, parent, *, name, obj=None):
return cls._create(name=name, parent=parent)
def collect(self): def collect(self):
if not safe_getattr(self.obj, "__test__", True): if not safe_getattr(self.obj, "__test__", True):
return [] return []
@ -702,7 +706,7 @@ class Class(PyCollector):
self._inject_setup_class_fixture() self._inject_setup_class_fixture()
self._inject_setup_method_fixture() self._inject_setup_method_fixture()
return [Instance(name="()", parent=self)] return [Instance.from_parent(self, name="()")]
def _inject_setup_class_fixture(self): def _inject_setup_class_fixture(self):
"""Injects a hidden autouse, class scoped fixture into the collected class object """Injects a hidden autouse, class scoped fixture into the collected class object
@ -1454,6 +1458,10 @@ class Function(FunctionMixin, nodes.Item):
#: .. versionadded:: 3.0 #: .. versionadded:: 3.0
self.originalname = originalname self.originalname = originalname
@classmethod
def from_parent(cls, parent, **kw):
return cls._create(parent=parent, **kw)
def _initrequest(self): def _initrequest(self):
self.funcargs = {} self.funcargs = {}
self._request = fixtures.FixtureRequest(self) self._request = fixtures.FixtureRequest(self)

View File

@ -23,7 +23,7 @@ def pytest_pycollect_makeitem(collector, name, obj):
except Exception: except Exception:
return return
# yes, so let's collect it # yes, so let's collect it
return UnitTestCase(name, parent=collector) return UnitTestCase.from_parent(collector, name=name, obj=obj)
class UnitTestCase(Class): class UnitTestCase(Class):
@ -51,7 +51,7 @@ class UnitTestCase(Class):
if not getattr(x, "__test__", True): if not getattr(x, "__test__", True):
continue continue
funcobj = getimfunc(x) funcobj = getimfunc(x)
yield TestCaseFunction(name, parent=self, callobj=funcobj) yield TestCaseFunction.from_parent(self, name=name, callobj=funcobj)
foundsomething = True foundsomething = True
if not foundsomething: if not foundsomething:
@ -59,7 +59,8 @@ class UnitTestCase(Class):
if runtest is not None: if runtest is not None:
ut = sys.modules.get("twisted.trial.unittest", None) ut = sys.modules.get("twisted.trial.unittest", None)
if ut is None or runtest != ut.TestCase.runTest: if ut is None or runtest != ut.TestCase.runTest:
yield TestCaseFunction("runTest", parent=self) # TODO: callobj consistency
yield TestCaseFunction.from_parent(self, name="runTest")
def _inject_setup_teardown_fixtures(self, cls): def _inject_setup_teardown_fixtures(self, cls):
"""Injects a hidden auto-use fixture to invoke setUpClass/setup_method and corresponding """Injects a hidden auto-use fixture to invoke setUpClass/setup_method and corresponding
@ -190,20 +191,21 @@ class TestCaseFunction(Function):
def _handle_skip(self): def _handle_skip(self):
# implements the skipping machinery (see #2137) # implements the skipping machinery (see #2137)
# analog to pythons Lib/unittest/case.py:run # analog to pythons Lib/unittest/case.py:run
testMethod = getattr(self._testcase, self._testcase._testMethodName) test_method = getattr(self._testcase, self._testcase._testMethodName)
if getattr(self._testcase.__class__, "__unittest_skip__", False) or getattr( if getattr(self._testcase.__class__, "__unittest_skip__", False) or getattr(
testMethod, "__unittest_skip__", False test_method, "__unittest_skip__", False
): ):
# If the class or method was skipped. # If the class or method was skipped.
skip_why = getattr( skip_why = getattr(
self._testcase.__class__, "__unittest_skip_why__", "" self._testcase.__class__, "__unittest_skip_why__", ""
) or getattr(testMethod, "__unittest_skip_why__", "") ) or getattr(test_method, "__unittest_skip_why__", "")
self._testcase._addSkip(self, self._testcase, skip_why) self._testcase._addSkip(self, self._testcase, skip_why)
return True return True
return False return False
def runtest(self): def runtest(self):
if self.config.pluginmanager.get_plugin("pdbinvoke") is None: if self.config.pluginmanager.get_plugin("pdbinvoke") is None:
# TODO: move testcase reporter into separate class, this shouldnt be on item
self._testcase(result=self) self._testcase(result=self)
else: else:
# disables tearDown and cleanups for post mortem debugging (see #1890) # disables tearDown and cleanups for post mortem debugging (see #1890)

View File

@ -1,5 +1,8 @@
import inspect
import pytest import pytest
from _pytest import deprecated from _pytest import deprecated
from _pytest import nodes
@pytest.mark.filterwarnings("default") @pytest.mark.filterwarnings("default")
@ -73,3 +76,17 @@ def test_warn_about_imminent_junit_family_default_change(testdir, junit_family):
result.stdout.no_fnmatch_line(warning_msg) result.stdout.no_fnmatch_line(warning_msg)
else: else:
result.stdout.fnmatch_lines([warning_msg]) result.stdout.fnmatch_lines([warning_msg])
def test_node_direct_ctor_warning():
class MockConfig:
pass
ms = MockConfig()
with pytest.warns(
DeprecationWarning,
match="direct construction of .* has been deprecated, please use .*.from_parent",
) as w:
nodes.Node(name="test", config=ms, session=ms, nodeid="None")
assert w[0].lineno == inspect.currentframe().f_lineno - 1
assert w[0].filename == __file__

View File

@ -281,10 +281,10 @@ class TestFunction:
from _pytest.fixtures import FixtureManager from _pytest.fixtures import FixtureManager
config = testdir.parseconfigure() config = testdir.parseconfigure()
session = testdir.Session(config) session = testdir.Session.from_config(config)
session._fixturemanager = FixtureManager(session) session._fixturemanager = FixtureManager(session)
return pytest.Function(config=config, parent=session, **kwargs) return pytest.Function.from_parent(config=config, parent=session, **kwargs)
def test_function_equality(self, testdir, tmpdir): def test_function_equality(self, testdir, tmpdir):
def func1(): def func1():
@ -1024,7 +1024,7 @@ class TestReportInfo:
return "ABCDE", 42, "custom" return "ABCDE", 42, "custom"
def pytest_pycollect_makeitem(collector, name, obj): def pytest_pycollect_makeitem(collector, name, obj):
if name == "test_func": if name == "test_func":
return MyFunction(name, parent=collector) return MyFunction.from_parent(name=name, parent=collector)
""" """
) )
item = testdir.getitem("def test_func(): pass") item = testdir.getitem("def test_func(): pass")

View File

@ -10,7 +10,7 @@ class TestOEJSKITSpecials:
import pytest import pytest
def pytest_pycollect_makeitem(collector, name, obj): def pytest_pycollect_makeitem(collector, name, obj):
if name == "MyClass": if name == "MyClass":
return MyCollector(name, parent=collector) return MyCollector.from_parent(collector, name=name)
class MyCollector(pytest.Collector): class MyCollector(pytest.Collector):
def reportinfo(self): def reportinfo(self):
return self.fspath, 3, "xyz" return self.fspath, 3, "xyz"
@ -40,7 +40,7 @@ class TestOEJSKITSpecials:
import pytest import pytest
def pytest_pycollect_makeitem(collector, name, obj): def pytest_pycollect_makeitem(collector, name, obj):
if name == "MyClass": if name == "MyClass":
return MyCollector(name, parent=collector) return MyCollector.from_parent(collector, name=name)
class MyCollector(pytest.Collector): class MyCollector(pytest.Collector):
def reportinfo(self): def reportinfo(self):
return self.fspath, 3, "xyz" return self.fspath, 3, "xyz"

View File

@ -30,7 +30,7 @@ class TestMetafunc:
names = fixtures.getfuncargnames(func) names = fixtures.getfuncargnames(func)
fixtureinfo = FixtureInfo(names) fixtureinfo = FixtureInfo(names)
definition = DefinitionMock(func) definition = DefinitionMock._create(func)
return python.Metafunc(definition, fixtureinfo, config) return python.Metafunc(definition, fixtureinfo, config)
def test_no_funcargs(self, testdir): def test_no_funcargs(self, testdir):

View File

@ -75,7 +75,7 @@ class TestCollector:
pass pass
def pytest_collect_file(path, parent): def pytest_collect_file(path, parent):
if path.ext == ".xxx": if path.ext == ".xxx":
return CustomFile(path, parent=parent) return CustomFile.from_parent(fspath=path, parent=parent)
""" """
) )
node = testdir.getpathnode(hello) node = testdir.getpathnode(hello)
@ -446,7 +446,7 @@ class TestSession:
p.move(target) p.move(target)
subdir.chdir() subdir.chdir()
config = testdir.parseconfig(p.basename) config = testdir.parseconfig(p.basename)
rcol = Session(config=config) rcol = Session.from_config(config)
assert rcol.fspath == subdir assert rcol.fspath == subdir
parts = rcol._parsearg(p.basename) parts = rcol._parsearg(p.basename)
@ -463,7 +463,7 @@ class TestSession:
# XXX migrate to collectonly? (see below) # XXX migrate to collectonly? (see below)
config = testdir.parseconfig(id) config = testdir.parseconfig(id)
topdir = testdir.tmpdir topdir = testdir.tmpdir
rcol = Session(config) rcol = Session.from_config(config)
assert topdir == rcol.fspath assert topdir == rcol.fspath
# rootid = rcol.nodeid # rootid = rcol.nodeid
# root2 = rcol.perform_collect([rcol.nodeid], genitems=False)[0] # root2 = rcol.perform_collect([rcol.nodeid], genitems=False)[0]

View File

@ -962,7 +962,11 @@ def test_mark_expressions_no_smear(testdir):
def test_addmarker_order(): def test_addmarker_order():
node = Node("Test", config=mock.Mock(), session=mock.Mock(), nodeid="Test") session = mock.Mock()
session.own_markers = []
session.parent = None
session.nodeid = ""
node = Node.from_parent(session, name="Test")
node.add_marker("foo") node.add_marker("foo")
node.add_marker("bar") node.add_marker("bar")
node.add_marker("baz", append=False) node.add_marker("baz", append=False)

View File

@ -122,7 +122,7 @@ class TestPytestPluginInteractions:
def test_hook_proxy(self, testdir): def test_hook_proxy(self, testdir):
"""Test the gethookproxy function(#2016)""" """Test the gethookproxy function(#2016)"""
config = testdir.parseconfig() config = testdir.parseconfig()
session = Session(config) session = Session.from_config(config)
testdir.makepyfile(**{"tests/conftest.py": "", "tests/subdir/conftest.py": ""}) testdir.makepyfile(**{"tests/conftest.py": "", "tests/subdir/conftest.py": ""})
conftest1 = testdir.tmpdir.join("tests/conftest.py") conftest1 = testdir.tmpdir.join("tests/conftest.py")