diff --git a/changelog/11122.improvement.rst b/changelog/11122.improvement.rst new file mode 100644 index 00000000000..022546097c9 --- /dev/null +++ b/changelog/11122.improvement.rst @@ -0,0 +1,6 @@ +``pluggy>=1.1.0`` is now required. + +pytest now uses "new-style" hook wrappers internally, available since pluggy 1.1.0. +See `pluggy's 1.1.0 changelog `_ and the :ref:`updated docs ` for details. + +Plugins which want to use new-style wrappers can do so if they require this version of pytest or later. diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index 97a6dd9f436..2535074e5b5 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -808,11 +808,10 @@ case we just write some information out to a ``failures`` file: import pytest - @pytest.hookimpl(tryfirst=True, hookwrapper=True) + @pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_runtest_makereport(item, call): # execute all other hooks to obtain the report object - outcome = yield - rep = outcome.get_result() + rep = yield # we only look at actual failing test calls, not setup/teardown if rep.when == "call" and rep.failed: @@ -826,6 +825,8 @@ case we just write some information out to a ``failures`` file: f.write(rep.nodeid + extra + "\n") + return rep + if you then have failing tests: @@ -899,16 +900,17 @@ here is a little example implemented via a local plugin: phase_report_key = StashKey[Dict[str, CollectReport]]() - @pytest.hookimpl(tryfirst=True, hookwrapper=True) + @pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_runtest_makereport(item, call): # execute all other hooks to obtain the report object - outcome = yield - rep = outcome.get_result() + rep = yield # store test results for each phase of a call, which can # be "setup", "call", "teardown" item.stash.setdefault(phase_report_key, {})[rep.when] = rep + return rep + @pytest.fixture def something(request): diff --git a/doc/en/how-to/writing_hook_functions.rst b/doc/en/how-to/writing_hook_functions.rst index 71379016f2e..527aeec8170 100644 --- a/doc/en/how-to/writing_hook_functions.rst +++ b/doc/en/how-to/writing_hook_functions.rst @@ -56,7 +56,7 @@ The remaining hook functions will not be called in this case. .. _`hookwrapper`: -hookwrapper: executing around other hooks +hook wrappers: executing around other hooks ------------------------------------------------- .. currentmodule:: _pytest.core @@ -69,10 +69,8 @@ which yields exactly once. When pytest invokes hooks it first executes hook wrappers and passes the same arguments as to the regular hooks. At the yield point of the hook wrapper pytest will execute the next hook -implementations and return their result to the yield point in the form of -a :py:class:`Result ` instance which encapsulates a result or -exception info. The yield point itself will thus typically not raise -exceptions (unless there are bugs). +implementations and return their result to the yield point, or will +propagate an exception if they raised. Here is an example definition of a hook wrapper: @@ -81,26 +79,35 @@ Here is an example definition of a hook wrapper: import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_pyfunc_call(pyfuncitem): do_something_before_next_hook_executes() - outcome = yield - # outcome.excinfo may be None or a (cls, val, tb) tuple + # If the outcome is an exception, will raise the exception. + res = yield - res = outcome.get_result() # will raise if outcome was exception + new_res = post_process_result(res) - post_process_result(res) + # Override the return value to the plugin system. + return new_res - outcome.force_result(new_res) # to override the return value to the plugin system +The hook wrapper needs to return a result for the hook, or raise an exception. -Note that hook wrappers don't return results themselves, they merely -perform tracing or other side effects around the actual hook implementations. -If the result of the underlying hook is a mutable object, they may modify -that result but it's probably better to avoid it. +In many cases, the wrapper only needs to perform tracing or other side effects +around the actual hook implementations, in which case it can return the result +value of the ``yield``. The simplest (though useless) hook wrapper is +``return (yield)``. + +In other cases, the wrapper wants the adjust or adapt the result, in which case +it can return a new value. If the result of the underlying hook is a mutable +object, the wrapper may modify that result, but it's probably better to avoid it. + +If the hook implementation failed with an exception, the wrapper can handle that +exception using a ``try-catch-finally`` around the ``yield``, by propagating it, +supressing it, or raising a different exception entirely. For more information, consult the -:ref:`pluggy documentation about hookwrappers `. +:ref:`pluggy documentation about hook wrappers `. .. _plugin-hookorder: @@ -130,11 +137,14 @@ after others, i.e. the position in the ``N``-sized list of functions: # Plugin 3 - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_collection_modifyitems(items): # will execute even before the tryfirst one above! - outcome = yield - # will execute after all non-hookwrappers executed + try: + return (yield) + finally: + # will execute after all non-wrappers executed + ... Here is the order of execution: @@ -149,12 +159,11 @@ Here is the order of execution: Plugin1). 4. Plugin3's pytest_collection_modifyitems then executing the code after the yield - point. The yield receives a :py:class:`Result ` instance which encapsulates - the result from calling the non-wrappers. Wrappers shall not modify the result. + point. The yield receives the result from calling the non-wrappers, or raises + an exception if the non-wrappers raised. -It's possible to use ``tryfirst`` and ``trylast`` also in conjunction with -``hookwrapper=True`` in which case it will influence the ordering of hookwrappers -among each other. +It's possible to use ``tryfirst`` and ``trylast`` also on hook wrappers +in which case it will influence the ordering of hook wrappers among each other. Declaring new hooks diff --git a/doc/en/requirements.txt b/doc/en/requirements.txt index b6059723cd5..0ee999e0faf 100644 --- a/doc/en/requirements.txt +++ b/doc/en/requirements.txt @@ -1,5 +1,5 @@ pallets-sphinx-themes -pluggy>=1.0 +pluggy>=1.2.0 pygments-pytest>=2.3.0 sphinx-removed-in>=0.2.0 sphinx>=5,<6 diff --git a/setup.cfg b/setup.cfg index bc93b1c06b9..9e37dd4a06e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,7 +47,7 @@ py_modules = py install_requires = iniconfig packaging - pluggy>=0.12,<2.0 + pluggy>=1.2.0,<2.0 colorama;sys_platform=="win32" exceptiongroup>=1.0.0rc8;python_version<"3.11" importlib-metadata>=0.12;python_version<"3.8" diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a46e58136ba..64ad4b0e662 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -112,8 +112,8 @@ def pytest_collection(session: "Session") -> None: assertstate.hook.set_session(session) -@hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: +@hookimpl(wrapper=True, tryfirst=True) +def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]: """Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks. 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: util._assertion_pass = call_assertion_pass_hook - yield - - util._reprcompare, util._assertion_pass = saved_assert_hooks - util._config = None + try: + return (yield) + finally: + util._reprcompare, util._assertion_pass = saved_assert_hooks + util._config = None def pytest_sessionfinish(session: "Session") -> None: diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 855716d8199..e618bb100dc 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -217,12 +217,12 @@ def __init__(self, lfplugin: "LFPlugin") -> None: self.lfplugin = lfplugin self._collected_at_least_one_failure = False - @hookimpl(hookwrapper=True) - def pytest_make_collect_report(self, collector: nodes.Collector): + @hookimpl(wrapper=True) + def pytest_make_collect_report( + self, collector: nodes.Collector + ) -> Generator[None, CollectReport, CollectReport]: + res = yield if isinstance(collector, (Session, Package)): - out = yield - res: CollectReport = out.get_result() - # Sort any lf-paths to the beginning. lf_paths = self.lfplugin._last_failed_paths @@ -240,19 +240,16 @@ def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool: key=sort_key, reverse=True, ) - return elif isinstance(collector, File): if collector.path in self.lfplugin._last_failed_paths: - out = yield - res = out.get_result() result = res.result lastfailed = self.lfplugin.lastfailed # Only filter with known failures. if not self._collected_at_least_one_failure: if not any(x.nodeid in lastfailed for x in result): - return + return res self.lfplugin.config.pluginmanager.register( LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip" ) @@ -268,8 +265,8 @@ def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool: # Keep all sub-collectors. or isinstance(x, nodes.Collector) ] - return - yield + + return res class LFPluginCollSkipfiles: @@ -342,14 +339,14 @@ def pytest_collectreport(self, report: CollectReport) -> None: else: self.lastfailed[report.nodeid] = True - @hookimpl(hookwrapper=True, tryfirst=True) + @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems( self, config: Config, items: List[nodes.Item] ) -> Generator[None, None, None]: - yield + res = yield if not self.active: - return + return res if self.lastfailed: previously_failed = [] @@ -394,6 +391,8 @@ def pytest_collection_modifyitems( else: self._report_status += "not deselecting items." + return res + def pytest_sessionfinish(self, session: Session) -> None: config = self.config if config.getoption("cacheshow") or hasattr(config, "workerinput"): @@ -414,11 +413,11 @@ def __init__(self, config: Config) -> None: assert config.cache is not None self.cached_nodeids = set(config.cache.get("cache/nodeids", [])) - @hookimpl(hookwrapper=True, tryfirst=True) + @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems( self, items: List[nodes.Item] ) -> Generator[None, None, None]: - yield + res = yield if self.active: new_items: Dict[str, nodes.Item] = {} @@ -436,6 +435,8 @@ def pytest_collection_modifyitems( else: self.cached_nodeids.update(item.nodeid for item in items) + return res + def _get_increasing_order(self, items: Iterable[nodes.Item]) -> List[nodes.Item]: return sorted(items, key=lambda item: item.path.stat().st_mtime, reverse=True) # type: ignore[no-any-return] diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index a8ca0869f33..a31346e160b 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -34,6 +34,7 @@ from _pytest.nodes import Collector from _pytest.nodes import File from _pytest.nodes import Item +from _pytest.reports import CollectReport if TYPE_CHECKING: from typing_extensions import Final @@ -132,8 +133,8 @@ def _reopen_stdio(f, mode): sys.stderr = _reopen_stdio(sys.stderr, "wb") -@hookimpl(hookwrapper=True) -def pytest_load_initial_conftests(early_config: Config): +@hookimpl(wrapper=True) +def pytest_load_initial_conftests(early_config: Config) -> Generator[None, None, None]: ns = early_config.known_args_namespace if ns.capture == "fd": _windowsconsoleio_workaround(sys.stdout) @@ -147,12 +148,16 @@ def pytest_load_initial_conftests(early_config: Config): # Finally trigger conftest loading but while capturing (issue #93). capman.start_global_capturing() - outcome = yield - capman.suspend_global_capture() - if outcome.excinfo is not None: + try: + try: + yield + finally: + capman.suspend_global_capture() + except BaseException: out, err = capman.read_global_capture() sys.stdout.write(out) sys.stderr.write(err) + raise # IO Helpers. @@ -843,41 +848,45 @@ def item_capture(self, when: str, item: Item) -> Generator[None, None, None]: self.deactivate_fixture() self.suspend_global_capture(in_=False) - out, err = self.read_global_capture() - item.add_report_section(when, "stdout", out) - item.add_report_section(when, "stderr", err) + out, err = self.read_global_capture() + item.add_report_section(when, "stdout", out) + item.add_report_section(when, "stderr", err) # Hooks - @hookimpl(hookwrapper=True) - def pytest_make_collect_report(self, collector: Collector): + @hookimpl(wrapper=True) + def pytest_make_collect_report( + self, collector: Collector + ) -> Generator[None, CollectReport, CollectReport]: if isinstance(collector, File): self.resume_global_capture() - outcome = yield - self.suspend_global_capture() + try: + rep = yield + finally: + self.suspend_global_capture() out, err = self.read_global_capture() - rep = outcome.get_result() if out: rep.sections.append(("Captured stdout", out)) if err: rep.sections.append(("Captured stderr", err)) else: - yield + rep = yield + return rep - @hookimpl(hookwrapper=True) + @hookimpl(wrapper=True) def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]: with self.item_capture("setup", item): - yield + return (yield) - @hookimpl(hookwrapper=True) + @hookimpl(wrapper=True) def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]: with self.item_capture("call", item): - yield + return (yield) - @hookimpl(hookwrapper=True) + @hookimpl(wrapper=True) def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]: with self.item_capture("teardown", item): - yield + return (yield) @hookimpl(tryfirst=True) def pytest_keyboard_interrupt(self) -> None: diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c9a4b7f63cb..b5a99cc9c40 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1338,12 +1338,14 @@ def _preparse(self, args: List[str], addopts: bool = True) -> None: else: raise - @hookimpl(hookwrapper=True) - def pytest_collection(self) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_collection(self) -> Generator[None, object, object]: # Validate invalid ini keys after collection is done so we take in account # options added by late-loading conftest files. - yield - self._validate_config_options() + try: + return (yield) + finally: + self._validate_config_options() def _checkversion(self) -> None: import pytest @@ -1445,7 +1447,7 @@ def issue_config_time_warning(self, warning: Warning, stacklevel: int) -> None: """Issue and handle a warning during the "configure" stage. During ``pytest_configure`` we can't capture warnings using the ``catch_warnings_for_item`` - function because it is not possible to have hookwrappers around ``pytest_configure``. + function because it is not possible to have hook wrappers around ``pytest_configure``. This function is mainly intended for plugins that need to issue warnings during ``pytest_configure`` (or similar stages). diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index a3f80802cab..69ec58c5b8c 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -304,10 +304,10 @@ def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None: class PdbTrace: - @hookimpl(hookwrapper=True) - def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, object, object]: wrap_pytest_function_for_tracing(pyfuncitem) - yield + return (yield) def wrap_pytest_function_for_tracing(pyfuncitem): diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index af879aa44cf..2dc672c8d7f 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -62,8 +62,8 @@ def get_timeout_config_value(config: Config) -> float: return float(config.getini("faulthandler_timeout") or 0.0) -@pytest.hookimpl(hookwrapper=True, trylast=True) -def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: +@pytest.hookimpl(wrapper=True, trylast=True) +def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]: timeout = get_timeout_config_value(item.config) if timeout > 0: import faulthandler @@ -71,11 +71,11 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: stderr = item.config.stash[fault_handler_stderr_fd_key] faulthandler.dump_traceback_later(timeout, file=stderr) try: - yield + return (yield) finally: faulthandler.cancel_dump_traceback_later() else: - yield + return (yield) @pytest.hookimpl(tryfirst=True) diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 6b6718a7083..69600670894 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -2,6 +2,7 @@ import os import sys from argparse import Action +from typing import Generator from typing import List from typing import Optional from typing import Union @@ -97,10 +98,9 @@ def pytest_addoption(parser: Parser) -> None: ) -@pytest.hookimpl(hookwrapper=True) -def pytest_cmdline_parse(): - outcome = yield - config: Config = outcome.get_result() +@pytest.hookimpl(wrapper=True) +def pytest_cmdline_parse() -> Generator[None, Config, Config]: + config = yield if config.option.debug: # --debug | --debug was provided. @@ -128,6 +128,8 @@ def unset_tracing() -> None: config.add_cleanup(unset_tracing) + return config + def showversion(config: Config) -> None: if config.option.version > 1: diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 1f7c368f792..b01f8ec16b4 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -60,7 +60,7 @@ def pytest_addhooks(pluginmanager: "PytestPluginManager") -> None: :param pytest.PytestPluginManager pluginmanager: The pytest plugin manager. .. note:: - This hook is incompatible with ``hookwrapper=True``. + This hook is incompatible with hook wrappers. """ @@ -74,7 +74,7 @@ def pytest_plugin_registered( :param pytest.PytestPluginManager manager: pytest plugin manager. .. note:: - This hook is incompatible with ``hookwrapper=True``. + This hook is incompatible with hook wrappers. """ @@ -113,7 +113,7 @@ def pytest_addoption(parser: "Parser", pluginmanager: "PytestPluginManager") -> attribute or can be retrieved as the ``pytestconfig`` fixture. .. note:: - This hook is incompatible with ``hookwrapper=True``. + This hook is incompatible with hook wrappers. """ @@ -128,7 +128,7 @@ def pytest_configure(config: "Config") -> None: imported. .. note:: - This hook is incompatible with ``hookwrapper=True``. + This hook is incompatible with hook wrappers. :param pytest.Config config: The pytest config object. """ diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 83813466016..415adb97a9e 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -738,27 +738,26 @@ def _log_cli_enabled(self): return True - @hookimpl(hookwrapper=True, tryfirst=True) + @hookimpl(wrapper=True, tryfirst=True) def pytest_sessionstart(self) -> Generator[None, None, None]: self.log_cli_handler.set_when("sessionstart") with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_file_handler, level=self.log_file_level): - yield + return (yield) - @hookimpl(hookwrapper=True, tryfirst=True) + @hookimpl(wrapper=True, tryfirst=True) def pytest_collection(self) -> Generator[None, None, None]: self.log_cli_handler.set_when("collection") with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_file_handler, level=self.log_file_level): - yield + return (yield) - @hookimpl(hookwrapper=True) - def pytest_runtestloop(self, session: Session) -> Generator[None, None, None]: + @hookimpl(wrapper=True) + def pytest_runtestloop(self, session: Session) -> Generator[None, object, object]: if session.config.option.collectonly: - yield - return + return (yield) if self._log_cli_enabled() and self._config.getoption("verbose") < 1: # The verbose flag is needed to avoid messy test progress output. @@ -766,7 +765,7 @@ def pytest_runtestloop(self, session: Session) -> Generator[None, None, None]: with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_file_handler, level=self.log_file_level): - yield # Run all the tests. + return (yield) # Run all the tests. @hookimpl def pytest_runtest_logstart(self) -> None: @@ -791,12 +790,13 @@ def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None, None, Non item.stash[caplog_records_key][when] = caplog_handler.records item.stash[caplog_handler_key] = caplog_handler - yield - - log = report_handler.stream.getvalue().strip() - item.add_report_section(when, "log", log) + try: + yield + finally: + log = report_handler.stream.getvalue().strip() + item.add_report_section(when, "log", log) - @hookimpl(hookwrapper=True) + @hookimpl(wrapper=True) def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]: self.log_cli_handler.set_when("setup") @@ -804,31 +804,33 @@ def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]: item.stash[caplog_records_key] = empty yield from self._runtest_for(item, "setup") - @hookimpl(hookwrapper=True) + @hookimpl(wrapper=True) def pytest_runtest_call(self, item: nodes.Item) -> Generator[None, None, None]: self.log_cli_handler.set_when("call") yield from self._runtest_for(item, "call") - @hookimpl(hookwrapper=True) + @hookimpl(wrapper=True) def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None, None, None]: self.log_cli_handler.set_when("teardown") - yield from self._runtest_for(item, "teardown") - del item.stash[caplog_records_key] - del item.stash[caplog_handler_key] + try: + yield from self._runtest_for(item, "teardown") + finally: + del item.stash[caplog_records_key] + del item.stash[caplog_handler_key] @hookimpl def pytest_runtest_logfinish(self) -> None: self.log_cli_handler.set_when("finish") - @hookimpl(hookwrapper=True, tryfirst=True) + @hookimpl(wrapper=True, tryfirst=True) def pytest_sessionfinish(self) -> Generator[None, None, None]: self.log_cli_handler.set_when("sessionfinish") with catching_logs(self.log_cli_handler, level=self.log_cli_level): with catching_logs(self.log_file_handler, level=self.log_file_level): - yield + return (yield) @hookimpl def pytest_unconfigure(self) -> None: diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index a9299944dec..651d4ede366 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -161,29 +161,31 @@ def matching_platform(self) -> bool: else: return True - @hookimpl(hookwrapper=True, tryfirst=True) - def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]: + @hookimpl(wrapper=True, tryfirst=True) + def pytest_runtest_protocol(self, item: Item) -> Generator[None, object, object]: lines1 = self.get_open_files() - yield - if hasattr(sys, "pypy_version_info"): - gc.collect() - lines2 = self.get_open_files() - - new_fds = {t[0] for t in lines2} - {t[0] for t in lines1} - leaked_files = [t for t in lines2 if t[0] in new_fds] - if leaked_files: - error = [ - "***** %s FD leakage detected" % len(leaked_files), - *(str(f) for f in leaked_files), - "*** Before:", - *(str(f) for f in lines1), - "*** After:", - *(str(f) for f in lines2), - "***** %s FD leakage detected" % len(leaked_files), - "*** function %s:%s: %s " % item.location, - "See issue #2366", - ] - item.warn(PytestWarning("\n".join(error))) + try: + return (yield) + finally: + if hasattr(sys, "pypy_version_info"): + gc.collect() + lines2 = self.get_open_files() + + new_fds = {t[0] for t in lines2} - {t[0] for t in lines1} + leaked_files = [t for t in lines2 if t[0] in new_fds] + if leaked_files: + error = [ + "***** %s FD leakage detected" % len(leaked_files), + *(str(f) for f in leaked_files), + "*** Before:", + *(str(f) for f in lines1), + "*** After:", + *(str(f) for f in lines2), + "***** %s FD leakage detected" % len(leaked_files), + "*** function %s:%s: %s " % item.location, + "See issue #2366", + ] + item.warn(PytestWarning("\n".join(error))) # used at least by pytest-xdist plugin diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 74e8794b232..a978d529f09 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -249,6 +249,9 @@ class TestReport(BaseReport): """ __test__ = False + # Defined by skipping plugin. + # xfail reason if xfailed, otherwise not defined. Use hasattr to distinguish. + wasxfail: str def __init__( self, diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 583590d6b70..0f8be899af2 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -28,24 +28,26 @@ def pytest_addoption(parser: Parser) -> None: ) -@pytest.hookimpl(hookwrapper=True) +@pytest.hookimpl(wrapper=True) def pytest_fixture_setup( fixturedef: FixtureDef[object], request: SubRequest -) -> Generator[None, None, None]: - yield - if request.config.option.setupshow: - if hasattr(request, "param"): - # Save the fixture parameter so ._show_fixture_action() can - # display it now and during the teardown (in .finish()). - if fixturedef.ids: - if callable(fixturedef.ids): - param = fixturedef.ids(request.param) +) -> Generator[None, object, object]: + try: + return (yield) + finally: + if request.config.option.setupshow: + if hasattr(request, "param"): + # Save the fixture parameter so ._show_fixture_action() can + # display it now and during the teardown (in .finish()). + if fixturedef.ids: + if callable(fixturedef.ids): + param = fixturedef.ids(request.param) + else: + param = fixturedef.ids[request.param_index] else: - param = fixturedef.ids[request.param_index] - else: - param = request.param - fixturedef.cached_param = param # type: ignore[attr-defined] - _show_fixture_action(fixturedef, "SETUP") + param = request.param + fixturedef.cached_param = param # type: ignore[attr-defined] + _show_fixture_action(fixturedef, "SETUP") def pytest_fixture_post_finalizer(fixturedef: FixtureDef[object]) -> None: diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 26ce73758a0..0c5c38f5f1a 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -19,6 +19,7 @@ from _pytest.outcomes import skip from _pytest.outcomes import xfail from _pytest.reports import BaseReport +from _pytest.reports import TestReport from _pytest.runner import CallInfo from _pytest.stash import StashKey @@ -243,7 +244,7 @@ def pytest_runtest_setup(item: Item) -> None: xfail("[NOTRUN] " + xfailed.reason) -@hookimpl(hookwrapper=True) +@hookimpl(wrapper=True) def pytest_runtest_call(item: Item) -> Generator[None, None, None]: xfailed = item.stash.get(xfailed_key, None) if xfailed is None: @@ -252,18 +253,20 @@ def pytest_runtest_call(item: Item) -> Generator[None, None, None]: if xfailed and not item.config.option.runxfail and not xfailed.run: xfail("[NOTRUN] " + xfailed.reason) - yield - - # The test run may have added an xfail mark dynamically. - xfailed = item.stash.get(xfailed_key, None) - if xfailed is None: - item.stash[xfailed_key] = xfailed = evaluate_xfail_marks(item) + try: + return (yield) + finally: + # The test run may have added an xfail mark dynamically. + xfailed = item.stash.get(xfailed_key, None) + if xfailed is None: + item.stash[xfailed_key] = xfailed = evaluate_xfail_marks(item) -@hookimpl(hookwrapper=True) -def pytest_runtest_makereport(item: Item, call: CallInfo[None]): - outcome = yield - rep = outcome.get_result() +@hookimpl(wrapper=True) +def pytest_runtest_makereport( + item: Item, call: CallInfo[None] +) -> Generator[None, TestReport, TestReport]: + rep = yield xfailed = item.stash.get(xfailed_key, None) if item.config.option.runxfail: pass # don't interfere @@ -286,6 +289,7 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]): else: rep.outcome = "passed" rep.wasxfail = xfailed.reason + return rep def pytest_report_teststatus(report: BaseReport) -> Optional[Tuple[str, str, str]]: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index b0cdb58ce00..fa0b1a2293f 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -15,7 +15,6 @@ from pathlib import Path from typing import Any from typing import Callable -from typing import cast from typing import ClassVar from typing import Dict from typing import Generator @@ -849,12 +848,11 @@ def _printcollecteditems(self, items: Sequence[Item]) -> None: for line in doc.splitlines(): self._tw.line("{}{}".format(indent + " ", line)) - @hookimpl(hookwrapper=True) + @hookimpl(wrapper=True) def pytest_sessionfinish( self, session: "Session", exitstatus: Union[int, ExitCode] - ): - outcome = yield - outcome.get_result() + ) -> Generator[None, None, None]: + result = yield self._tw.line("") summary_exit_codes = ( ExitCode.OK, @@ -875,17 +873,20 @@ def pytest_sessionfinish( elif session.shouldstop: self.write_sep("!", str(session.shouldstop), red=True) self.summary_stats() + return result - @hookimpl(hookwrapper=True) + @hookimpl(wrapper=True) def pytest_terminal_summary(self) -> Generator[None, None, None]: self.summary_errors() self.summary_failures() self.summary_warnings() self.summary_passes() - yield - self.short_test_summary() - # Display any extra warnings from teardown here (if any). - self.summary_warnings() + try: + return (yield) + finally: + self.short_test_summary() + # Display any extra warnings from teardown here (if any). + self.summary_warnings() def pytest_keyboard_interrupt(self, excinfo: ExceptionInfo[BaseException]) -> None: self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) @@ -1466,7 +1467,7 @@ def _get_raw_skip_reason(report: TestReport) -> str: The string is just the part given by the user. """ if hasattr(report, "wasxfail"): - reason = cast(str, report.wasxfail) + reason = report.wasxfail if reason.startswith("reason: "): reason = reason[len("reason: ") :] return reason diff --git a/src/_pytest/threadexception.py b/src/_pytest/threadexception.py index 43341e739a0..0b5902d66a8 100644 --- a/src/_pytest/threadexception.py +++ b/src/_pytest/threadexception.py @@ -59,30 +59,34 @@ def __exit__( def thread_exception_runtest_hook() -> Generator[None, None, None]: with catch_threading_exception() as cm: - yield - if cm.args: - thread_name = "" if cm.args.thread is None else cm.args.thread.name - msg = f"Exception in thread {thread_name}\n\n" - msg += "".join( - traceback.format_exception( - cm.args.exc_type, - cm.args.exc_value, - cm.args.exc_traceback, + try: + yield + finally: + if cm.args: + thread_name = ( + "" if cm.args.thread is None else cm.args.thread.name ) - ) - warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) + msg = f"Exception in thread {thread_name}\n\n" + msg += "".join( + traceback.format_exception( + cm.args.exc_type, + cm.args.exc_value, + cm.args.exc_traceback, + ) + ) + warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) -@pytest.hookimpl(hookwrapper=True, trylast=True) +@pytest.hookimpl(wrapper=True, trylast=True) def pytest_runtest_setup() -> Generator[None, None, None]: yield from thread_exception_runtest_hook() -@pytest.hookimpl(hookwrapper=True, tryfirst=True) +@pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_runtest_call() -> Generator[None, None, None]: yield from thread_exception_runtest_hook() -@pytest.hookimpl(hookwrapper=True, tryfirst=True) +@pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_runtest_teardown() -> Generator[None, None, None]: yield from thread_exception_runtest_hook() diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 3cc2bace55b..e708553a0fb 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -13,7 +13,7 @@ from typing import Union from _pytest.nodes import Item -from _pytest.reports import CollectReport +from _pytest.reports import TestReport from _pytest.stash import StashKey if TYPE_CHECKING: @@ -315,10 +315,12 @@ def pytest_sessionfinish(session, exitstatus: Union[int, ExitCode]): cleanup_dead_symlinks(basetemp) -@hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_makereport(item: Item, call): - outcome = yield - result: CollectReport = outcome.get_result() - +@hookimpl(wrapper=True, tryfirst=True) +def pytest_runtest_makereport( + item: Item, call +) -> Generator[None, TestReport, TestReport]: + rep = yield + assert rep.when is not None empty: Dict[str, bool] = {} - item.stash.setdefault(tmppath_result_key, empty)[result.when] = result.passed + item.stash.setdefault(tmppath_result_key, empty)[rep.when] = rep.passed + return rep diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index d42a12a3a9e..27598cbdef6 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -376,8 +376,8 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None: # Twisted trial support. -@hookimpl(hookwrapper=True) -def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: +@hookimpl(wrapper=True) +def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]: if isinstance(item, TestCaseFunction) and "twisted.trial.unittest" in sys.modules: ut: Any = sys.modules["twisted.python.failure"] Failure__init__ = ut.Failure.__init__ @@ -400,10 +400,13 @@ def excstore( Failure__init__(self, exc_value, exc_type, exc_tb) ut.Failure.__init__ = excstore - yield - ut.Failure.__init__ = Failure__init__ + try: + res = yield + finally: + ut.Failure.__init__ = Failure__init__ else: - yield + res = yield + return res def check_testcase_implements_trial_reporter(done: List[int] = []) -> None: diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index fcb5d8237c1..8c0a2d9ae95 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -61,33 +61,35 @@ def __exit__( def unraisable_exception_runtest_hook() -> Generator[None, None, None]: with catch_unraisable_exception() as cm: - yield - if cm.unraisable: - if cm.unraisable.err_msg is not None: - err_msg = cm.unraisable.err_msg - else: - err_msg = "Exception ignored in" - msg = f"{err_msg}: {cm.unraisable.object!r}\n\n" - msg += "".join( - traceback.format_exception( - cm.unraisable.exc_type, - cm.unraisable.exc_value, - cm.unraisable.exc_traceback, + try: + yield + finally: + if cm.unraisable: + if cm.unraisable.err_msg is not None: + err_msg = cm.unraisable.err_msg + else: + err_msg = "Exception ignored in" + msg = f"{err_msg}: {cm.unraisable.object!r}\n\n" + msg += "".join( + traceback.format_exception( + cm.unraisable.exc_type, + cm.unraisable.exc_value, + cm.unraisable.exc_traceback, + ) ) - ) - warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) + warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) +@pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_runtest_setup() -> Generator[None, None, None]: yield from unraisable_exception_runtest_hook() -@pytest.hookimpl(hookwrapper=True, tryfirst=True) +@pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_runtest_call() -> Generator[None, None, None]: yield from unraisable_exception_runtest_hook() -@pytest.hookimpl(hookwrapper=True, tryfirst=True) +@pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_runtest_teardown() -> Generator[None, None, None]: yield from unraisable_exception_runtest_hook() diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 4aaa9445293..b71b4d0ea64 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -58,17 +58,18 @@ def catch_warnings_for_item( for arg in mark.args: warnings.filterwarnings(*parse_warning_filter(arg, escape=False)) - yield - - for warning_message in log: - ihook.pytest_warning_recorded.call_historic( - kwargs=dict( - warning_message=warning_message, - nodeid=nodeid, - when=when, - location=None, + try: + yield + finally: + for warning_message in log: + ihook.pytest_warning_recorded.call_historic( + kwargs=dict( + warning_message=warning_message, + nodeid=nodeid, + when=when, + location=None, + ) ) - ) def warning_record_to_str(warning_message: warnings.WarningMessage) -> str: @@ -101,24 +102,24 @@ def warning_record_to_str(warning_message: warnings.WarningMessage) -> str: return msg -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]: +@pytest.hookimpl(wrapper=True, tryfirst=True) +def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]: with catch_warnings_for_item( config=item.config, ihook=item.ihook, when="runtest", item=item ): - yield + return (yield) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_collection(session: Session) -> Generator[None, None, None]: +@pytest.hookimpl(wrapper=True, tryfirst=True) +def pytest_collection(session: Session) -> Generator[None, object, object]: config = session.config with catch_warnings_for_item( config=config, ihook=config.hook, when="collect", item=None ): - yield + return (yield) -@pytest.hookimpl(hookwrapper=True) +@pytest.hookimpl(wrapper=True) def pytest_terminal_summary( terminalreporter: TerminalReporter, ) -> Generator[None, None, None]: @@ -126,23 +127,23 @@ def pytest_terminal_summary( with catch_warnings_for_item( config=config, ihook=config.hook, when="config", item=None ): - yield + return (yield) -@pytest.hookimpl(hookwrapper=True) +@pytest.hookimpl(wrapper=True) def pytest_sessionfinish(session: Session) -> Generator[None, None, None]: config = session.config with catch_warnings_for_item( config=config, ihook=config.hook, when="config", item=None ): - yield + return (yield) -@pytest.hookimpl(hookwrapper=True) +@pytest.hookimpl(wrapper=True) def pytest_load_initial_conftests( early_config: "Config", ) -> Generator[None, None, None]: with catch_warnings_for_item( config=early_config, ihook=early_config.hook, when="config", item=None ): - yield + return (yield) diff --git a/testing/conftest.py b/testing/conftest.py index 8e77fcae5ab..926a1d5d364 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,6 +1,7 @@ import dataclasses import re import sys +from typing import Generator from typing import List import pytest @@ -21,11 +22,11 @@ def restore_tracing(): sys.settrace(orig_trace) -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_collection_modifyitems(items): +@pytest.hookimpl(wrapper=True, tryfirst=True) +def pytest_collection_modifyitems(items) -> Generator[None, None, None]: """Prefer faster tests. - Use a hookwrapper to do this in the beginning, so e.g. --ff still works + Use a hook wrapper to do this in the beginning, so e.g. --ff still works correctly. """ fast_items = [] @@ -62,7 +63,7 @@ def pytest_collection_modifyitems(items): items[:] = fast_items + neutral_items + slow_items + slowest_items - yield + return (yield) @pytest.fixture diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 14b77236ab2..2606ed325a3 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -1040,13 +1040,13 @@ def test_log_set_path(pytester: Pytester) -> None: """ import os import pytest - @pytest.hookimpl(hookwrapper=True, tryfirst=True) + @pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_runtest_setup(item): config = item.config logging_plugin = config.pluginmanager.get_plugin("logging-plugin") report_file = os.path.join({}, item._request.node.name) logging_plugin.set_log_path(report_file) - yield + return (yield) """.format( repr(report_dir_base) ) diff --git a/testing/python/collect.py b/testing/python/collect.py index de10ce4083a..451379b4b5f 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -826,11 +826,11 @@ def test_customized_pymakemodule_issue205_subdir(self, pytester: Pytester) -> No textwrap.dedent( """\ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_pycollect_makemodule(): - outcome = yield - mod = outcome.get_result() + mod = yield mod.obj.hello = "world" + return mod """ ) ) @@ -852,14 +852,13 @@ def test_customized_pymakeitem(self, pytester: Pytester) -> None: textwrap.dedent( """\ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_pycollect_makeitem(): - outcome = yield - if outcome.excinfo is None: - result = outcome.get_result() - if result: - for func in result: - func._some123 = "world" + result = yield + if result: + for func in result: + func._some123 = "world" + return result """ ) ) diff --git a/testing/test_collection.py b/testing/test_collection.py index 3021398720f..6b65fd7807b 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -328,12 +328,11 @@ def test_collect_report_postprocessing(self, pytester: Pytester) -> None: pytester.makeconftest( """ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_make_collect_report(): - outcome = yield - rep = outcome.get_result() + rep = yield rep.headerlines += ["header1"] - outcome.force_result(rep) + return rep """ ) result = pytester.runpytest(p) diff --git a/testing/test_config.py b/testing/test_config.py index cdeb67aceff..9303070193e 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1309,7 +1309,7 @@ def pytest_load_initial_conftests(self): hookimpls = [ ( hookimpl.function.__module__, - "wrapper" if hookimpl.hookwrapper else "nonwrapper", + "wrapper" if (hookimpl.wrapper or hookimpl.hookwrapper) else "nonwrapper", ) for hookimpl in hc.get_hookimpls() ] diff --git a/testing/test_mark.py b/testing/test_mark.py index e2d1a40c38a..614f86dc5c8 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -806,12 +806,12 @@ def test_2(self): pytester.makepyfile( conftest=""" import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_pycollect_makeitem(name): - outcome = yield + item = yield if name == "TestClass": - item = outcome.get_result() item.extra_keyword_matches.add("xxx") + return item """ ) reprec = pytester.inline_run(p.parent, "-s", "-k", keyword) diff --git a/testing/test_python_path.py b/testing/test_python_path.py index e1628feb159..dfef0f3fecf 100644 --- a/testing/test_python_path.py +++ b/testing/test_python_path.py @@ -85,8 +85,8 @@ def test_clean_up(pytester: Pytester) -> None: # This is tough to test behaviorally because the cleanup really runs last. # So the test make several implementation assumptions: # - Cleanup is done in pytest_unconfigure(). - # - Not a hookwrapper. - # So we can add a hookwrapper ourselves to test what it does. + # - Not a hook wrapper. + # So we can add a hook wrapper ourselves to test what it does. pytester.makefile(".ini", pytest="[pytest]\npythonpath=I_SHALL_BE_REMOVED\n") pytester.makepyfile(test_foo="""def test_foo(): pass""") @@ -94,12 +94,14 @@ def test_clean_up(pytester: Pytester) -> None: after: Optional[List[str]] = None class Plugin: - @pytest.hookimpl(hookwrapper=True, tryfirst=True) + @pytest.hookimpl(wrapper=True, tryfirst=True) def pytest_unconfigure(self) -> Generator[None, None, None]: nonlocal before, after before = sys.path.copy() - yield - after = sys.path.copy() + try: + return (yield) + finally: + after = sys.path.copy() result = pytester.runpytest_inprocess(plugins=[Plugin()]) assert result.ret == 0 diff --git a/testing/test_runner.py b/testing/test_runner.py index de3e18184c7..cab631ee12e 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -542,10 +542,10 @@ def pytest_runtest_setup(self, item): @pytest.fixture def mylist(self, request): return request.function.mylist - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_runtest_call(self, item): try: - (yield).get_result() + yield except ValueError: pass def test_hello1(self, mylist): @@ -826,12 +826,12 @@ def test_unicode_in_longrepr(pytester: Pytester) -> None: pytester.makeconftest( """\ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_runtest_makereport(): - outcome = yield - rep = outcome.get_result() + rep = yield if rep.when == "call": rep.longrepr = 'รค' + return rep """ ) pytester.makepyfile( diff --git a/testing/test_terminal.py b/testing/test_terminal.py index c0acb600608..82bf4e271cd 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -724,12 +724,12 @@ def test_three(): ) assert result.ret == 0 - def test_deselected_with_hookwrapper(self, pytester: Pytester) -> None: + def test_deselected_with_hook_wrapper(self, pytester: Pytester) -> None: pytester.makeconftest( """ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_collection_modifyitems(config, items): yield deselected = items.pop() diff --git a/testing/test_unittest.py b/testing/test_unittest.py index d917d331a03..c276980e05a 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -952,7 +952,7 @@ def test_issue333_result_clearing(pytester: Pytester) -> None: pytester.makeconftest( """ import pytest - @pytest.hookimpl(hookwrapper=True) + @pytest.hookimpl(wrapper=True) def pytest_runtest_call(item): yield assert 0