Skip to content

Commit b41acae

Browse files
committed
Switch to new-style pluggy hook wrappers
Fix #11122.
1 parent 7008385 commit b41acae

34 files changed

+335
-276
lines changed

changelog/11122.improvement.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
``pluggy>=1.2.0`` is now required.
2+
3+
pytest now uses "new-style" hook wrappers internally, available since pluggy 1.2.0.
4+
See `pluggy's 1.2.0 changelog <https://pluggy.readthedocs.io/en/latest/changelog.html#pluggy-1-2-0-2023-06-21>`_ and the :ref:`updated docs <hookwrapper>` for details.
5+
6+
Plugins which want to use new-style wrappers can do so if they require this version of pytest or later.

doc/en/example/simple.rst

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -808,11 +808,10 @@ case we just write some information out to a ``failures`` file:
808808
import pytest
809809
810810
811-
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
811+
@pytest.hookimpl(wrapper=True, tryfirst=True)
812812
def pytest_runtest_makereport(item, call):
813813
# execute all other hooks to obtain the report object
814-
outcome = yield
815-
rep = outcome.get_result()
814+
rep = yield
816815
817816
# we only look at actual failing test calls, not setup/teardown
818817
if rep.when == "call" and rep.failed:
@@ -826,6 +825,8 @@ case we just write some information out to a ``failures`` file:
826825
827826
f.write(rep.nodeid + extra + "\n")
828827
828+
return rep
829+
829830
830831
if you then have failing tests:
831832

@@ -899,16 +900,17 @@ here is a little example implemented via a local plugin:
899900
phase_report_key = StashKey[Dict[str, CollectReport]]()
900901
901902
902-
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
903+
@pytest.hookimpl(wrapper=True, tryfirst=True)
903904
def pytest_runtest_makereport(item, call):
904905
# execute all other hooks to obtain the report object
905-
outcome = yield
906-
rep = outcome.get_result()
906+
rep = yield
907907
908908
# store test results for each phase of a call, which can
909909
# be "setup", "call", "teardown"
910910
item.stash.setdefault(phase_report_key, {})[rep.when] = rep
911911
912+
return rep
913+
912914
913915
@pytest.fixture
914916
def something(request):

doc/en/how-to/writing_hook_functions.rst

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ The remaining hook functions will not be called in this case.
5656

5757
.. _`hookwrapper`:
5858

59-
hookwrapper: executing around other hooks
59+
hook wrappers: executing around other hooks
6060
-------------------------------------------------
6161

6262
.. currentmodule:: _pytest.core
@@ -69,10 +69,8 @@ which yields exactly once. When pytest invokes hooks it first executes
6969
hook wrappers and passes the same arguments as to the regular hooks.
7070

7171
At the yield point of the hook wrapper pytest will execute the next hook
72-
implementations and return their result to the yield point in the form of
73-
a :py:class:`Result <pluggy._Result>` instance which encapsulates a result or
74-
exception info. The yield point itself will thus typically not raise
75-
exceptions (unless there are bugs).
72+
implementations and return their result to the yield point, or will
73+
propagate an exception if they raised.
7674

7775
Here is an example definition of a hook wrapper:
7876

@@ -81,26 +79,35 @@ Here is an example definition of a hook wrapper:
8179
import pytest
8280
8381
84-
@pytest.hookimpl(hookwrapper=True)
82+
@pytest.hookimpl(wrapper=True)
8583
def pytest_pyfunc_call(pyfuncitem):
8684
do_something_before_next_hook_executes()
8785
88-
outcome = yield
89-
# outcome.excinfo may be None or a (cls, val, tb) tuple
86+
# If the outcome is an exception, will raise the exception.
87+
res = yield
9088
91-
res = outcome.get_result() # will raise if outcome was exception
89+
new_res = post_process_result(res)
9290
93-
post_process_result(res)
91+
# Override the return value to the plugin system.
92+
return new_res
9493
95-
outcome.force_result(new_res) # to override the return value to the plugin system
94+
The hook wrapper needs to return a result for the hook, or raise an exception.
9695

97-
Note that hook wrappers don't return results themselves, they merely
98-
perform tracing or other side effects around the actual hook implementations.
99-
If the result of the underlying hook is a mutable object, they may modify
100-
that result but it's probably better to avoid it.
96+
In many cases, the wrapper only needs to perform tracing or other side effects
97+
around the actual hook implementations, in which case it can return the result
98+
value of the ``yield``. The simplest (though useless) hook wrapper is
99+
``return (yield)``.
100+
101+
In other cases, the wrapper wants the adjust or adapt the result, in which case
102+
it can return a new value. If the result of the underlying hook is a mutable
103+
object, the wrapper may modify that result, but it's probably better to avoid it.
104+
105+
If the hook implementation failed with an exception, the wrapper can handle that
106+
exception using a ``try-catch-finally`` around the ``yield``, by propagating it,
107+
supressing it, or raising a different exception entirely.
101108

102109
For more information, consult the
103-
:ref:`pluggy documentation about hookwrappers <pluggy:hookwrappers>`.
110+
:ref:`pluggy documentation about hook wrappers <pluggy:hookwrappers>`.
104111

105112
.. _plugin-hookorder:
106113

@@ -130,11 +137,14 @@ after others, i.e. the position in the ``N``-sized list of functions:
130137
131138
132139
# Plugin 3
133-
@pytest.hookimpl(hookwrapper=True)
140+
@pytest.hookimpl(wrapper=True)
134141
def pytest_collection_modifyitems(items):
135142
# will execute even before the tryfirst one above!
136-
outcome = yield
137-
# will execute after all non-hookwrappers executed
143+
try:
144+
return (yield)
145+
finally:
146+
# will execute after all non-wrappers executed
147+
...
138148
139149
Here is the order of execution:
140150

@@ -149,12 +159,11 @@ Here is the order of execution:
149159
Plugin1).
150160

151161
4. Plugin3's pytest_collection_modifyitems then executing the code after the yield
152-
point. The yield receives a :py:class:`Result <pluggy._Result>` instance which encapsulates
153-
the result from calling the non-wrappers. Wrappers shall not modify the result.
162+
point. The yield receives the result from calling the non-wrappers, or raises
163+
an exception if the non-wrappers raised.
154164

155-
It's possible to use ``tryfirst`` and ``trylast`` also in conjunction with
156-
``hookwrapper=True`` in which case it will influence the ordering of hookwrappers
157-
among each other.
165+
It's possible to use ``tryfirst`` and ``trylast`` also on hook wrappers
166+
in which case it will influence the ordering of hook wrappers among each other.
158167

159168

160169
Declaring new hooks

doc/en/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
pallets-sphinx-themes
2-
pluggy>=1.0
2+
pluggy>=1.2.0
33
pygments-pytest>=2.3.0
44
sphinx-removed-in>=0.2.0
55
sphinx>=5,<6

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ py_modules = py
4646
install_requires =
4747
iniconfig
4848
packaging
49-
pluggy>=0.12,<2.0
49+
pluggy>=1.2.0,<2.0
5050
colorama;sys_platform=="win32"
5151
exceptiongroup>=1.0.0rc8;python_version<"3.11"
5252
tomli>=1.0.0;python_version<"3.11"

src/_pytest/assertion/__init__.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ def pytest_collection(session: "Session") -> None:
112112
assertstate.hook.set_session(session)
113113

114114

115-
@hookimpl(tryfirst=True, hookwrapper=True)
116-
def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:
115+
@hookimpl(wrapper=True, tryfirst=True)
116+
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
117117
"""Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks.
118118
119119
The rewrite module will use util._reprcompare if it exists to use custom
@@ -162,10 +162,11 @@ def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None:
162162

163163
util._assertion_pass = call_assertion_pass_hook
164164

165-
yield
166-
167-
util._reprcompare, util._assertion_pass = saved_assert_hooks
168-
util._config = None
165+
try:
166+
return (yield)
167+
finally:
168+
util._reprcompare, util._assertion_pass = saved_assert_hooks
169+
util._config = None
169170

170171

171172
def pytest_sessionfinish(session: "Session") -> None:

src/_pytest/cacheprovider.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,12 @@ def __init__(self, lfplugin: "LFPlugin") -> None:
217217
self.lfplugin = lfplugin
218218
self._collected_at_least_one_failure = False
219219

220-
@hookimpl(hookwrapper=True)
221-
def pytest_make_collect_report(self, collector: nodes.Collector):
220+
@hookimpl(wrapper=True)
221+
def pytest_make_collect_report(
222+
self, collector: nodes.Collector
223+
) -> Generator[None, CollectReport, CollectReport]:
224+
res = yield
222225
if isinstance(collector, (Session, Package)):
223-
out = yield
224-
res: CollectReport = out.get_result()
225-
226226
# Sort any lf-paths to the beginning.
227227
lf_paths = self.lfplugin._last_failed_paths
228228

@@ -240,19 +240,16 @@ def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool:
240240
key=sort_key,
241241
reverse=True,
242242
)
243-
return
244243

245244
elif isinstance(collector, File):
246245
if collector.path in self.lfplugin._last_failed_paths:
247-
out = yield
248-
res = out.get_result()
249246
result = res.result
250247
lastfailed = self.lfplugin.lastfailed
251248

252249
# Only filter with known failures.
253250
if not self._collected_at_least_one_failure:
254251
if not any(x.nodeid in lastfailed for x in result):
255-
return
252+
return res
256253
self.lfplugin.config.pluginmanager.register(
257254
LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip"
258255
)
@@ -268,8 +265,8 @@ def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool:
268265
# Keep all sub-collectors.
269266
or isinstance(x, nodes.Collector)
270267
]
271-
return
272-
yield
268+
269+
return res
273270

274271

275272
class LFPluginCollSkipfiles:
@@ -342,14 +339,14 @@ def pytest_collectreport(self, report: CollectReport) -> None:
342339
else:
343340
self.lastfailed[report.nodeid] = True
344341

345-
@hookimpl(hookwrapper=True, tryfirst=True)
342+
@hookimpl(wrapper=True, tryfirst=True)
346343
def pytest_collection_modifyitems(
347344
self, config: Config, items: List[nodes.Item]
348345
) -> Generator[None, None, None]:
349-
yield
346+
res = yield
350347

351348
if not self.active:
352-
return
349+
return res
353350

354351
if self.lastfailed:
355352
previously_failed = []
@@ -394,6 +391,8 @@ def pytest_collection_modifyitems(
394391
else:
395392
self._report_status += "not deselecting items."
396393

394+
return res
395+
397396
def pytest_sessionfinish(self, session: Session) -> None:
398397
config = self.config
399398
if config.getoption("cacheshow") or hasattr(config, "workerinput"):
@@ -414,11 +413,11 @@ def __init__(self, config: Config) -> None:
414413
assert config.cache is not None
415414
self.cached_nodeids = set(config.cache.get("cache/nodeids", []))
416415

417-
@hookimpl(hookwrapper=True, tryfirst=True)
416+
@hookimpl(wrapper=True, tryfirst=True)
418417
def pytest_collection_modifyitems(
419418
self, items: List[nodes.Item]
420419
) -> Generator[None, None, None]:
421-
yield
420+
res = yield
422421

423422
if self.active:
424423
new_items: Dict[str, nodes.Item] = {}
@@ -436,6 +435,8 @@ def pytest_collection_modifyitems(
436435
else:
437436
self.cached_nodeids.update(item.nodeid for item in items)
438437

438+
return res
439+
439440
def _get_increasing_order(self, items: Iterable[nodes.Item]) -> List[nodes.Item]:
440441
return sorted(items, key=lambda item: item.path.stat().st_mtime, reverse=True) # type: ignore[no-any-return]
441442

src/_pytest/capture.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from _pytest.nodes import Collector
3737
from _pytest.nodes import File
3838
from _pytest.nodes import Item
39+
from _pytest.reports import CollectReport
3940

4041
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
4142

@@ -130,8 +131,8 @@ def _reopen_stdio(f, mode):
130131
sys.stderr = _reopen_stdio(sys.stderr, "wb")
131132

132133

133-
@hookimpl(hookwrapper=True)
134-
def pytest_load_initial_conftests(early_config: Config):
134+
@hookimpl(wrapper=True)
135+
def pytest_load_initial_conftests(early_config: Config) -> Generator[None, None, None]:
135136
ns = early_config.known_args_namespace
136137
if ns.capture == "fd":
137138
_windowsconsoleio_workaround(sys.stdout)
@@ -145,12 +146,16 @@ def pytest_load_initial_conftests(early_config: Config):
145146

146147
# Finally trigger conftest loading but while capturing (issue #93).
147148
capman.start_global_capturing()
148-
outcome = yield
149-
capman.suspend_global_capture()
150-
if outcome.excinfo is not None:
149+
try:
150+
try:
151+
yield
152+
finally:
153+
capman.suspend_global_capture()
154+
except BaseException:
151155
out, err = capman.read_global_capture()
152156
sys.stdout.write(out)
153157
sys.stderr.write(err)
158+
raise
154159

155160

156161
# IO Helpers.
@@ -841,41 +846,45 @@ def item_capture(self, when: str, item: Item) -> Generator[None, None, None]:
841846
self.deactivate_fixture()
842847
self.suspend_global_capture(in_=False)
843848

844-
out, err = self.read_global_capture()
845-
item.add_report_section(when, "stdout", out)
846-
item.add_report_section(when, "stderr", err)
849+
out, err = self.read_global_capture()
850+
item.add_report_section(when, "stdout", out)
851+
item.add_report_section(when, "stderr", err)
847852

848853
# Hooks
849854

850-
@hookimpl(hookwrapper=True)
851-
def pytest_make_collect_report(self, collector: Collector):
855+
@hookimpl(wrapper=True)
856+
def pytest_make_collect_report(
857+
self, collector: Collector
858+
) -> Generator[None, CollectReport, CollectReport]:
852859
if isinstance(collector, File):
853860
self.resume_global_capture()
854-
outcome = yield
855-
self.suspend_global_capture()
861+
try:
862+
rep = yield
863+
finally:
864+
self.suspend_global_capture()
856865
out, err = self.read_global_capture()
857-
rep = outcome.get_result()
858866
if out:
859867
rep.sections.append(("Captured stdout", out))
860868
if err:
861869
rep.sections.append(("Captured stderr", err))
862870
else:
863-
yield
871+
rep = yield
872+
return rep
864873

865-
@hookimpl(hookwrapper=True)
874+
@hookimpl(wrapper=True)
866875
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
867876
with self.item_capture("setup", item):
868-
yield
877+
return (yield)
869878

870-
@hookimpl(hookwrapper=True)
879+
@hookimpl(wrapper=True)
871880
def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]:
872881
with self.item_capture("call", item):
873-
yield
882+
return (yield)
874883

875-
@hookimpl(hookwrapper=True)
884+
@hookimpl(wrapper=True)
876885
def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
877886
with self.item_capture("teardown", item):
878-
yield
887+
return (yield)
879888

880889
@hookimpl(tryfirst=True)
881890
def pytest_keyboard_interrupt(self) -> None:

0 commit comments

Comments
 (0)