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/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) 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/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/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/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/example/parametrize.rst b/doc/en/example/parametrize.rst index c3848db5112..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 - + @@ -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: 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/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/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:: 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 - + 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 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/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" 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/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/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/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/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/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/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`?*"]) 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_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: 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") 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:}