Skip to content

Commit c99c7d0

Browse files
deprecate direct node construction and introduce Node.from_parent
1 parent 886b8d2 commit c99c7d0

15 files changed

+108
-35
lines changed

changelog/5975.deprecation.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Deprecate using direct constructors for ``Nodes``.
2+
3+
Instead they are new constructed via ``Node.from_parent``.
4+
5+
This transitional mechanism enables us to detangle the very intensely
6+
entangled ``Node`` relationships by enforcing more controlled creation/configruation patterns.

src/_pytest/deprecated.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
in case of warnings which need to format their messages.
1010
"""
1111
from _pytest.warning_types import PytestDeprecationWarning
12+
from _pytest.warning_types import UnformattedWarning
1213

1314
# set of plugins which have been integrated into the core; we use this list to ignore
1415
# them during registration to avoid conflicts
@@ -35,6 +36,11 @@
3536
"as a keyword argument instead."
3637
)
3738

39+
NODE_USE_FROM_PARENT = UnformattedWarning(
40+
PytestDeprecationWarning,
41+
"direct construction of {name} has been deprecated, please use {name}.from_parent",
42+
)
43+
3844
JUNIT_XML_DEFAULT_FAMILY = PytestDeprecationWarning(
3945
"The 'junit_family' default value will change to 'xunit2' in pytest 6.0.\n"
4046
"Add 'junit_family=legacy' to your pytest.ini file to silence this warning and make your suite compatible."

src/_pytest/doctest.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,9 @@ def pytest_collect_file(path, parent):
108108
config = parent.config
109109
if path.ext == ".py":
110110
if config.option.doctestmodules and not _is_setup_py(config, path, parent):
111-
return DoctestModule(path, parent)
111+
return DoctestModule.from_parent(parent, fspath=path)
112112
elif _is_doctest(config, path, parent):
113-
return DoctestTextfile(path, parent)
113+
return DoctestTextfile.from_parent(parent, fspath=path)
114114

115115

116116
def _is_setup_py(config, path, parent):
@@ -215,6 +215,10 @@ def __init__(self, name, parent, runner=None, dtest=None):
215215
self.obj = None
216216
self.fixture_request = None
217217

218+
@classmethod
219+
def from_parent(cls, parent, *, name, runner, dtest):
220+
return cls._create(name=name, parent=parent, runner=runner, dtest=dtest)
221+
218222
def setup(self):
219223
if self.dtest is not None:
220224
self.fixture_request = _setup_fixtures(self)
@@ -370,7 +374,9 @@ def collect(self):
370374
parser = doctest.DocTestParser()
371375
test = parser.get_doctest(text, globs, name, filename, 0)
372376
if test.examples:
373-
yield DoctestItem(test.name, self, runner, test)
377+
yield DoctestItem.from_parent(
378+
self, name=test.name, runner=runner, dtest=test
379+
)
374380

375381

376382
def _check_all_skipped(test):
@@ -467,7 +473,9 @@ def _find(self, tests, obj, name, module, source_lines, globs, seen):
467473

468474
for test in finder.find(module, module.__name__):
469475
if test.examples: # skip empty doctests
470-
yield DoctestItem(test.name, self, runner, test)
476+
yield DoctestItem.from_parent(
477+
self, name=test.name, runner=runner, dtest=test
478+
)
471479

472480

473481
def _setup_fixtures(doctest_item):

src/_pytest/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def pytest_addoption(parser):
184184

185185
def wrap_session(config, doit):
186186
"""Skeleton command line program"""
187-
session = Session(config)
187+
session = Session.from_config(config)
188188
session.exitstatus = ExitCode.OK
189189
initstate = 0
190190
try:
@@ -395,6 +395,10 @@ def __init__(self, config):
395395

396396
self.config.pluginmanager.register(self, name="session")
397397

398+
@classmethod
399+
def from_config(cls, config):
400+
return cls._create(config)
401+
398402
def __repr__(self):
399403
return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % (
400404
self.__class__.__name__,

src/_pytest/nodes.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from _pytest.compat import cached_property
1919
from _pytest.compat import getfslineno
2020
from _pytest.config import Config
21+
from _pytest.deprecated import NODE_USE_FROM_PARENT
2122
from _pytest.fixtures import FixtureDef
2223
from _pytest.fixtures import FixtureLookupError
2324
from _pytest.fixtures import FixtureLookupErrorRepr
@@ -73,7 +74,16 @@ def ischildnode(baseid, nodeid):
7374
return node_parts[: len(base_parts)] == base_parts
7475

7576

76-
class Node:
77+
class NodeMeta(type):
78+
def __call__(self, *k, **kw):
79+
warnings.warn(NODE_USE_FROM_PARENT.format(name=self.__name__), stacklevel=2)
80+
return super().__call__(*k, **kw)
81+
82+
def _create(self, *k, **kw):
83+
return super().__call__(*k, **kw)
84+
85+
86+
class Node(metaclass=NodeMeta):
7787
""" base class for Collector and Item the test collection tree.
7888
Collector subclasses have children, Items are terminal nodes."""
7989

@@ -133,6 +143,10 @@ def __init__(
133143
if self.name != "()":
134144
self._nodeid += "::" + self.name
135145

146+
@classmethod
147+
def from_parent(cls, parent, *, name):
148+
return cls._create(parent=parent, name=name)
149+
136150
@property
137151
def ihook(self):
138152
""" fspath sensitive hook proxy used to call pytest hooks"""
@@ -418,6 +432,10 @@ def __init__(
418432

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

435+
@classmethod
436+
def from_parent(cls, parent, *, fspath):
437+
return cls._create(parent=parent, fspath=fspath)
438+
421439

422440
class File(FSCollector):
423441
""" base class for collecting tests from a file. """

src/_pytest/pytester.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ def getnode(self, config, arg):
744744
:param arg: a :py:class:`py.path.local` instance of the file
745745
746746
"""
747-
session = Session(config)
747+
session = Session.from_config(config)
748748
assert "::" not in str(arg)
749749
p = py.path.local(arg)
750750
config.hook.pytest_sessionstart(session=session)
@@ -762,7 +762,7 @@ def getpathnode(self, path):
762762
763763
"""
764764
config = self.parseconfigure(path)
765-
session = Session(config)
765+
session = Session.from_config(config)
766766
x = session.fspath.bestrelpath(path)
767767
config.hook.pytest_sessionstart(session=session)
768768
res = session.perform_collect([x], genitems=False)[0]

src/_pytest/python.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,8 @@ def path_matches_patterns(path, patterns):
190190

191191
def pytest_pycollect_makemodule(path, parent):
192192
if path.basename == "__init__.py":
193-
return Package(path, parent)
194-
return Module(path, parent)
193+
return Package.from_parent(parent, fspath=path)
194+
return Module.from_parent(parent, fspath=path)
195195

196196

197197
@hookimpl(hookwrapper=True)
@@ -203,7 +203,7 @@ def pytest_pycollect_makeitem(collector, name, obj):
203203
# nothing was collected elsewhere, let's do it here
204204
if safe_isclass(obj):
205205
if collector.istestclass(obj, name):
206-
outcome.force_result(Class(name, parent=collector))
206+
outcome.force_result(Class.from_parent(collector, name=name, obj=obj))
207207
elif collector.istestfunction(obj, name):
208208
# mock seems to store unbound methods (issue473), normalize it
209209
obj = getattr(obj, "__func__", obj)
@@ -222,7 +222,7 @@ def pytest_pycollect_makeitem(collector, name, obj):
222222
)
223223
elif getattr(obj, "__test__", True):
224224
if is_generator(obj):
225-
res = Function(name, parent=collector)
225+
res = Function.from_parent(collector, name=name)
226226
reason = "yield tests were removed in pytest 4.0 - {name} will be ignored".format(
227227
name=name
228228
)
@@ -381,7 +381,7 @@ def _genfunctions(self, name, funcobj):
381381
cls = clscol and clscol.obj or None
382382
fm = self.session._fixturemanager
383383

384-
definition = FunctionDefinition(name=name, parent=self, callobj=funcobj)
384+
definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj)
385385
fixtureinfo = fm.getfixtureinfo(definition, funcobj, cls)
386386

387387
metafunc = Metafunc(
@@ -396,7 +396,7 @@ def _genfunctions(self, name, funcobj):
396396
self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc))
397397

398398
if not metafunc._calls:
399-
yield Function(name, parent=self, fixtureinfo=fixtureinfo)
399+
yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo)
400400
else:
401401
# add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs
402402
fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm)
@@ -408,9 +408,9 @@ def _genfunctions(self, name, funcobj):
408408

409409
for callspec in metafunc._calls:
410410
subname = "{}[{}]".format(name, callspec.id)
411-
yield Function(
411+
yield Function.from_parent(
412+
self,
412413
name=subname,
413-
parent=self,
414414
callspec=callspec,
415415
callobj=funcobj,
416416
fixtureinfo=fixtureinfo,
@@ -626,7 +626,7 @@ def collect(self):
626626
if init_module.check(file=1) and path_matches_patterns(
627627
init_module, self.config.getini("python_files")
628628
):
629-
yield Module(init_module, self)
629+
yield Module.from_parent(self, fspath=init_module)
630630
pkg_prefixes = set()
631631
for path in this_path.visit(rec=self._recurse, bf=True, sort=True):
632632
# 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):
677677
class Class(PyCollector):
678678
""" Collector for test methods. """
679679

680+
@classmethod
681+
def from_parent(cls, parent, *, name, obj=None):
682+
return cls._create(name=name, parent=parent)
683+
680684
def collect(self):
681685
if not safe_getattr(self.obj, "__test__", True):
682686
return []
@@ -702,7 +706,7 @@ def collect(self):
702706
self._inject_setup_class_fixture()
703707
self._inject_setup_method_fixture()
704708

705-
return [Instance(name="()", parent=self)]
709+
return [Instance.from_parent(self, name="()")]
706710

707711
def _inject_setup_class_fixture(self):
708712
"""Injects a hidden autouse, class scoped fixture into the collected class object
@@ -1454,6 +1458,10 @@ def __init__(
14541458
#: .. versionadded:: 3.0
14551459
self.originalname = originalname
14561460

1461+
@classmethod
1462+
def from_parent(cls, parent, **kw):
1463+
return cls._create(parent=parent, **kw)
1464+
14571465
def _initrequest(self):
14581466
self.funcargs = {}
14591467
self._request = fixtures.FixtureRequest(self)

src/_pytest/unittest.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def pytest_pycollect_makeitem(collector, name, obj):
2323
except Exception:
2424
return
2525
# yes, so let's collect it
26-
return UnitTestCase(name, parent=collector)
26+
return UnitTestCase.from_parent(collector, name=name, obj=obj)
2727

2828

2929
class UnitTestCase(Class):
@@ -51,15 +51,16 @@ def collect(self):
5151
if not getattr(x, "__test__", True):
5252
continue
5353
funcobj = getimfunc(x)
54-
yield TestCaseFunction(name, parent=self, callobj=funcobj)
54+
yield TestCaseFunction.from_parent(self, name=name, callobj=funcobj)
5555
foundsomething = True
5656

5757
if not foundsomething:
5858
runtest = getattr(self.obj, "runTest", None)
5959
if runtest is not None:
6060
ut = sys.modules.get("twisted.trial.unittest", None)
6161
if ut is None or runtest != ut.TestCase.runTest:
62-
yield TestCaseFunction("runTest", parent=self)
62+
# TODO: callobj consistency
63+
yield TestCaseFunction.from_parent(self, name="runTest")
6364

6465
def _inject_setup_teardown_fixtures(self, cls):
6566
"""Injects a hidden auto-use fixture to invoke setUpClass/setup_method and corresponding
@@ -190,20 +191,21 @@ def stopTest(self, testcase):
190191
def _handle_skip(self):
191192
# implements the skipping machinery (see #2137)
192193
# analog to pythons Lib/unittest/case.py:run
193-
testMethod = getattr(self._testcase, self._testcase._testMethodName)
194+
test_method = getattr(self._testcase, self._testcase._testMethodName)
194195
if getattr(self._testcase.__class__, "__unittest_skip__", False) or getattr(
195-
testMethod, "__unittest_skip__", False
196+
test_method, "__unittest_skip__", False
196197
):
197198
# If the class or method was skipped.
198199
skip_why = getattr(
199200
self._testcase.__class__, "__unittest_skip_why__", ""
200-
) or getattr(testMethod, "__unittest_skip_why__", "")
201+
) or getattr(test_method, "__unittest_skip_why__", "")
201202
self._testcase._addSkip(self, self._testcase, skip_why)
202203
return True
203204
return False
204205

205206
def runtest(self):
206207
if self.config.pluginmanager.get_plugin("pdbinvoke") is None:
208+
# TODO: move testcase reporter into separate class, this shouldnt be on item
207209
self._testcase(result=self)
208210
else:
209211
# disables tearDown and cleanups for post mortem debugging (see #1890)

testing/deprecated_test.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import inspect
2+
13
import pytest
24
from _pytest import deprecated
5+
from _pytest import nodes
36

47

58
@pytest.mark.filterwarnings("default")
@@ -73,3 +76,17 @@ def test_foo():
7376
result.stdout.no_fnmatch_line(warning_msg)
7477
else:
7578
result.stdout.fnmatch_lines([warning_msg])
79+
80+
81+
def test_node_direct_ctor_warning():
82+
class MockConfig:
83+
pass
84+
85+
ms = MockConfig()
86+
with pytest.warns(
87+
DeprecationWarning,
88+
match="direct construction of .* has been deprecated, please use .*.from_parent",
89+
) as w:
90+
nodes.Node(name="test", config=ms, session=ms, nodeid="None")
91+
assert w[0].lineno == inspect.currentframe().f_lineno - 1
92+
assert w[0].filename == __file__

testing/python/collect.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,10 +281,10 @@ def make_function(testdir, **kwargs):
281281
from _pytest.fixtures import FixtureManager
282282

283283
config = testdir.parseconfigure()
284-
session = testdir.Session(config)
284+
session = testdir.Session.from_config(config)
285285
session._fixturemanager = FixtureManager(session)
286286

287-
return pytest.Function(config=config, parent=session, **kwargs)
287+
return pytest.Function.from_parent(config=config, parent=session, **kwargs)
288288

289289
def test_function_equality(self, testdir, tmpdir):
290290
def func1():
@@ -1024,7 +1024,7 @@ def reportinfo(self):
10241024
return "ABCDE", 42, "custom"
10251025
def pytest_pycollect_makeitem(collector, name, obj):
10261026
if name == "test_func":
1027-
return MyFunction(name, parent=collector)
1027+
return MyFunction.from_parent(name=name, parent=collector)
10281028
"""
10291029
)
10301030
item = testdir.getitem("def test_func(): pass")

testing/python/integration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def test_funcarg_non_pycollectobj(self, testdir): # rough jstests usage
1010
import pytest
1111
def pytest_pycollect_makeitem(collector, name, obj):
1212
if name == "MyClass":
13-
return MyCollector(name, parent=collector)
13+
return MyCollector.from_parent(collector, name=name)
1414
class MyCollector(pytest.Collector):
1515
def reportinfo(self):
1616
return self.fspath, 3, "xyz"
@@ -40,7 +40,7 @@ def test_autouse_fixture(self, testdir): # rough jstests usage
4040
import pytest
4141
def pytest_pycollect_makeitem(collector, name, obj):
4242
if name == "MyClass":
43-
return MyCollector(name, parent=collector)
43+
return MyCollector.from_parent(collector, name=name)
4444
class MyCollector(pytest.Collector):
4545
def reportinfo(self):
4646
return self.fspath, 3, "xyz"

testing/python/metafunc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class DefinitionMock(python.FunctionDefinition):
3030

3131
names = fixtures.getfuncargnames(func)
3232
fixtureinfo = FixtureInfo(names)
33-
definition = DefinitionMock(func)
33+
definition = DefinitionMock._create(func)
3434
return python.Metafunc(definition, fixtureinfo, config)
3535

3636
def test_no_funcargs(self, testdir):

0 commit comments

Comments
 (0)