From fcc0d4a27477f77e90af96c1c11dd6fdcc36766b Mon Sep 17 00:00:00 2001 From: kelliott1 Date: Mon, 6 Apr 2026 15:20:42 -0400 Subject: [PATCH 1/5] Fix RaisesGroup calling check() on contained exceptions instead of ExceptionGroup --- src/_pytest/raises.py | 29 +++++++++++++++++++++++++---- testing/python/raises_group.py | 19 +++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/_pytest/raises.py b/src/_pytest/raises.py index 75eea7d8cc9..ab690310df7 100644 --- a/src/_pytest/raises.py +++ b/src/_pytest/raises.py @@ -1207,14 +1207,35 @@ def matches( reason = ( cast(str, self._fail_reason) + f" on the {type(exception).__name__}" ) + + suggest_subexception_check = False if ( - len(actual_exceptions) == len(self.expected_exceptions) == 1 + self.check is not None + and len(actual_exceptions) == len(self.expected_exceptions) == 1 and isinstance(expected := self.expected_exceptions[0], type) - # we explicitly break typing here :) - and self._check_check(actual_exceptions[0]) # type: ignore[arg-type] + and isinstance(actual_exceptions[0], expected) ): + annotations = getattr(self.check, "__annotations__", {}) + param_names = [name for name in annotations if name != "return"] + if param_names: + param_annotation = annotations[param_names[0]] + + if isinstance(param_annotation, str): + suggest_subexception_check = ( + "ExceptionGroup" not in param_annotation + and "BaseExceptionGroup" not in param_annotation + ) + else: + origin = get_origin(param_annotation) or param_annotation + if isinstance(origin, type): + suggest_subexception_check = not issubclass( + origin, BaseExceptionGroup + ) + + if suggest_subexception_check: self._fail_reason = reason + ( - f", but did return True for the expected {self._repr_expected(expected)}." + f", but the single contained exception matches the expected " + f"{self._repr_expected(expected)}." f" You might want RaisesGroup(RaisesExc({expected.__name__}, check=<...>))" ) else: diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py index e5e3b5cd2dc..e19873029ec 100644 --- a/testing/python/raises_group.py +++ b/testing/python/raises_group.py @@ -412,9 +412,12 @@ def is_exc(e: ExceptionGroup[ValueError]) -> bool: return e is exc is_exc_repr = repr_callable(is_exc) + + # this should pass (same object) with RaisesGroup(ValueError, check=is_exc): raise exc + # this should fail WITHOUT suggestion with ( fails_raises_group( f"check {is_exc_repr} did not return True on the ExceptionGroup" @@ -426,16 +429,28 @@ def is_exc(e: ExceptionGroup[ValueError]) -> bool: def is_value_error(e: BaseException) -> bool: return isinstance(e, ValueError) - # helpful suggestion if the user thinks the check is for the sub-exception + # this should fail WITH suggestion (because check looks like it's for inner exception) with ( fails_raises_group( - f"check {is_value_error} did not return True on the ExceptionGroup, but did return True for the expected ValueError. You might want RaisesGroup(RaisesExc(ValueError, check=<...>))" + f"check {is_value_error} did not return True on the ExceptionGroup, but the single contained exception matches the expected ValueError. You might want RaisesGroup(RaisesExc(ValueError, check=<...>))" ), RaisesGroup(ValueError, check=is_value_error), ): raise ExceptionGroup("", (ValueError(),)) +def test_check_called_only_with_group() -> None: + seen = [] + + def check(exc_group: ExceptionGroup[ValueError]) -> bool: + seen.append(type(exc_group)) + return len(exc_group.exceptions) == 1 + + with RaisesGroup(ValueError, match="Main message", check=check): + raise ExceptionGroup("Main message", [ValueError("foo")]) + + assert seen == [ExceptionGroup] + def test_unwrapped_match_check() -> None: def my_check(e: object) -> bool: # pragma: no cover return True From c1f638ed674073fe35120465e43efe64b397351a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:29:41 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/python/raises_group.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py index e19873029ec..ce3bcf14d70 100644 --- a/testing/python/raises_group.py +++ b/testing/python/raises_group.py @@ -451,6 +451,7 @@ def check(exc_group: ExceptionGroup[ValueError]) -> bool: assert seen == [ExceptionGroup] + def test_unwrapped_match_check() -> None: def my_check(e: object) -> bool: # pragma: no cover return True From 87a6edc13daa264f740d2e151df0fbb7b93a3e4e Mon Sep 17 00:00:00 2001 From: kelliott1 Date: Mon, 6 Apr 2026 15:34:53 -0400 Subject: [PATCH 3/5] Added changelog entry per repo guidelines --- changelog/14324.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/14324.bugfix.rst diff --git a/changelog/14324.bugfix.rst b/changelog/14324.bugfix.rst new file mode 100644 index 00000000000..9bba1a8efa5 --- /dev/null +++ b/changelog/14324.bugfix.rst @@ -0,0 +1 @@ +Fix ``pytest.RaisesGroup`` incorrectly calling the ``check`` callback with contained exceptions instead of only the exception group. \ No newline at end of file From 503c50bd2de0c4ad339bf70936ca5f64ff7c3dc2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:41:33 +0000 Subject: [PATCH 4/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- changelog/14324.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/14324.bugfix.rst b/changelog/14324.bugfix.rst index 9bba1a8efa5..11c40192b67 100644 --- a/changelog/14324.bugfix.rst +++ b/changelog/14324.bugfix.rst @@ -1 +1 @@ -Fix ``pytest.RaisesGroup`` incorrectly calling the ``check`` callback with contained exceptions instead of only the exception group. \ No newline at end of file +Fix ``pytest.RaisesGroup`` incorrectly calling the ``check`` callback with contained exceptions instead of only the exception group. From 269551ef614a28c16d4391871518a47a4aa141a7 Mon Sep 17 00:00:00 2001 From: evanwilson2123 Date: Sun, 19 Apr 2026 11:07:40 -0400 Subject: [PATCH 5/5] Initial fix for issue 14112-pytest crashing with ptest-xdist --- src/_pytest/assertion/rewrite.py | 76 +++++++++++++++++--------------- testing/test_assertrewrite.py | 33 ++++++++++++++ 2 files changed, 73 insertions(+), 36 deletions(-) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 566549d66f2..b2c550ae4f5 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -371,42 +371,46 @@ def _read_pyc( fp = open(pyc, "rb") except OSError: return None - with fp: - try: - stat_result = os.stat(source) - mtime = int(stat_result.st_mtime) - size = stat_result.st_size - data = fp.read(16) - except OSError as e: - trace(f"_read_pyc({source}): OSError {e}") - return None - # Check for invalid or out of date pyc file. - if len(data) != (16): - trace(f"_read_pyc({source}): invalid pyc (too short)") - return None - if data[:4] != importlib.util.MAGIC_NUMBER: - trace(f"_read_pyc({source}): invalid pyc (bad magic number)") - return None - if data[4:8] != b"\x00\x00\x00\x00": - trace(f"_read_pyc({source}): invalid pyc (unsupported flags)") - return None - mtime_data = data[8:12] - if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF: - trace(f"_read_pyc({source}): out of date") - return None - size_data = data[12:16] - if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF: - trace(f"_read_pyc({source}): invalid pyc (incorrect size)") - return None - try: - co = marshal.load(fp) - except Exception as e: - trace(f"_read_pyc({source}): marshal.load error {e}") - return None - if not isinstance(co, types.CodeType): - trace(f"_read_pyc({source}): not a code object") - return None - return co + try: + with fp: + try: + stat_result = os.stat(source) + mtime = int(stat_result.st_mtime) + size = stat_result.st_size + data = fp.read(16) + except OSError as e: + trace(f"_read_pyc({source}): OSError {e}") + return None + # Check for invalid or out of date pyc file. + if len(data) != (16): + trace(f"_read_pyc({source}): invalid pyc (too short)") + return None + if data[:4] != importlib.util.MAGIC_NUMBER: + trace(f"_read_pyc({source}): invalid pyc (bad magic number)") + return None + if data[4:8] != b"\x00\x00\x00\x00": + trace(f"_read_pyc({source}): invalid pyc (unsupported flags)") + return None + mtime_data = data[8:12] + if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF: + trace(f"_read_pyc({source}): out of date") + return None + size_data = data[12:16] + if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF: + trace(f"_read_pyc({source}): invalid pyc (incorrect size)") + return None + try: + co = marshal.load(fp) + except Exception as e: + trace(f"_read_pyc({source}): marshal.load error {e}") + return None + if not isinstance(co, types.CodeType): + trace(f"_read_pyc({source}): not a code object") + return None + return co + except OSError as e: + trace(f"_read_pyc({source}): OSError {e}") + return None def rewrite_asserts( diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 92664354470..9ab7a72d0a6 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1362,6 +1362,39 @@ def test_read_pyc(self, tmp_path: Path) -> None: assert _read_pyc(source, pyc) is None # no error + def test_read_pyc_handles_context_manager_oserror(self, tmp_path: Path) -> None: + from _pytest.assertion.rewrite import _read_pyc + + source = tmp_path / "source.py" + pyc = Path(str(source) + "c") + source.write_text("def test(): pass", encoding="utf-8") + py_compile.compile(str(source), str(pyc)) + + real_open = open + + class FailingContextManager: + def __init__(self, fp) -> None: + self.fp = fp + + def __enter__(self): + return self.fp + + def __exit__(self, exc_type, exc, tb) -> None: + self.fp.close() + raise OSError(errno.EIO, "Input/output error") + + def __getattr__(self, name): + return getattr(self.fp, name) + + def mock_open(file, mode="r", *args, **kwargs): + fp = real_open(file, mode, *args, **kwargs) + if Path(file) == pyc and mode == "rb": + return FailingContextManager(fp) + return fp + + with mock.patch("builtins.open", mock_open): + assert _read_pyc(source, pyc) is None + def test_read_pyc_success(self, tmp_path: Path, pytester: Pytester) -> None: """ Ensure that the _rewrite_test() -> _write_pyc() produces a pyc file