From cc5ceed916d0c63696da33c67e035917194a4e87 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:58:49 +0000 Subject: [PATCH 01/10] RELEASING: remove pytest mailing list (#13472) (#13473) It is no longer. (cherry picked from commit 80dfa2db8e6157bf706c2f2656ba0fd7bc13195a) Co-authored-by: Ran Benita --- RELEASING.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/RELEASING.rst b/RELEASING.rst index 0ca63ee4fbf..76c41d83a58 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -168,7 +168,6 @@ Both automatic and manual processes described above follow the same steps from t To the following mailing lists: - * pytest-dev@python.org (all releases) * python-announce-list@python.org (all releases) * testing-in-python@lists.idyll.org (only major/minor releases) From b49745ec529f06edfbbe531b766839763b2be3c2 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 19:25:38 -0300 Subject: [PATCH 02/10] fix: support TerminalReporter.isatty being called (#13462) (#13483) Fixes #13461. (cherry picked from commit cba5c4a98f65fdcfa484749bc06bcde1fb0865bc) Co-authored-by: Martin Fischer --- changelog/13461.bugfix.rst | 3 +++ src/_pytest/compat.py | 20 ++++++++++++++++++++ src/_pytest/terminal.py | 11 +++++++---- testing/test_terminal.py | 10 ++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 changelog/13461.bugfix.rst diff --git a/changelog/13461.bugfix.rst b/changelog/13461.bugfix.rst new file mode 100644 index 00000000000..4b45f7cc9da --- /dev/null +++ b/changelog/13461.bugfix.rst @@ -0,0 +1,3 @@ +Corrected ``_pytest.terminal.TerminalReporter.isatty`` to support +being called as a method. Before it was just a boolean which could +break correct code when using ``-o log_cli=true``). diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index f113a2197f3..7d71838be51 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -300,3 +300,23 @@ def get_user_id() -> int | None: # This also work for Enums (if you use `is` to compare) and Literals. def assert_never(value: NoReturn) -> NoReturn: assert False, f"Unhandled value: {value} ({type(value).__name__})" + + +class CallableBool: + """ + A bool-like object that can also be called, returning its true/false value. + + Used for backwards compatibility in cases where something was supposed to be a method + but was implemented as a simple attribute by mistake (see `TerminalReporter.isatty`). + + Do not use in new code. + """ + + def __init__(self, value: bool) -> None: + self._value = value + + def __bool__(self) -> bool: + return self._value + + def __call__(self) -> bool: + return self._value diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index d5ccc4e4900..5f27c46b41e 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -31,6 +31,7 @@ import pluggy +from _pytest import compat from _pytest import nodes from _pytest import timing from _pytest._code import ExceptionInfo @@ -387,7 +388,9 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: self.reportchars = getreportopt(config) self.foldskipped = config.option.fold_skipped self.hasmarkup = self._tw.hasmarkup - self.isatty = file.isatty() + # isatty should be a method but was wrongly implemented as a boolean. + # We use CallableBool here to support both. + self.isatty = compat.CallableBool(file.isatty()) self._progress_nodeids_reported: set[str] = set() self._timing_nodeids_reported: set[str] = set() self._show_progress_info = self._determine_show_progress_info() @@ -766,7 +769,7 @@ def _width_of_current_line(self) -> int: return self._tw.width_of_current_line def pytest_collection(self) -> None: - if self.isatty: + if self.isatty(): if self.config.option.verbose >= 0: self.write("collecting ... ", flush=True, bold=True) elif self.config.option.verbose >= 1: @@ -779,7 +782,7 @@ def pytest_collectreport(self, report: CollectReport) -> None: self._add_stats("skipped", [report]) items = [x for x in report.result if isinstance(x, Item)] self._numcollected += len(items) - if self.isatty: + if self.isatty(): self.report_collect() def report_collect(self, final: bool = False) -> None: @@ -811,7 +814,7 @@ def report_collect(self, final: bool = False) -> None: line += f" / {skipped} skipped" if self._numcollected > selected: line += f" / {selected} selected" - if self.isatty: + if self.isatty(): self.rewrite(line, bold=True, erase=True) if final: self.write("\n") diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 86feb33b3ec..3ea10195c6b 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -442,6 +442,16 @@ def test_long_xfail(): ] ) + @pytest.mark.parametrize("isatty", [True, False]) + def test_isatty(self, pytester: Pytester, monkeypatch, isatty: bool) -> None: + config = pytester.parseconfig() + f = StringIO() + monkeypatch.setattr(f, "isatty", lambda: isatty) + tr = TerminalReporter(config, f) + assert tr.isatty() == isatty + # It was incorrectly implemented as a boolean so we still support using it as one. + assert bool(tr.isatty) == isatty + class TestCollectonly: def test_collectonly_basic(self, pytester: Pytester) -> None: From 4e631a71484c2fa49e3fd9f884546af411a4888d Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 22:53:48 +0000 Subject: [PATCH 03/10] Merge pull request #13486 from hosmir/fixtypo (#13487) Fix typo in docs regarding capsys/capteesys (cherry picked from commit 8f93ae9f38b2b1d6fd2d602e099d8907f34cf0a0) Co-authored-by: John Litborn <11260241+jakkdl@users.noreply.github.com> --- doc/en/builtin.rst | 2 +- src/_pytest/capture.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/en/builtin.rst b/doc/en/builtin.rst index b129d0a763f..31c2fa9df06 100644 --- a/doc/en/builtin.rst +++ b/doc/en/builtin.rst @@ -69,7 +69,7 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a .. code-block:: python - def test_output(capsys): + def test_output(capteesys): print("hello") captured = capteesys.readouterr() assert captured.out == "hello\n" diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 3812d88176a..6d98676be5f 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -1043,7 +1043,7 @@ def capteesys(request: SubRequest) -> Generator[CaptureFixture[str]]: .. code-block:: python - def test_output(capsys): + def test_output(capteesys): print("hello") captured = capteesys.readouterr() assert captured.out == "hello\n" From 1a0581b0227fa07afd1f2c4c6215aec3862cf1ab Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 13:50:26 +0000 Subject: [PATCH 04/10] Remove outdated warning about faulthandler_timeout on Windows (#13492) (#13493) --------- (cherry picked from commit 216c7ec882b2aa30fb8a35e100c990c34fca12c1) Co-authored-by: Iwithyou2025 Co-authored-by: Bruno Oliveira --- changelog/13492.doc.rst | 1 + doc/en/how-to/failures.rst | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog/13492.doc.rst diff --git a/changelog/13492.doc.rst b/changelog/13492.doc.rst new file mode 100644 index 00000000000..6e3f72a860e --- /dev/null +++ b/changelog/13492.doc.rst @@ -0,0 +1 @@ +Fixed outdated warning about ``faulthandler`` not working on Windows. diff --git a/doc/en/how-to/failures.rst b/doc/en/how-to/failures.rst index b3d0c155b48..0c45cd7b118 100644 --- a/doc/en/how-to/failures.rst +++ b/doc/en/how-to/failures.rst @@ -112,7 +112,7 @@ on the command-line. Also the :confval:`faulthandler_timeout=X` configuration option can be used to dump the traceback of all threads if a test takes longer than ``X`` -seconds to finish (not available on Windows). +seconds to finish. .. note:: From a86ee09291f913b36fdeec14c42356515b91c979 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Sat, 14 Jun 2025 21:51:35 -0300 Subject: [PATCH 05/10] Fix typo in parametrize.rst (#13514) (#13516) (cherry picked from commit 64c1173df4f897a8880a25a29eff67d45dbc41b2) Co-authored-by: SarahPythonista <4283226+SarahPythonista@users.noreply.github.com> --- doc/en/example/parametrize.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index c3848db5112..a7ec55dff0a 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -352,7 +352,7 @@ The first invocation with ``db == "DB1"`` passed while the second with ``db == " Indirect parametrization --------------------------------------------------- -Using the ``indirect=True`` parameter when parametrizing a test allows to +Using the ``indirect=True`` parameter when parametrizing a test allows one to parametrize a test with a fixture receiving the values before passing them to a test: From 4c161aba8ecaab9940040702369025595d26564c Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 18:52:02 -0300 Subject: [PATCH 06/10] pytester: avoid unraisableexception gc collects in inline runs to speed up test suite (#13525) (#13526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Because `pytester.runpytest()` executes the full session cycle (including `pytest_unconfigure`), it was calling `gc.collect()` in a loop multiple times—even for small, fast tests. This significantly increased the total test suite runtime. To optimize performance, disable the gc runs in inline pytester runs entirely, matching the behavior before #12958. Locally the test suite runtime improved dramatically, dropping from 425s to 160s. Fixes #13482. (cherry picked from commit 391324e13a6589f5d824d053240d4b9a4139a115) Co-authored-by: Ran Benita Co-authored-by: Bruno Oliveira --- src/_pytest/pytester.py | 10 ++++-- src/_pytest/unraisableexception.py | 15 ++++++--- testing/test_config.py | 3 +- testing/test_unraisableexception.py | 51 +++++++++++++---------------- 4 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 38f4643bd8b..59d2b0befe9 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1092,6 +1092,8 @@ def inline_run( Typically we reraise keyboard interrupts from the child run. If True, the KeyboardInterrupt exception is captured. """ + from _pytest.unraisableexception import gc_collect_iterations_key + # (maybe a cpython bug?) the importlib cache sometimes isn't updated # properly between file creation and inline_run (especially if imports # are interspersed with file creation) @@ -1115,12 +1117,16 @@ def inline_run( rec = [] - class Collect: + class PytesterHelperPlugin: @staticmethod def pytest_configure(config: Config) -> None: rec.append(self.make_hook_recorder(config.pluginmanager)) - plugins.append(Collect()) + # The unraisable plugin GC collect slows down inline + # pytester runs too much. + config.stash[gc_collect_iterations_key] = 0 + + plugins.append(PytesterHelperPlugin()) ret = main([str(x) for x in args], plugins=plugins) if len(rec) == 1: reprec = rec.pop() diff --git a/src/_pytest/unraisableexception.py b/src/_pytest/unraisableexception.py index 7826aeccd12..0faca36aa00 100644 --- a/src/_pytest/unraisableexception.py +++ b/src/_pytest/unraisableexception.py @@ -24,10 +24,12 @@ from exceptiongroup import ExceptionGroup -def gc_collect_harder() -> None: - # A single collection doesn't necessarily collect everything. - # Constant determined experimentally by the Trio project. - for _ in range(5): +# This is a stash item and not a simple constant to allow pytester to override it. +gc_collect_iterations_key = StashKey[int]() + + +def gc_collect_harder(iterations: int) -> None: + for _ in range(iterations): gc.collect() @@ -84,9 +86,12 @@ def collect_unraisable(config: Config) -> None: def cleanup( *, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object] ) -> None: + # A single collection doesn't necessarily collect everything. + # Constant determined experimentally by the Trio project. + gc_collect_iterations = config.stash.get(gc_collect_iterations_key, 5) try: try: - gc_collect_harder() + gc_collect_harder(gc_collect_iterations) collect_unraisable(config) finally: sys.unraisablehook = prev_hook diff --git a/testing/test_config.py b/testing/test_config.py index bb08c40fef4..3e8635fd1fc 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -2175,7 +2175,8 @@ class DummyPlugin: plugins = config.invocation_params.plugins assert len(plugins) == 2 assert plugins[0] is plugin - assert type(plugins[1]).__name__ == "Collect" # installed by pytester.inline_run() + # Installed by pytester.inline_run(). + assert type(plugins[1]).__name__ == "PytesterHelperPlugin" # args cannot be None with pytest.raises(TypeError): diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index 6c0dc542e93..a6a4d6f35e8 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -1,7 +1,5 @@ from __future__ import annotations -from collections.abc import Generator -import contextlib import gc import sys from unittest import mock @@ -229,19 +227,13 @@ def _set_gc_state(enabled: bool) -> bool: return was_enabled -@contextlib.contextmanager -def _disable_gc() -> Generator[None]: - was_enabled = _set_gc_state(enabled=False) - try: - yield - finally: - _set_gc_state(enabled=was_enabled) - - def test_refcycle_unraisable(pytester: Pytester) -> None: # see: https://github.com/pytest-dev/pytest/issues/10404 pytester.makepyfile( test_it=""" + # Should catch the unraisable exception even if gc is disabled. + import gc; gc.disable() + import pytest class BrokenDel: @@ -256,23 +248,22 @@ def test_it(): """ ) - with _disable_gc(): - result = pytester.runpytest() + result = pytester.runpytest_subprocess( + "-Wdefault::pytest.PytestUnraisableExceptionWarning" + ) - # TODO: should be a test failure or error - assert result.ret == pytest.ExitCode.INTERNAL_ERROR + assert result.ret == 0 result.assert_outcomes(passed=1) result.stderr.fnmatch_lines("ValueError: del is broken") -@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning") def test_refcycle_unraisable_warning_filter(pytester: Pytester) -> None: - # note that the host pytest warning filter is disabled and the pytester - # warning filter applies during config teardown of unraisablehook. - # see: https://github.com/pytest-dev/pytest/issues/10404 pytester.makepyfile( test_it=""" + # Should catch the unraisable exception even if gc is disabled. + import gc; gc.disable() + import pytest class BrokenDel: @@ -287,17 +278,18 @@ def test_it(): """ ) - with _disable_gc(): - result = pytester.runpytest("-Werror") + result = pytester.runpytest_subprocess( + "-Werror::pytest.PytestUnraisableExceptionWarning" + ) - # TODO: should be a test failure or error - assert result.ret == pytest.ExitCode.INTERNAL_ERROR + # TODO: Should be a test failure or error. Currently the exception + # propagates all the way to the top resulting in exit code 1. + assert result.ret == 1 result.assert_outcomes(passed=1) result.stderr.fnmatch_lines("ValueError: del is broken") -@pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning") def test_create_task_raises_unraisable_warning_filter(pytester: Pytester) -> None: # note that the host pytest warning filter is disabled and the pytester # warning filter applies during config teardown of unraisablehook. @@ -306,6 +298,9 @@ def test_create_task_raises_unraisable_warning_filter(pytester: Pytester) -> Non # the issue pytester.makepyfile( test_it=""" + # Should catch the unraisable exception even if gc is disabled. + import gc; gc.disable() + import asyncio import pytest @@ -318,11 +313,11 @@ def test_scheduler_must_be_created_within_running_loop() -> None: """ ) - with _disable_gc(): - result = pytester.runpytest("-Werror") + result = pytester.runpytest_subprocess("-Werror") - # TODO: should be a test failure or error - assert result.ret == pytest.ExitCode.INTERNAL_ERROR + # TODO: Should be a test failure or error. Currently the exception + # propagates all the way to the top resulting in exit code 1. + assert result.ret == 1 result.assert_outcomes(passed=1) result.stderr.fnmatch_lines("RuntimeWarning: coroutine 'my_task' was never awaited") From a1b3a7879589eb437e4fd97c169b228c3ed58c63 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 18:11:56 -0300 Subject: [PATCH 07/10] Fix compatibility with Twisted 25 (#13502) (#13531) As discussed in https://github.com/pytest-dev/pytest/pull/13502, the fix for compatibility with Twisted 25+ is simpler. Therefore, it makes sense to implement both fixes (for Twisted 24 and Twisted 25) in parallel. This way, we can eventually drop support for Twisted <25 and keep only the simpler workaround. In addition, the `unittestextras` tox environment has been replaced with dedicated test environments for `asynctest`, `Twisted 24`, and `Twisted 25`. Fixes #13497 (cherry picked from commit 01dce85a89eb0b3e881303784267f14b94d9691f) Co-authored-by: Bruno Oliveira --- .github/workflows/test.yml | 43 +++++++++- changelog/13497.bugfix.rst | 1 + src/_pytest/unittest.py | 155 ++++++++++++++++++++++++++++--------- tox.ini | 19 +++-- 4 files changed, 174 insertions(+), 44 deletions(-) create mode 100644 changelog/13497.bugfix.rst diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3ecc133878f..7873b66a49a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,7 +54,9 @@ jobs: fail-fast: false matrix: name: [ - "windows-py39-unittestextras", + "windows-py39-unittest-asynctest", + "windows-py39-unittest-twisted24", + "windows-py39-unittest-twisted25", "windows-py39-pluggy", "windows-py39-xdist", "windows-py310", @@ -63,6 +65,9 @@ jobs: "windows-py313", "windows-py314", + "ubuntu-py39-unittest-asynctest", + "ubuntu-py39-unittest-twisted24", + "ubuntu-py39-unittest-twisted25", "ubuntu-py39-lsof-numpy-pexpect", "ubuntu-py39-pluggy", "ubuntu-py39-freeze", @@ -85,10 +90,23 @@ jobs: ] include: - - name: "windows-py39-unittestextras" + # Use separate jobs for different unittest flavors (twisted, asynctest) to ensure proper coverage. + - name: "windows-py39-unittest-asynctest" python: "3.9" os: windows-latest - tox_env: "py39-unittestextras" + tox_env: "py39-asynctest" + use_coverage: true + + - name: "windows-py39-unittest-twisted24" + python: "3.9" + os: windows-latest + tox_env: "py39-twisted24" + use_coverage: true + + - name: "windows-py39-unittest-twisted25" + python: "3.9" + os: windows-latest + tox_env: "py39-twisted25" use_coverage: true - name: "windows-py39-pluggy" @@ -126,6 +144,25 @@ jobs: os: windows-latest tox_env: "py314" + # Use separate jobs for different unittest flavors (twisted, asynctest) to ensure proper coverage. + - name: "ubuntu-py39-unittest-asynctest" + python: "3.9" + os: ubuntu-latest + tox_env: "py39-asynctest" + use_coverage: true + + - name: "ubuntu-py39-unittest-twisted24" + python: "3.9" + os: ubuntu-latest + tox_env: "py39-twisted24" + use_coverage: true + + - name: "ubuntu-py39-unittest-twisted25" + python: "3.9" + os: ubuntu-latest + tox_env: "py39-twisted25" + use_coverage: true + - name: "ubuntu-py39-lsof-numpy-pexpect" python: "3.9" os: ubuntu-latest diff --git a/changelog/13497.bugfix.rst b/changelog/13497.bugfix.rst new file mode 100644 index 00000000000..75b4996e5af --- /dev/null +++ b/changelog/13497.bugfix.rst @@ -0,0 +1 @@ +Fixed compatibility with ``Twisted 25+``. diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 04d50b53090..ef6ef64a062 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -6,11 +6,13 @@ from collections.abc import Callable from collections.abc import Generator from collections.abc import Iterable +from collections.abc import Iterator +from enum import auto +from enum import Enum import inspect import sys import traceback import types -from typing import Any from typing import TYPE_CHECKING from typing import Union @@ -18,6 +20,7 @@ from _pytest.compat import is_async_function from _pytest.config import hookimpl from _pytest.fixtures import FixtureRequest +from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector from _pytest.nodes import Item from _pytest.outcomes import exit @@ -228,8 +231,7 @@ def startTest(self, testcase: unittest.TestCase) -> None: pass def _addexcinfo(self, rawexcinfo: _SysExcInfoType) -> None: - # Unwrap potential exception info (see twisted trial support below). - rawexcinfo = getattr(rawexcinfo, "_rawexcinfo", rawexcinfo) + rawexcinfo = _handle_twisted_exc_info(rawexcinfo) try: excinfo = _pytest._code.ExceptionInfo[BaseException].from_exc_info( rawexcinfo # type: ignore[arg-type] @@ -385,49 +387,130 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None: call.excinfo = call2.excinfo -# Twisted trial support. -classImplements_has_run = False +def _is_skipped(obj) -> bool: + """Return True if the given object has been marked with @unittest.skip.""" + return bool(getattr(obj, "__unittest_skip__", False)) + + +def pytest_configure() -> None: + """Register the TestCaseFunction class as an IReporter if twisted.trial is available.""" + if _get_twisted_version() is not TwistedVersion.NotInstalled: + from twisted.trial.itrial import IReporter + from zope.interface import classImplements + + classImplements(TestCaseFunction, IReporter) + + +class TwistedVersion(Enum): + """ + The Twisted version installed in the environment. + + We have different workarounds in place for different versions of Twisted. + """ + + # Twisted version 24 or prior. + Version24 = auto() + # Twisted version 25 or later. + Version25 = auto() + # Twisted version is not available. + NotInstalled = auto() + + +def _get_twisted_version() -> TwistedVersion: + # We need to check if "twisted.trial.unittest" is specifically present in sys.modules. + # This is because we intend to integrate with Trial only when it's actively running + # the test suite, but not needed when only other Twisted components are in use. + if "twisted.trial.unittest" not in sys.modules: + return TwistedVersion.NotInstalled + + import importlib.metadata + + import packaging.version + + version_str = importlib.metadata.version("twisted") + version = packaging.version.parse(version_str) + if version.major <= 24: + return TwistedVersion.Version24 + else: + return TwistedVersion.Version25 + + +# Name of the attribute in `twisted.python.Failure` instances that stores +# the `sys.exc_info()` tuple. +# See twisted.trial support in `pytest_runtest_protocol`. +TWISTED_RAW_EXCINFO_ATTR = "_twisted_raw_excinfo" @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"] - global classImplements_has_run - Failure__init__ = ut.Failure.__init__ - if not classImplements_has_run: - from twisted.trial.itrial import IReporter - from zope.interface import classImplements - - classImplements(TestCaseFunction, IReporter) - classImplements_has_run = True - - def excstore( +def pytest_runtest_protocol(item: Item) -> Iterator[None]: + if _get_twisted_version() is TwistedVersion.Version24: + import twisted.python.failure as ut + + # Monkeypatch `Failure.__init__` to store the raw exception info. + original__init__ = ut.Failure.__init__ + + def store_raw_exception_info( self, exc_value=None, exc_type=None, exc_tb=None, captureVars=None - ): + ): # pragma: no cover if exc_value is None: - self._rawexcinfo = sys.exc_info() + raw_exc_info = sys.exc_info() else: if exc_type is None: exc_type = type(exc_value) - self._rawexcinfo = (exc_type, exc_value, exc_tb) + if exc_tb is None: + exc_tb = sys.exc_info()[2] + raw_exc_info = (exc_type, exc_value, exc_tb) + setattr(self, TWISTED_RAW_EXCINFO_ATTR, tuple(raw_exc_info)) try: - Failure__init__( + original__init__( self, exc_value, exc_type, exc_tb, captureVars=captureVars ) - except TypeError: - Failure__init__(self, exc_value, exc_type, exc_tb) + except TypeError: # pragma: no cover + original__init__(self, exc_value, exc_type, exc_tb) - ut.Failure.__init__ = excstore - try: - res = yield - finally: - ut.Failure.__init__ = Failure__init__ + with MonkeyPatch.context() as patcher: + patcher.setattr(ut.Failure, "__init__", store_raw_exception_info) + return (yield) else: - res = yield - return res - - -def _is_skipped(obj) -> bool: - """Return True if the given object has been marked with @unittest.skip.""" - return bool(getattr(obj, "__unittest_skip__", False)) + return (yield) + + +def _handle_twisted_exc_info( + rawexcinfo: _SysExcInfoType | BaseException, +) -> _SysExcInfoType: + """ + Twisted passes a custom Failure instance to `addError()` instead of using `sys.exc_info()`. + Therefore, if `rawexcinfo` is a `Failure` instance, convert it into the equivalent `sys.exc_info()` tuple + as expected by pytest. + """ + twisted_version = _get_twisted_version() + if twisted_version is TwistedVersion.NotInstalled: + # Unfortunately, because we cannot import `twisted.python.failure` at the top of the file + # and use it in the signature, we need to use `type:ignore` here because we cannot narrow + # the type properly in the `if` statement above. + return rawexcinfo # type:ignore[return-value] + elif twisted_version is TwistedVersion.Version24: + # Twisted calls addError() passing its own classes (like `twisted.python.Failure`), which violates + # the `addError()` signature, so we extract the original `sys.exc_info()` tuple which is stored + # in the object. + if hasattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR): + saved_exc_info = getattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR) + # Delete the attribute from the original object to avoid leaks. + delattr(rawexcinfo, TWISTED_RAW_EXCINFO_ATTR) + return saved_exc_info # type:ignore[no-any-return] + return rawexcinfo # type:ignore[return-value] + elif twisted_version is TwistedVersion.Version25: + if isinstance(rawexcinfo, BaseException): + import twisted.python.failure + + if isinstance(rawexcinfo, twisted.python.failure.Failure): + tb = rawexcinfo.__traceback__ + if tb is None: + tb = sys.exc_info()[2] + return type(rawexcinfo.value), rawexcinfo.value, tb + + return rawexcinfo # type:ignore[return-value] + else: + # Ideally we would use assert_never() here, but it is not available in all Python versions + # we support, plus we do not require `type_extensions` currently. + assert False, f"Unexpected Twisted version: {twisted_version}" diff --git a/tox.ini b/tox.ini index 8f7d8495285..f1283aa8260 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ envlist = py313 py314 pypy3 - py39-{pexpect,xdist,unittestextras,numpy,pluggymain,pylib} + py39-{pexpect,xdist,twisted24,twisted25,asynctest,numpy,pluggymain,pylib} doctesting doctesting-coverage plugins @@ -36,7 +36,9 @@ description = pexpect: against `pexpect` pluggymain: against the bleeding edge `pluggy` from Git pylib: against `py` lib - unittestextras: against the unit test extras + twisted24: against the unit test extras with twisted prior to 24.0 + twisted25: against the unit test extras with twisted 25.0 or later + asynctest: against the unit test extras with asynctest xdist: with pytest in parallel mode under `{basepython}` doctesting: including doctests @@ -51,7 +53,7 @@ passenv = TERM SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTEST setenv = - _PYTEST_TOX_DEFAULT_POSARGS={env:_PYTEST_TOX_POSARGS_DOCTESTING:} {env:_PYTEST_TOX_POSARGS_LSOF:} {env:_PYTEST_TOX_POSARGS_XDIST:} + _PYTEST_TOX_DEFAULT_POSARGS={env:_PYTEST_TOX_POSARGS_DOCTESTING:} {env:_PYTEST_TOX_POSARGS_LSOF:} {env:_PYTEST_TOX_POSARGS_XDIST:} {env:_PYTEST_FILES:} # See https://docs.python.org/3/library/io.html#io-encoding-warning # If we don't enable this, neither can any of our downstream users! @@ -66,6 +68,12 @@ setenv = doctesting: _PYTEST_TOX_POSARGS_DOCTESTING=doc/en + # The configurations below are related only to standard unittest support. + # Run only tests from test_unittest.py. + asynctest: _PYTEST_FILES=testing/test_unittest.py + twisted24: _PYTEST_FILES=testing/test_unittest.py + twisted25: _PYTEST_FILES=testing/test_unittest.py + nobyte: PYTHONDONTWRITEBYTECODE=1 lsof: _PYTEST_TOX_POSARGS_LSOF=--lsof @@ -79,8 +87,9 @@ deps = pexpect: pexpect>=4.8.0 pluggymain: pluggy @ git+https://github.com/pytest-dev/pluggy.git pylib: py>=1.8.2 - unittestextras: twisted - unittestextras: asynctest + twisted24: twisted<25 + twisted25: twisted>=25 + asynctest: asynctest xdist: pytest-xdist>=2.1.0 xdist: -e . {env:_PYTEST_TOX_EXTRA_DEP:} From d0c7ed0bfae5a5f1f9153cd1e464a421d701e925 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 21:35:14 +0000 Subject: [PATCH 08/10] Reintroduce PytestReturnNotNoneWarning (#13495) (#13527) Since this warning is meant to be permanent, added proper documentation to the `assert` section in the docs. Fixes #13477 (cherry picked from commit 53f05c44d9530c4ac5ce5804ef75fe61713d46d8) Co-authored-by: Bruno Oliveira --- changelog/13477.bugfix.rst | 5 ++++ doc/en/deprecations.rst | 40 ------------------------------- doc/en/how-to/assert.rst | 44 ++++++++++++++++++++++++++++++++++ doc/en/reference/reference.rst | 3 +++ src/_pytest/python.py | 13 +++++----- src/_pytest/warning_types.py | 11 +++++++++ src/pytest/__init__.py | 2 ++ testing/acceptance_test.py | 4 ++-- 8 files changed, 74 insertions(+), 48 deletions(-) create mode 100644 changelog/13477.bugfix.rst diff --git a/changelog/13477.bugfix.rst b/changelog/13477.bugfix.rst new file mode 100644 index 00000000000..7b7f875df09 --- /dev/null +++ b/changelog/13477.bugfix.rst @@ -0,0 +1,5 @@ +Reintroduced :class:`pytest.PytestReturnNotNoneWarning` which was removed by accident in pytest `8.4`. + +This warning is raised when a test functions returns a value other than ``None``, which is often a mistake made by beginners. + +See :ref:`return-not-none` for more information. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 18df64c9204..6897fb28fc1 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -316,46 +316,6 @@ Users expected in this case that the ``usefixtures`` mark would have its intende Now pytest will issue a warning when it encounters this problem, and will raise an error in the future versions. -Returning non-None value in test functions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 7.2 - -A ``pytest.PytestReturnNotNoneWarning`` is now emitted if a test function returns something other than `None`. - -This prevents a common mistake among beginners that expect that returning a `bool` would cause a test to pass or fail, for example: - -.. code-block:: python - - @pytest.mark.parametrize( - ["a", "b", "result"], - [ - [1, 2, 5], - [2, 3, 8], - [5, 3, 18], - ], - ) - def test_foo(a, b, result): - return foo(a, b) == result - -Given that pytest ignores the return value, this might be surprising that it will never fail. - -The proper fix is to change the `return` to an `assert`: - -.. code-block:: python - - @pytest.mark.parametrize( - ["a", "b", "result"], - [ - [1, 2, 5], - [2, 3, 8], - [5, 3, 18], - ], - ) - def test_foo(a, b, result): - assert foo(a, b) == result - - The ``yield_fixture`` function/decorator ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/en/how-to/assert.rst b/doc/en/how-to/assert.rst index 6bc8f6fed33..c83fb93c4b1 100644 --- a/doc/en/how-to/assert.rst +++ b/doc/en/how-to/assert.rst @@ -476,6 +476,50 @@ the conftest file: FAILED test_foocompare.py::test_compare - assert Comparing Foo instances: 1 failed in 0.12s +.. _`return-not-none`: + +Returning non-None value in test functions +------------------------------------------ + +A :class:`pytest.PytestReturnNotNoneWarning` is emitted when a test function returns a value other than ``None``. + +This helps prevent a common mistake made by beginners who assume that returning a ``bool`` (e.g., ``True`` or ``False``) will determine whether a test passes or fails. + +Example: + +.. code-block:: python + + @pytest.mark.parametrize( + ["a", "b", "result"], + [ + [1, 2, 5], + [2, 3, 8], + [5, 3, 18], + ], + ) + def test_foo(a, b, result): + return foo(a, b) == result # Incorrect usage, do not do this. + +Since pytest ignores return values, it might be surprising that the test will never fail based on the returned value. + +The correct fix is to replace the ``return`` statement with an ``assert``: + +.. code-block:: python + + @pytest.mark.parametrize( + ["a", "b", "result"], + [ + [1, 2, 5], + [2, 3, 8], + [5, 3, 18], + ], + ) + def test_foo(a, b, result): + assert foo(a, b) == result + + + + .. _assert-details: .. _`assert introspection`: diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index b0535ef6589..cfdc3eb3421 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1274,6 +1274,9 @@ Custom warnings generated in some situations such as improper usage or deprecate .. autoclass:: pytest.PytestExperimentalApiWarning :show-inheritance: +.. autoclass:: pytest.PytestReturnNotNoneWarning + :show-inheritance: + .. autoclass:: pytest.PytestRemovedIn9Warning :show-inheritance: diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 82aab85a300..8e4fb041532 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -73,6 +73,7 @@ from _pytest.scope import Scope from _pytest.stash import StashKey from _pytest.warning_types import PytestCollectionWarning +from _pytest.warning_types import PytestReturnNotNoneWarning if TYPE_CHECKING: @@ -157,12 +158,12 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: if hasattr(result, "__await__") or hasattr(result, "__aiter__"): async_fail(pyfuncitem.nodeid) elif result is not None: - fail( - ( - f"Expected None, but test returned {result!r}. " - "Did you mean to use `assert` instead of `return`?" - ), - pytrace=False, + warnings.warn( + PytestReturnNotNoneWarning( + f"Test functions should return None, but {pyfuncitem.nodeid} returned {type(result)!r}.\n" + "Did you mean to use `assert` instead of `return`?\n" + "See https://docs.pytest.org/en/stable/how-to/assert.html#return-not-none for more information." + ) ) return True diff --git a/src/_pytest/warning_types.py b/src/_pytest/warning_types.py index 8c9ff2d9a36..5e78debb682 100644 --- a/src/_pytest/warning_types.py +++ b/src/_pytest/warning_types.py @@ -71,6 +71,17 @@ def simple(cls, apiname: str) -> PytestExperimentalApiWarning: return cls(f"{apiname} is an experimental api that may change over time") +@final +class PytestReturnNotNoneWarning(PytestWarning): + """ + Warning emitted when a test function returns a value other than ``None``. + + See :ref:`return-not-none` for details. + """ + + __module__ = "pytest" + + @final class PytestUnknownMarkWarning(PytestWarning): """Warning emitted on use of unknown markers. diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index e36d3e704c1..297b524bcc2 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -82,6 +82,7 @@ from _pytest.warning_types import PytestExperimentalApiWarning from _pytest.warning_types import PytestFDWarning from _pytest.warning_types import PytestRemovedIn9Warning +from _pytest.warning_types import PytestReturnNotNoneWarning from _pytest.warning_types import PytestUnhandledThreadExceptionWarning from _pytest.warning_types import PytestUnknownMarkWarning from _pytest.warning_types import PytestUnraisableExceptionWarning @@ -132,6 +133,7 @@ "PytestFDWarning", "PytestPluginManager", "PytestRemovedIn9Warning", + "PytestReturnNotNoneWarning", "PytestUnhandledThreadExceptionWarning", "PytestUnknownMarkWarning", "PytestUnraisableExceptionWarning", diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 4948e3ff8ae..2101fe9ad76 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1489,7 +1489,8 @@ def test_no_brokenpipeerror_message(pytester: Pytester) -> None: popen.stderr.close() -def test_function_return_non_none_error(pytester: Pytester) -> None: +@pytest.mark.filterwarnings("default") +def test_function_return_non_none_warning(pytester: Pytester) -> None: pytester.makepyfile( """ def test_stuff(): @@ -1497,7 +1498,6 @@ def test_stuff(): """ ) res = pytester.runpytest() - res.assert_outcomes(failed=1) res.stdout.fnmatch_lines(["*Did you mean to use `assert` instead of `return`?*"]) From 5dc5880715633c97310c0593f0ae356de27fa933 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 21:39:34 +0000 Subject: [PATCH 09/10] docs: update pytest.ini addopts example to use separate -p entries (#13529) (#13532) (cherry picked from commit 40a1713d42187732d0095dd1623b1424fb2bc489) Co-authored-by: Iwithyou2025 --- doc/en/how-to/plugins.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/en/how-to/plugins.rst b/doc/en/how-to/plugins.rst index fca8ab54e63..b2cb1cda5c3 100644 --- a/doc/en/how-to/plugins.rst +++ b/doc/en/how-to/plugins.rst @@ -154,7 +154,10 @@ manually specify each plugin with ``-p`` or :envvar:`PYTEST_PLUGINS`, you can us .. code-block:: ini [pytest] - addopts = --disable-plugin-autoload -p NAME,NAME2 + addopts = + --disable-plugin-autoload + -p NAME + -p NAME2 .. versionadded:: 8.4 From 8d99211f0ce3927eb7ee579f7b4f969da06dc787 Mon Sep 17 00:00:00 2001 From: pytest bot Date: Tue, 17 Jun 2025 21:56:14 +0000 Subject: [PATCH 10/10] Prepare release version 8.4.1 --- changelog/13461.bugfix.rst | 3 --- changelog/13477.bugfix.rst | 5 ----- changelog/13492.doc.rst | 1 - changelog/13497.bugfix.rst | 1 - doc/en/announce/index.rst | 1 + doc/en/announce/release-8.4.1.rst | 21 +++++++++++++++++++++ doc/en/changelog.rst | 28 ++++++++++++++++++++++++++++ doc/en/example/parametrize.rst | 6 +++--- doc/en/example/pythoncollection.rst | 4 ++-- doc/en/getting-started.rst | 2 +- doc/en/how-to/fixtures.rst | 2 +- 11 files changed, 57 insertions(+), 17 deletions(-) delete mode 100644 changelog/13461.bugfix.rst delete mode 100644 changelog/13477.bugfix.rst delete mode 100644 changelog/13492.doc.rst delete mode 100644 changelog/13497.bugfix.rst create mode 100644 doc/en/announce/release-8.4.1.rst diff --git a/changelog/13461.bugfix.rst b/changelog/13461.bugfix.rst deleted file mode 100644 index 4b45f7cc9da..00000000000 --- a/changelog/13461.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Corrected ``_pytest.terminal.TerminalReporter.isatty`` to support -being called as a method. Before it was just a boolean which could -break correct code when using ``-o log_cli=true``). diff --git a/changelog/13477.bugfix.rst b/changelog/13477.bugfix.rst deleted file mode 100644 index 7b7f875df09..00000000000 --- a/changelog/13477.bugfix.rst +++ /dev/null @@ -1,5 +0,0 @@ -Reintroduced :class:`pytest.PytestReturnNotNoneWarning` which was removed by accident in pytest `8.4`. - -This warning is raised when a test functions returns a value other than ``None``, which is often a mistake made by beginners. - -See :ref:`return-not-none` for more information. diff --git a/changelog/13492.doc.rst b/changelog/13492.doc.rst deleted file mode 100644 index 6e3f72a860e..00000000000 --- a/changelog/13492.doc.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed outdated warning about ``faulthandler`` not working on Windows. diff --git a/changelog/13497.bugfix.rst b/changelog/13497.bugfix.rst deleted file mode 100644 index 75b4996e5af..00000000000 --- a/changelog/13497.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed compatibility with ``Twisted 25+``. diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index 702fd26dd0d..2ed23eb1fbb 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-8.4.1 release-8.4.0 release-8.3.5 release-8.3.4 diff --git a/doc/en/announce/release-8.4.1.rst b/doc/en/announce/release-8.4.1.rst new file mode 100644 index 00000000000..07ee26187a7 --- /dev/null +++ b/doc/en/announce/release-8.4.1.rst @@ -0,0 +1,21 @@ +pytest-8.4.1 +======================================= + +pytest 8.4.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Iwithyou2025 +* John Litborn +* Martin Fischer +* Ran Benita +* SarahPythonista + + +Happy testing, +The pytest Development Team diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index 73073ffca1c..63623de8aad 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -31,6 +31,34 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 8.4.1 (2025-06-17) +========================= + +Bug fixes +--------- + +- `#13461 `_: Corrected ``_pytest.terminal.TerminalReporter.isatty`` to support + being called as a method. Before it was just a boolean which could + break correct code when using ``-o log_cli=true``). + + +- `#13477 `_: Reintroduced :class:`pytest.PytestReturnNotNoneWarning` which was removed by accident in pytest `8.4`. + + This warning is raised when a test functions returns a value other than ``None``, which is often a mistake made by beginners. + + See :ref:`return-not-none` for more information. + + +- `#13497 `_: Fixed compatibility with ``Twisted 25+``. + + + +Improved documentation +---------------------- + +- `#13492 `_: Fixed outdated warning about ``faulthandler`` not working on Windows. + + pytest 8.4.0 (2025-06-02) ========================= diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index a7ec55dff0a..8e6479254bb 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -162,7 +162,7 @@ objects, they are still using the default pytest representation: rootdir: /home/sweet/project collected 8 items - + @@ -239,7 +239,7 @@ If you just collect tests you'll also nicely see 'advanced' and 'basic' as varia rootdir: /home/sweet/project collected 4 items - + @@ -318,7 +318,7 @@ Let's first see how it looks like at collection time: rootdir: /home/sweet/project collected 2 items - + diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index 760140390cb..01a8f48fdbf 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -152,7 +152,7 @@ The test collection would look like this: configfile: pytest.ini collected 2 items - + @@ -215,7 +215,7 @@ You can always peek at the collection tree without running tests like this: configfile: pytest.ini collected 3 items - + diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index c92e97f556e..c5e9f708828 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -22,7 +22,7 @@ Install ``pytest`` .. code-block:: bash $ pytest --version - pytest 8.4.0 + pytest 8.4.1 .. _`simpletest`: diff --git a/doc/en/how-to/fixtures.rst b/doc/en/how-to/fixtures.rst index a86fd1065fd..62ace745d43 100644 --- a/doc/en/how-to/fixtures.rst +++ b/doc/en/how-to/fixtures.rst @@ -1423,7 +1423,7 @@ Running the above tests results in the following test IDs being used: rootdir: /home/sweet/project collected 12 items - +