From 5ba54674a4ee4a1b4527a42168d8a3b849a43f27 Mon Sep 17 00:00:00 2001 From: EternalRights Date: Sat, 18 Apr 2026 20:55:43 +0800 Subject: [PATCH 1/6] Fix is_fully_escaped not handling consecutive backslashes The function only checked if the character immediately before a regex metacharacter was a backslash, but did not count how many consecutive backslashes preceded it. When two backslashes appear before a metacharacter (e.g. r'\\.'), the first escapes the second, leaving the metacharacter unescaped. The function incorrectly reported such strings as fully escaped. Fix by counting consecutive backslashes: an even count means the metacharacter is not escaped, an odd count means it is. Closes #14392 --- changelog/14392.bugfix.rst | 1 + src/_pytest/raises.py | 17 ++++++++++++++--- testing/python/raises.py | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 changelog/14392.bugfix.rst diff --git a/changelog/14392.bugfix.rst b/changelog/14392.bugfix.rst new file mode 100644 index 00000000000..ef768560467 --- /dev/null +++ b/changelog/14392.bugfix.rst @@ -0,0 +1 @@ +Fixed ``is_fully_escaped`` not handling consecutive backslashes correctly: an escaped backslash before a metacharacter (e.g. ``\\\\.``) was incorrectly treated as escaping the metacharacter itself, causing ``pytest.raises(match=...)`` to skip the regex diff display when it should have shown one. diff --git a/src/_pytest/raises.py b/src/_pytest/raises.py index 82fe2c41c96..4dc55d2c813 100644 --- a/src/_pytest/raises.py +++ b/src/_pytest/raises.py @@ -345,9 +345,20 @@ def _check_raw_type( def is_fully_escaped(s: str) -> bool: # we know we won't compile with re.VERBOSE, so whitespace doesn't need to be escaped metacharacters = "{}()+.*?^$[]|" - return not any( - c in metacharacters and (i == 0 or s[i - 1] != "\\") for (i, c) in enumerate(s) - ) + for i, c in enumerate(s): + if c in metacharacters: + # Count consecutive backslashes preceding this metacharacter. + # An odd number of backslashes means the metacharacter is escaped + # (the last backslash does the escaping); an even number means + # it is not escaped (backslashes escape each other in pairs). + n_backslashes = 0 + j = i - 1 + while j >= 0 and s[j] == "\\": + n_backslashes += 1 + j -= 1 + if n_backslashes % 2 == 0: + return False + return True def unescape(s: str) -> str: diff --git a/testing/python/raises.py b/testing/python/raises.py index f74d747c0df..56da827b831 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -444,3 +444,17 @@ def test_pipe_is_treated_as_regex_metacharacter(self) -> None: assert not is_fully_escaped("foo|bar") assert is_fully_escaped(r"foo\|bar") assert unescape(r"foo\|bar") == "foo|bar" + + def test_consecutive_backslashes_in_escape_check(self) -> None: + """Consecutive backslashes escape each other, leaving the metachar unescaped.""" + from _pytest.raises import is_fully_escaped + + # r"\." -> one backslash escapes the dot -> fully escaped + assert is_fully_escaped(r"\.") + # r"\\." -> two backslashes: the first escapes the second, dot is unescaped + assert not is_fully_escaped(r"\\.") + # r"\\\." -> three backslashes: pair escapes pair, last escapes dot -> fully escaped + assert is_fully_escaped(r"\\\.") + # Same idea with pipe metachar + assert not is_fully_escaped("\\\\|") + assert is_fully_escaped(r"\\\\|") From ed7bbc594eb608ac431dc564fb1829dc905a6941 Mon Sep 17 00:00:00 2001 From: EternalRights Date: Sat, 18 Apr 2026 21:31:30 +0800 Subject: [PATCH 2/6] Fix incorrect test assertion for 4-backslash case r'\\\\|' is 4 backslashes + pipe. Even count means pipe is not escaped, so is_fully_escaped should return False, not True. --- testing/python/raises.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testing/python/raises.py b/testing/python/raises.py index 56da827b831..52336b122a1 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -456,5 +456,7 @@ def test_consecutive_backslashes_in_escape_check(self) -> None: # r"\\\." -> three backslashes: pair escapes pair, last escapes dot -> fully escaped assert is_fully_escaped(r"\\\.") # Same idea with pipe metachar + # "\\\\|" is the string \\| (2 backslashes + pipe): even count, pipe is unescaped assert not is_fully_escaped("\\\\|") - assert is_fully_escaped(r"\\\\|") + # r"\\\\|" is the string \\\\| (4 backslashes + pipe): even count, pipe is unescaped + assert not is_fully_escaped(r"\\\\|") From 046455f692038ab9581940806dd7a84a418f9904 Mon Sep 17 00:00:00 2001 From: EternalRights <162705204+EternalRights@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:35:58 +0800 Subject: [PATCH 3/6] Fix incorrect test assertion for 4-backslash case 4 backslashes + pipe: even count means pipe is not escaped, so is_fully_escaped should return False, not True. --- testing/python/raises.py | 922 ++++++++++++++++++++------------------- 1 file changed, 462 insertions(+), 460 deletions(-) diff --git a/testing/python/raises.py b/testing/python/raises.py index 56da827b831..49e7db36c52 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -1,460 +1,462 @@ -# mypy: allow-untyped-defs -from __future__ import annotations - -import io -import re -import sys - -from _pytest.outcomes import Failed -from _pytest.pytester import Pytester -from _pytest.warning_types import PytestWarning -import pytest - - -def wrap_escape(s: str) -> str: - return "^" + re.escape(s) + "$" - - -class TestRaises: - def test_check_callable(self) -> None: - with pytest.raises(TypeError, match=r".* must be callable"): - pytest.raises(RuntimeError, "int('qwe')") # type: ignore[call-overload] - - def test_raises(self): - with pytest.raises(ValueError) as excinfo: - int("qwe") - assert "invalid literal" in str(excinfo.value) - - def test_raises_function(self): - with pytest.raises(ValueError) as excinfo: - int("hello") - assert "invalid literal" in str(excinfo.value) - - def test_raises_does_not_allow_none(self): - with pytest.raises( - ValueError, - match=wrap_escape("You must specify at least one parameter to match on."), - ): - # We're testing that this invalid usage gives a helpful error, - # so we can ignore Mypy telling us that None is invalid. - pytest.raises(expected_exception=None) # type: ignore - - # it's unclear if this message is helpful, and if it is, should it trigger more - # liberally? Usually you'd get a TypeError here - def test_raises_false_and_arg(self): - with pytest.raises( - ValueError, - match=wrap_escape( - "Expected an exception type or a tuple of exception types, but got `False`. " - "Raising exceptions is already understood as failing the test, so you don't need " - "any special code to say 'this should never raise an exception'." - ), - ): - pytest.raises(False, int) # type: ignore[call-overload] - - def test_raises_does_not_allow_empty_tuple(self): - with pytest.raises( - ValueError, - match=wrap_escape("You must specify at least one parameter to match on."), - ): - pytest.raises(expected_exception=()) - - def test_raises_callable_no_exception(self) -> None: - class A: - def __call__(self): - pass - - try: - pytest.raises(ValueError, A()) - except pytest.fail.Exception: - pass - - def test_raises_falsey_type_error(self) -> None: - with pytest.raises(TypeError): - with pytest.raises(AssertionError, match=0): # type: ignore[call-overload] - raise AssertionError("ohai") - - def test_raises_repr_inflight(self): - """Ensure repr() on an exception info inside a pytest.raises with block works (#4386)""" - - class E(Exception): - pass - - with pytest.raises(E) as excinfo: - # this test prints the inflight uninitialized object - # using repr and str as well as pprint to demonstrate - # it works - print(str(excinfo)) - print(repr(excinfo)) - import pprint - - pprint.pprint(excinfo) - raise E() - - def test_raises_as_contextmanager(self, pytester: Pytester) -> None: - pytester.makepyfile( - """ - import pytest - import _pytest._code - - def test_simple(): - with pytest.raises(ZeroDivisionError) as excinfo: - assert isinstance(excinfo, _pytest._code.ExceptionInfo) - 1/0 - print(excinfo) - assert excinfo.type == ZeroDivisionError - assert isinstance(excinfo.value, ZeroDivisionError) - - def test_noraise(): - with pytest.raises(pytest.raises.Exception): - with pytest.raises(ValueError): - int() - - def test_raise_wrong_exception_passes_by(): - with pytest.raises(ZeroDivisionError): - with pytest.raises(ValueError): - 1/0 - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*3 passed*"]) - - def test_does_not_raise(self, pytester: Pytester) -> None: - pytester.makepyfile( - """ - from contextlib import nullcontext as does_not_raise - import pytest - - @pytest.mark.parametrize('example_input,expectation', [ - (3, does_not_raise()), - (2, does_not_raise()), - (1, does_not_raise()), - (0, pytest.raises(ZeroDivisionError)), - ]) - def test_division(example_input, expectation): - '''Test how much I know division.''' - with expectation: - assert (6 / example_input) is not None - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*4 passed*"]) - - def test_does_not_raise_does_raise(self, pytester: Pytester) -> None: - pytester.makepyfile( - """ - from contextlib import nullcontext as does_not_raise - import pytest - - @pytest.mark.parametrize('example_input,expectation', [ - (0, does_not_raise()), - (1, pytest.raises(ZeroDivisionError)), - ]) - def test_division(example_input, expectation): - '''Test how much I know division.''' - with expectation: - assert (6 / example_input) is not None - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*2 failed*"]) - - def test_raises_with_invalid_regex(self, pytester: Pytester) -> None: - pytester.makepyfile( - """ - import pytest - - def test_invalid_regex(): - with pytest.raises(ValueError, match="invalid regex character ["): - raise ValueError() - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines( - [ - "*Invalid regex pattern provided to 'match': unterminated character set at position 24*", - ] - ) - result.stdout.no_fnmatch_line("*Traceback*") - result.stdout.no_fnmatch_line("*File*") - result.stdout.no_fnmatch_line("*line*") - - def test_noclass(self) -> None: - with pytest.raises(TypeError): - with pytest.raises("wrong"): # type: ignore[call-overload] - ... # pragma: no cover - - def test_invalid_arguments_to_raises(self) -> None: - with pytest.raises(TypeError, match="unknown"): - with pytest.raises(TypeError, unknown="bogus"): # type: ignore[call-overload] - raise ValueError() - - def test_tuple(self): - with pytest.raises((KeyError, ValueError)): - raise KeyError("oops") - - def test_no_raise_message(self) -> None: - try: - with pytest.raises(ValueError): - int("0") - except pytest.fail.Exception as e: - assert e.msg == "DID NOT RAISE ValueError" - else: - assert False, "Expected pytest.raises.Exception" - - try: - with pytest.raises(ValueError): - pass - except pytest.fail.Exception as e: - assert e.msg == "DID NOT RAISE ValueError" - else: - assert False, "Expected pytest.raises.Exception" - - @pytest.mark.parametrize( - "method", ["function", "function_match", "with", "with_raisesexc", "with_group"] - ) - def test_raises_cyclic_reference(self, method): - """Ensure pytest.raises does not leave a reference cycle (#1965).""" - import gc - - class T: - def __call__(self): - raise ValueError - - t = T() - refcount = len(gc.get_referrers(t)) - - if method == "function": - pytest.raises(ValueError, t) - elif method == "function_match": - pytest.raises(ValueError, t).match("^$") - elif method == "with": - with pytest.raises(ValueError): - t() - elif method == "with_raisesexc": - with pytest.RaisesExc(ValueError): - t() - elif method == "with_group": - with pytest.RaisesGroup(ValueError, allow_unwrapped=True): - t() - else: # pragma: no cover - raise AssertionError("bad parametrization") - - # ensure both forms of pytest.raises don't leave exceptions in sys.exc_info() - assert sys.exc_info() == (None, None, None) - - assert refcount == len(gc.get_referrers(t)) - - def test_raises_match(self) -> None: - msg = r"with base \d+" - with pytest.raises(ValueError, match=msg): - int("asdf") - - msg = "with base 10" - with pytest.raises(ValueError, match=msg): - int("asdf") - - msg = "with base 16" - expr = ( - "Regex pattern did not match.\n" - f" Expected regex: {msg!r}\n" - f" Actual message: \"invalid literal for int() with base 10: 'asdf'\"" - ) - with pytest.raises(AssertionError, match="^" + re.escape(expr) + "$"): - with pytest.raises(ValueError, match=msg): - int("asdf", base=10) - - # "match" without context manager. - pytest.raises(ValueError, int, "asdf").match("invalid literal") - with pytest.raises(AssertionError) as excinfo: - pytest.raises(ValueError, int, "asdf").match(msg) - assert str(excinfo.value) == expr - - pytest.raises(TypeError, int, match="invalid") # type: ignore[call-overload] - - def tfunc(match): - raise ValueError(f"match={match}") - - pytest.raises(ValueError, tfunc, match="asdf").match("match=asdf") - pytest.raises(ValueError, tfunc, match="").match("match=") - - # empty string matches everything, which is probably not what the user wants - with pytest.warns( - PytestWarning, - match=wrap_escape( - "matching against an empty string will *always* pass. If you want to check for an empty message you " - "need to pass '^$'. If you don't want to match you should pass `None` or leave out the parameter." - ), - ): - pytest.raises(match="") - - def test_match_failure_string_quoting(self): - with pytest.raises(AssertionError) as excinfo: - with pytest.raises(AssertionError, match="'foo"): - raise AssertionError("'bar") - (msg,) = excinfo.value.args - assert ( - msg - == '''Regex pattern did not match.\n Expected regex: "'foo"\n Actual message: "'bar"''' - ) - - def test_match_failure_exact_string_message(self): - message = "Oh here is a message with (42) numbers in parameters" - with pytest.raises(AssertionError) as excinfo: - with pytest.raises(AssertionError, match=message): - raise AssertionError(message) - (msg,) = excinfo.value.args - assert msg == ( - "Regex pattern did not match.\n" - " Expected regex: 'Oh here is a message with (42) numbers in parameters'\n" - " Actual message: 'Oh here is a message with (42) numbers in parameters'\n" - " Did you mean to `re.escape()` the regex?" - ) - - def test_raises_match_wrong_type(self): - """Raising an exception with the wrong type and match= given. - - pytest should throw the unexpected exception - the pattern match is not - really relevant if we got a different exception. - """ - with pytest.raises( - ValueError, - match=wrap_escape("invalid literal for int() with base 10: 'asdf'"), - ): - with pytest.raises(IndexError, match="nomatch"): - int("asdf") - - def test_raises_exception_looks_iterable(self): - class Meta(type): - def __getitem__(self, item): - return 1 / 0 # pragma: no cover - - def __len__(self): - return 1 # pragma: no cover - - class ClassLooksIterableException(Exception, metaclass=Meta): - pass - - with pytest.raises( - Failed, - match=r"DID NOT RAISE ClassLooksIterableException", - ): - with pytest.raises(ClassLooksIterableException): - ... # pragma: no cover - - def test_raises_with_raising_dunder_class(self) -> None: - """Test current behavior with regard to exceptions via __class__ (#4284).""" - - class CrappyClass(Exception): - # Type ignored because it's bypassed intentionally. - @property # type: ignore - def __class__(self): - assert False, "via __class__" - - with pytest.raises(AssertionError) as excinfo: - with pytest.raises(CrappyClass()): # type: ignore[call-overload] - pass - assert "via __class__" in excinfo.value.args[0] - - def test_raises_context_manager_with_kwargs(self): - with pytest.raises(expected_exception=ValueError): - raise ValueError - with pytest.raises( - TypeError, - match=wrap_escape( - "Unexpected keyword arguments passed to pytest.raises: foo\n" - "Use context-manager form instead?" - ), - ): - with pytest.raises(OSError, foo="bar"): # type: ignore[call-overload] - pass - - def test_expected_exception_is_not_a_baseexception(self) -> None: - with pytest.raises( - TypeError, - match=wrap_escape("Expected a BaseException type, but got 'str'"), - ): - with pytest.raises("hello"): # type: ignore[call-overload] - pass # pragma: no cover - - class NotAnException: - pass - - with pytest.raises( - ValueError, - match=wrap_escape( - "Expected a BaseException type, but got 'NotAnException'" - ), - ): - with pytest.raises(NotAnException): # type: ignore[type-var] - pass # pragma: no cover - - with pytest.raises( - TypeError, - match=wrap_escape("Expected a BaseException type, but got 'str'"), - ): - with pytest.raises(("hello", NotAnException)): # type: ignore[arg-type] - pass # pragma: no cover - - def test_issue_11872(self) -> None: - """Regression test for #11872. - - urllib.error.HTTPError on some Python 3.10/11 minor releases raises - KeyError instead of AttributeError on invalid attribute access. - - https://github.com/python/cpython/issues/98778 - """ - from email.message import Message - from urllib.error import HTTPError - - with pytest.raises(HTTPError, match="Not Found") as exc_info: - raise HTTPError( - code=404, msg="Not Found", fp=io.BytesIO(), hdrs=Message(), url="" - ) - exc_info.value.close() # avoid a resource warning - - def test_raises_match_compiled_regex(self) -> None: - """Test that compiled regex patterns work with pytest.raises.""" - # Test with a compiled pattern that matches - pattern = re.compile(r"with base \d+") - with pytest.raises(ValueError, match=pattern): - int("asdf") - - # Test with a compiled pattern that doesn't match - pattern_nomatch = re.compile(r"with base 16") - expr = ( - "Regex pattern did not match.\n" - f" Expected regex: {pattern_nomatch.pattern!r}\n" - f" Actual message: \"invalid literal for int() with base 10: 'asdf'\"" - ) - with pytest.raises(AssertionError, match="^" + re.escape(expr) + "$"): - with pytest.raises(ValueError, match=pattern_nomatch): - int("asdf", base=10) - - # Test compiled pattern with flags - pattern_with_flags = re.compile(r"INVALID LITERAL", re.IGNORECASE) - with pytest.raises(ValueError, match=pattern_with_flags): - int("asdf") - - def test_pipe_is_treated_as_regex_metacharacter(self) -> None: - """| (pipe) must be recognized as a regex metacharacter.""" - from _pytest.raises import is_fully_escaped - from _pytest.raises import unescape - - assert not is_fully_escaped("foo|bar") - assert is_fully_escaped(r"foo\|bar") - assert unescape(r"foo\|bar") == "foo|bar" - - def test_consecutive_backslashes_in_escape_check(self) -> None: - """Consecutive backslashes escape each other, leaving the metachar unescaped.""" - from _pytest.raises import is_fully_escaped - - # r"\." -> one backslash escapes the dot -> fully escaped - assert is_fully_escaped(r"\.") - # r"\\." -> two backslashes: the first escapes the second, dot is unescaped - assert not is_fully_escaped(r"\\.") - # r"\\\." -> three backslashes: pair escapes pair, last escapes dot -> fully escaped - assert is_fully_escaped(r"\\\.") - # Same idea with pipe metachar - assert not is_fully_escaped("\\\\|") - assert is_fully_escaped(r"\\\\|") +# mypy: allow-untyped-defs +from __future__ import annotations + +import io +import re +import sys + +from _pytest.outcomes import Failed +from _pytest.pytester import Pytester +from _pytest.warning_types import PytestWarning +import pytest + + +def wrap_escape(s: str) -> str: + return "^" + re.escape(s) + "$" + + +class TestRaises: + def test_check_callable(self) -> None: + with pytest.raises(TypeError, match=r".* must be callable"): + pytest.raises(RuntimeError, "int('qwe')") # type: ignore[call-overload] + + def test_raises(self): + with pytest.raises(ValueError) as excinfo: + int("qwe") + assert "invalid literal" in str(excinfo.value) + + def test_raises_function(self): + with pytest.raises(ValueError) as excinfo: + int("hello") + assert "invalid literal" in str(excinfo.value) + + def test_raises_does_not_allow_none(self): + with pytest.raises( + ValueError, + match=wrap_escape("You must specify at least one parameter to match on."), + ): + # We're testing that this invalid usage gives a helpful error, + # so we can ignore Mypy telling us that None is invalid. + pytest.raises(expected_exception=None) # type: ignore + + # it's unclear if this message is helpful, and if it is, should it trigger more + # liberally? Usually you'd get a TypeError here + def test_raises_false_and_arg(self): + with pytest.raises( + ValueError, + match=wrap_escape( + "Expected an exception type or a tuple of exception types, but got `False`. " + "Raising exceptions is already understood as failing the test, so you don't need " + "any special code to say 'this should never raise an exception'." + ), + ): + pytest.raises(False, int) # type: ignore[call-overload] + + def test_raises_does_not_allow_empty_tuple(self): + with pytest.raises( + ValueError, + match=wrap_escape("You must specify at least one parameter to match on."), + ): + pytest.raises(expected_exception=()) + + def test_raises_callable_no_exception(self) -> None: + class A: + def __call__(self): + pass + + try: + pytest.raises(ValueError, A()) + except pytest.fail.Exception: + pass + + def test_raises_falsey_type_error(self) -> None: + with pytest.raises(TypeError): + with pytest.raises(AssertionError, match=0): # type: ignore[call-overload] + raise AssertionError("ohai") + + def test_raises_repr_inflight(self): + """Ensure repr() on an exception info inside a pytest.raises with block works (#4386)""" + + class E(Exception): + pass + + with pytest.raises(E) as excinfo: + # this test prints the inflight uninitialized object + # using repr and str as well as pprint to demonstrate + # it works + print(str(excinfo)) + print(repr(excinfo)) + import pprint + + pprint.pprint(excinfo) + raise E() + + def test_raises_as_contextmanager(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + import _pytest._code + + def test_simple(): + with pytest.raises(ZeroDivisionError) as excinfo: + assert isinstance(excinfo, _pytest._code.ExceptionInfo) + 1/0 + print(excinfo) + assert excinfo.type == ZeroDivisionError + assert isinstance(excinfo.value, ZeroDivisionError) + + def test_noraise(): + with pytest.raises(pytest.raises.Exception): + with pytest.raises(ValueError): + int() + + def test_raise_wrong_exception_passes_by(): + with pytest.raises(ZeroDivisionError): + with pytest.raises(ValueError): + 1/0 + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*3 passed*"]) + + def test_does_not_raise(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + from contextlib import nullcontext as does_not_raise + import pytest + + @pytest.mark.parametrize('example_input,expectation', [ + (3, does_not_raise()), + (2, does_not_raise()), + (1, does_not_raise()), + (0, pytest.raises(ZeroDivisionError)), + ]) + def test_division(example_input, expectation): + '''Test how much I know division.''' + with expectation: + assert (6 / example_input) is not None + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*4 passed*"]) + + def test_does_not_raise_does_raise(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + from contextlib import nullcontext as does_not_raise + import pytest + + @pytest.mark.parametrize('example_input,expectation', [ + (0, does_not_raise()), + (1, pytest.raises(ZeroDivisionError)), + ]) + def test_division(example_input, expectation): + '''Test how much I know division.''' + with expectation: + assert (6 / example_input) is not None + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*2 failed*"]) + + def test_raises_with_invalid_regex(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + def test_invalid_regex(): + with pytest.raises(ValueError, match="invalid regex character ["): + raise ValueError() + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*Invalid regex pattern provided to 'match': unterminated character set at position 24*", + ] + ) + result.stdout.no_fnmatch_line("*Traceback*") + result.stdout.no_fnmatch_line("*File*") + result.stdout.no_fnmatch_line("*line*") + + def test_noclass(self) -> None: + with pytest.raises(TypeError): + with pytest.raises("wrong"): # type: ignore[call-overload] + ... # pragma: no cover + + def test_invalid_arguments_to_raises(self) -> None: + with pytest.raises(TypeError, match="unknown"): + with pytest.raises(TypeError, unknown="bogus"): # type: ignore[call-overload] + raise ValueError() + + def test_tuple(self): + with pytest.raises((KeyError, ValueError)): + raise KeyError("oops") + + def test_no_raise_message(self) -> None: + try: + with pytest.raises(ValueError): + int("0") + except pytest.fail.Exception as e: + assert e.msg == "DID NOT RAISE ValueError" + else: + assert False, "Expected pytest.raises.Exception" + + try: + with pytest.raises(ValueError): + pass + except pytest.fail.Exception as e: + assert e.msg == "DID NOT RAISE ValueError" + else: + assert False, "Expected pytest.raises.Exception" + + @pytest.mark.parametrize( + "method", ["function", "function_match", "with", "with_raisesexc", "with_group"] + ) + def test_raises_cyclic_reference(self, method): + """Ensure pytest.raises does not leave a reference cycle (#1965).""" + import gc + + class T: + def __call__(self): + raise ValueError + + t = T() + refcount = len(gc.get_referrers(t)) + + if method == "function": + pytest.raises(ValueError, t) + elif method == "function_match": + pytest.raises(ValueError, t).match("^$") + elif method == "with": + with pytest.raises(ValueError): + t() + elif method == "with_raisesexc": + with pytest.RaisesExc(ValueError): + t() + elif method == "with_group": + with pytest.RaisesGroup(ValueError, allow_unwrapped=True): + t() + else: # pragma: no cover + raise AssertionError("bad parametrization") + + # ensure both forms of pytest.raises don't leave exceptions in sys.exc_info() + assert sys.exc_info() == (None, None, None) + + assert refcount == len(gc.get_referrers(t)) + + def test_raises_match(self) -> None: + msg = r"with base \d+" + with pytest.raises(ValueError, match=msg): + int("asdf") + + msg = "with base 10" + with pytest.raises(ValueError, match=msg): + int("asdf") + + msg = "with base 16" + expr = ( + "Regex pattern did not match.\n" + f" Expected regex: {msg!r}\n" + f" Actual message: \"invalid literal for int() with base 10: 'asdf'\"" + ) + with pytest.raises(AssertionError, match="^" + re.escape(expr) + "$"): + with pytest.raises(ValueError, match=msg): + int("asdf", base=10) + + # "match" without context manager. + pytest.raises(ValueError, int, "asdf").match("invalid literal") + with pytest.raises(AssertionError) as excinfo: + pytest.raises(ValueError, int, "asdf").match(msg) + assert str(excinfo.value) == expr + + pytest.raises(TypeError, int, match="invalid") # type: ignore[call-overload] + + def tfunc(match): + raise ValueError(f"match={match}") + + pytest.raises(ValueError, tfunc, match="asdf").match("match=asdf") + pytest.raises(ValueError, tfunc, match="").match("match=") + + # empty string matches everything, which is probably not what the user wants + with pytest.warns( + PytestWarning, + match=wrap_escape( + "matching against an empty string will *always* pass. If you want to check for an empty message you " + "need to pass '^$'. If you don't want to match you should pass `None` or leave out the parameter." + ), + ): + pytest.raises(match="") + + def test_match_failure_string_quoting(self): + with pytest.raises(AssertionError) as excinfo: + with pytest.raises(AssertionError, match="'foo"): + raise AssertionError("'bar") + (msg,) = excinfo.value.args + assert ( + msg + == '''Regex pattern did not match.\n Expected regex: "'foo"\n Actual message: "'bar"''' + ) + + def test_match_failure_exact_string_message(self): + message = "Oh here is a message with (42) numbers in parameters" + with pytest.raises(AssertionError) as excinfo: + with pytest.raises(AssertionError, match=message): + raise AssertionError(message) + (msg,) = excinfo.value.args + assert msg == ( + "Regex pattern did not match.\n" + " Expected regex: 'Oh here is a message with (42) numbers in parameters'\n" + " Actual message: 'Oh here is a message with (42) numbers in parameters'\n" + " Did you mean to `re.escape()` the regex?" + ) + + def test_raises_match_wrong_type(self): + """Raising an exception with the wrong type and match= given. + + pytest should throw the unexpected exception - the pattern match is not + really relevant if we got a different exception. + """ + with pytest.raises( + ValueError, + match=wrap_escape("invalid literal for int() with base 10: 'asdf'"), + ): + with pytest.raises(IndexError, match="nomatch"): + int("asdf") + + def test_raises_exception_looks_iterable(self): + class Meta(type): + def __getitem__(self, item): + return 1 / 0 # pragma: no cover + + def __len__(self): + return 1 # pragma: no cover + + class ClassLooksIterableException(Exception, metaclass=Meta): + pass + + with pytest.raises( + Failed, + match=r"DID NOT RAISE ClassLooksIterableException", + ): + with pytest.raises(ClassLooksIterableException): + ... # pragma: no cover + + def test_raises_with_raising_dunder_class(self) -> None: + """Test current behavior with regard to exceptions via __class__ (#4284).""" + + class CrappyClass(Exception): + # Type ignored because it's bypassed intentionally. + @property # type: ignore + def __class__(self): + assert False, "via __class__" + + with pytest.raises(AssertionError) as excinfo: + with pytest.raises(CrappyClass()): # type: ignore[call-overload] + pass + assert "via __class__" in excinfo.value.args[0] + + def test_raises_context_manager_with_kwargs(self): + with pytest.raises(expected_exception=ValueError): + raise ValueError + with pytest.raises( + TypeError, + match=wrap_escape( + "Unexpected keyword arguments passed to pytest.raises: foo\n" + "Use context-manager form instead?" + ), + ): + with pytest.raises(OSError, foo="bar"): # type: ignore[call-overload] + pass + + def test_expected_exception_is_not_a_baseexception(self) -> None: + with pytest.raises( + TypeError, + match=wrap_escape("Expected a BaseException type, but got 'str'"), + ): + with pytest.raises("hello"): # type: ignore[call-overload] + pass # pragma: no cover + + class NotAnException: + pass + + with pytest.raises( + ValueError, + match=wrap_escape( + "Expected a BaseException type, but got 'NotAnException'" + ), + ): + with pytest.raises(NotAnException): # type: ignore[type-var] + pass # pragma: no cover + + with pytest.raises( + TypeError, + match=wrap_escape("Expected a BaseException type, but got 'str'"), + ): + with pytest.raises(("hello", NotAnException)): # type: ignore[arg-type] + pass # pragma: no cover + + def test_issue_11872(self) -> None: + """Regression test for #11872. + + urllib.error.HTTPError on some Python 3.10/11 minor releases raises + KeyError instead of AttributeError on invalid attribute access. + + https://github.com/python/cpython/issues/98778 + """ + from email.message import Message + from urllib.error import HTTPError + + with pytest.raises(HTTPError, match="Not Found") as exc_info: + raise HTTPError( + code=404, msg="Not Found", fp=io.BytesIO(), hdrs=Message(), url="" + ) + exc_info.value.close() # avoid a resource warning + + def test_raises_match_compiled_regex(self) -> None: + """Test that compiled regex patterns work with pytest.raises.""" + # Test with a compiled pattern that matches + pattern = re.compile(r"with base \d+") + with pytest.raises(ValueError, match=pattern): + int("asdf") + + # Test with a compiled pattern that doesn't match + pattern_nomatch = re.compile(r"with base 16") + expr = ( + "Regex pattern did not match.\n" + f" Expected regex: {pattern_nomatch.pattern!r}\n" + f" Actual message: \"invalid literal for int() with base 10: 'asdf'\"" + ) + with pytest.raises(AssertionError, match="^" + re.escape(expr) + "$"): + with pytest.raises(ValueError, match=pattern_nomatch): + int("asdf", base=10) + + # Test compiled pattern with flags + pattern_with_flags = re.compile(r"INVALID LITERAL", re.IGNORECASE) + with pytest.raises(ValueError, match=pattern_with_flags): + int("asdf") + + def test_pipe_is_treated_as_regex_metacharacter(self) -> None: + """| (pipe) must be recognized as a regex metacharacter.""" + from _pytest.raises import is_fully_escaped + from _pytest.raises import unescape + + assert not is_fully_escaped("foo|bar") + assert is_fully_escaped(r"foo\|bar") + assert unescape(r"foo\|bar") == "foo|bar" + + def test_consecutive_backslashes_in_escape_check(self) -> None: + """Consecutive backslashes escape each other, leaving the metachar unescaped.""" + from _pytest.raises import is_fully_escaped + + # r"\." -> one backslash escapes the dot -> fully escaped + assert is_fully_escaped(r"\.") + # r"\\." -> two backslashes: the first escapes the second, dot is unescaped + assert not is_fully_escaped(r"\\.") + # r"\\\." -> three backslashes: pair escapes pair, last escapes dot -> fully escaped + assert is_fully_escaped(r"\\\.") + # Same idea with pipe metachar + # "\\\\|" is the string \\| (2 backslashes + pipe): even count, pipe is unescaped + assert not is_fully_escaped("\\\\|") + # r"\\\\|" is the string \\\\| (4 backslashes + pipe): even count, pipe is unescaped + assert not is_fully_escaped(r"\\\\|") From 4e88fdf3b99bdd80e8cb89754bd598e65d8d372e Mon Sep 17 00:00:00 2001 From: EternalRights <162705204+EternalRights@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:28:06 +0800 Subject: [PATCH 4/6] Fix line endings in test file (LF instead of CRLF) --- testing/python/raises.py | 924 +++++++++++++++++++-------------------- 1 file changed, 462 insertions(+), 462 deletions(-) diff --git a/testing/python/raises.py b/testing/python/raises.py index 49e7db36c52..52336b122a1 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -1,462 +1,462 @@ -# mypy: allow-untyped-defs -from __future__ import annotations - -import io -import re -import sys - -from _pytest.outcomes import Failed -from _pytest.pytester import Pytester -from _pytest.warning_types import PytestWarning -import pytest - - -def wrap_escape(s: str) -> str: - return "^" + re.escape(s) + "$" - - -class TestRaises: - def test_check_callable(self) -> None: - with pytest.raises(TypeError, match=r".* must be callable"): - pytest.raises(RuntimeError, "int('qwe')") # type: ignore[call-overload] - - def test_raises(self): - with pytest.raises(ValueError) as excinfo: - int("qwe") - assert "invalid literal" in str(excinfo.value) - - def test_raises_function(self): - with pytest.raises(ValueError) as excinfo: - int("hello") - assert "invalid literal" in str(excinfo.value) - - def test_raises_does_not_allow_none(self): - with pytest.raises( - ValueError, - match=wrap_escape("You must specify at least one parameter to match on."), - ): - # We're testing that this invalid usage gives a helpful error, - # so we can ignore Mypy telling us that None is invalid. - pytest.raises(expected_exception=None) # type: ignore - - # it's unclear if this message is helpful, and if it is, should it trigger more - # liberally? Usually you'd get a TypeError here - def test_raises_false_and_arg(self): - with pytest.raises( - ValueError, - match=wrap_escape( - "Expected an exception type or a tuple of exception types, but got `False`. " - "Raising exceptions is already understood as failing the test, so you don't need " - "any special code to say 'this should never raise an exception'." - ), - ): - pytest.raises(False, int) # type: ignore[call-overload] - - def test_raises_does_not_allow_empty_tuple(self): - with pytest.raises( - ValueError, - match=wrap_escape("You must specify at least one parameter to match on."), - ): - pytest.raises(expected_exception=()) - - def test_raises_callable_no_exception(self) -> None: - class A: - def __call__(self): - pass - - try: - pytest.raises(ValueError, A()) - except pytest.fail.Exception: - pass - - def test_raises_falsey_type_error(self) -> None: - with pytest.raises(TypeError): - with pytest.raises(AssertionError, match=0): # type: ignore[call-overload] - raise AssertionError("ohai") - - def test_raises_repr_inflight(self): - """Ensure repr() on an exception info inside a pytest.raises with block works (#4386)""" - - class E(Exception): - pass - - with pytest.raises(E) as excinfo: - # this test prints the inflight uninitialized object - # using repr and str as well as pprint to demonstrate - # it works - print(str(excinfo)) - print(repr(excinfo)) - import pprint - - pprint.pprint(excinfo) - raise E() - - def test_raises_as_contextmanager(self, pytester: Pytester) -> None: - pytester.makepyfile( - """ - import pytest - import _pytest._code - - def test_simple(): - with pytest.raises(ZeroDivisionError) as excinfo: - assert isinstance(excinfo, _pytest._code.ExceptionInfo) - 1/0 - print(excinfo) - assert excinfo.type == ZeroDivisionError - assert isinstance(excinfo.value, ZeroDivisionError) - - def test_noraise(): - with pytest.raises(pytest.raises.Exception): - with pytest.raises(ValueError): - int() - - def test_raise_wrong_exception_passes_by(): - with pytest.raises(ZeroDivisionError): - with pytest.raises(ValueError): - 1/0 - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*3 passed*"]) - - def test_does_not_raise(self, pytester: Pytester) -> None: - pytester.makepyfile( - """ - from contextlib import nullcontext as does_not_raise - import pytest - - @pytest.mark.parametrize('example_input,expectation', [ - (3, does_not_raise()), - (2, does_not_raise()), - (1, does_not_raise()), - (0, pytest.raises(ZeroDivisionError)), - ]) - def test_division(example_input, expectation): - '''Test how much I know division.''' - with expectation: - assert (6 / example_input) is not None - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*4 passed*"]) - - def test_does_not_raise_does_raise(self, pytester: Pytester) -> None: - pytester.makepyfile( - """ - from contextlib import nullcontext as does_not_raise - import pytest - - @pytest.mark.parametrize('example_input,expectation', [ - (0, does_not_raise()), - (1, pytest.raises(ZeroDivisionError)), - ]) - def test_division(example_input, expectation): - '''Test how much I know division.''' - with expectation: - assert (6 / example_input) is not None - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*2 failed*"]) - - def test_raises_with_invalid_regex(self, pytester: Pytester) -> None: - pytester.makepyfile( - """ - import pytest - - def test_invalid_regex(): - with pytest.raises(ValueError, match="invalid regex character ["): - raise ValueError() - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines( - [ - "*Invalid regex pattern provided to 'match': unterminated character set at position 24*", - ] - ) - result.stdout.no_fnmatch_line("*Traceback*") - result.stdout.no_fnmatch_line("*File*") - result.stdout.no_fnmatch_line("*line*") - - def test_noclass(self) -> None: - with pytest.raises(TypeError): - with pytest.raises("wrong"): # type: ignore[call-overload] - ... # pragma: no cover - - def test_invalid_arguments_to_raises(self) -> None: - with pytest.raises(TypeError, match="unknown"): - with pytest.raises(TypeError, unknown="bogus"): # type: ignore[call-overload] - raise ValueError() - - def test_tuple(self): - with pytest.raises((KeyError, ValueError)): - raise KeyError("oops") - - def test_no_raise_message(self) -> None: - try: - with pytest.raises(ValueError): - int("0") - except pytest.fail.Exception as e: - assert e.msg == "DID NOT RAISE ValueError" - else: - assert False, "Expected pytest.raises.Exception" - - try: - with pytest.raises(ValueError): - pass - except pytest.fail.Exception as e: - assert e.msg == "DID NOT RAISE ValueError" - else: - assert False, "Expected pytest.raises.Exception" - - @pytest.mark.parametrize( - "method", ["function", "function_match", "with", "with_raisesexc", "with_group"] - ) - def test_raises_cyclic_reference(self, method): - """Ensure pytest.raises does not leave a reference cycle (#1965).""" - import gc - - class T: - def __call__(self): - raise ValueError - - t = T() - refcount = len(gc.get_referrers(t)) - - if method == "function": - pytest.raises(ValueError, t) - elif method == "function_match": - pytest.raises(ValueError, t).match("^$") - elif method == "with": - with pytest.raises(ValueError): - t() - elif method == "with_raisesexc": - with pytest.RaisesExc(ValueError): - t() - elif method == "with_group": - with pytest.RaisesGroup(ValueError, allow_unwrapped=True): - t() - else: # pragma: no cover - raise AssertionError("bad parametrization") - - # ensure both forms of pytest.raises don't leave exceptions in sys.exc_info() - assert sys.exc_info() == (None, None, None) - - assert refcount == len(gc.get_referrers(t)) - - def test_raises_match(self) -> None: - msg = r"with base \d+" - with pytest.raises(ValueError, match=msg): - int("asdf") - - msg = "with base 10" - with pytest.raises(ValueError, match=msg): - int("asdf") - - msg = "with base 16" - expr = ( - "Regex pattern did not match.\n" - f" Expected regex: {msg!r}\n" - f" Actual message: \"invalid literal for int() with base 10: 'asdf'\"" - ) - with pytest.raises(AssertionError, match="^" + re.escape(expr) + "$"): - with pytest.raises(ValueError, match=msg): - int("asdf", base=10) - - # "match" without context manager. - pytest.raises(ValueError, int, "asdf").match("invalid literal") - with pytest.raises(AssertionError) as excinfo: - pytest.raises(ValueError, int, "asdf").match(msg) - assert str(excinfo.value) == expr - - pytest.raises(TypeError, int, match="invalid") # type: ignore[call-overload] - - def tfunc(match): - raise ValueError(f"match={match}") - - pytest.raises(ValueError, tfunc, match="asdf").match("match=asdf") - pytest.raises(ValueError, tfunc, match="").match("match=") - - # empty string matches everything, which is probably not what the user wants - with pytest.warns( - PytestWarning, - match=wrap_escape( - "matching against an empty string will *always* pass. If you want to check for an empty message you " - "need to pass '^$'. If you don't want to match you should pass `None` or leave out the parameter." - ), - ): - pytest.raises(match="") - - def test_match_failure_string_quoting(self): - with pytest.raises(AssertionError) as excinfo: - with pytest.raises(AssertionError, match="'foo"): - raise AssertionError("'bar") - (msg,) = excinfo.value.args - assert ( - msg - == '''Regex pattern did not match.\n Expected regex: "'foo"\n Actual message: "'bar"''' - ) - - def test_match_failure_exact_string_message(self): - message = "Oh here is a message with (42) numbers in parameters" - with pytest.raises(AssertionError) as excinfo: - with pytest.raises(AssertionError, match=message): - raise AssertionError(message) - (msg,) = excinfo.value.args - assert msg == ( - "Regex pattern did not match.\n" - " Expected regex: 'Oh here is a message with (42) numbers in parameters'\n" - " Actual message: 'Oh here is a message with (42) numbers in parameters'\n" - " Did you mean to `re.escape()` the regex?" - ) - - def test_raises_match_wrong_type(self): - """Raising an exception with the wrong type and match= given. - - pytest should throw the unexpected exception - the pattern match is not - really relevant if we got a different exception. - """ - with pytest.raises( - ValueError, - match=wrap_escape("invalid literal for int() with base 10: 'asdf'"), - ): - with pytest.raises(IndexError, match="nomatch"): - int("asdf") - - def test_raises_exception_looks_iterable(self): - class Meta(type): - def __getitem__(self, item): - return 1 / 0 # pragma: no cover - - def __len__(self): - return 1 # pragma: no cover - - class ClassLooksIterableException(Exception, metaclass=Meta): - pass - - with pytest.raises( - Failed, - match=r"DID NOT RAISE ClassLooksIterableException", - ): - with pytest.raises(ClassLooksIterableException): - ... # pragma: no cover - - def test_raises_with_raising_dunder_class(self) -> None: - """Test current behavior with regard to exceptions via __class__ (#4284).""" - - class CrappyClass(Exception): - # Type ignored because it's bypassed intentionally. - @property # type: ignore - def __class__(self): - assert False, "via __class__" - - with pytest.raises(AssertionError) as excinfo: - with pytest.raises(CrappyClass()): # type: ignore[call-overload] - pass - assert "via __class__" in excinfo.value.args[0] - - def test_raises_context_manager_with_kwargs(self): - with pytest.raises(expected_exception=ValueError): - raise ValueError - with pytest.raises( - TypeError, - match=wrap_escape( - "Unexpected keyword arguments passed to pytest.raises: foo\n" - "Use context-manager form instead?" - ), - ): - with pytest.raises(OSError, foo="bar"): # type: ignore[call-overload] - pass - - def test_expected_exception_is_not_a_baseexception(self) -> None: - with pytest.raises( - TypeError, - match=wrap_escape("Expected a BaseException type, but got 'str'"), - ): - with pytest.raises("hello"): # type: ignore[call-overload] - pass # pragma: no cover - - class NotAnException: - pass - - with pytest.raises( - ValueError, - match=wrap_escape( - "Expected a BaseException type, but got 'NotAnException'" - ), - ): - with pytest.raises(NotAnException): # type: ignore[type-var] - pass # pragma: no cover - - with pytest.raises( - TypeError, - match=wrap_escape("Expected a BaseException type, but got 'str'"), - ): - with pytest.raises(("hello", NotAnException)): # type: ignore[arg-type] - pass # pragma: no cover - - def test_issue_11872(self) -> None: - """Regression test for #11872. - - urllib.error.HTTPError on some Python 3.10/11 minor releases raises - KeyError instead of AttributeError on invalid attribute access. - - https://github.com/python/cpython/issues/98778 - """ - from email.message import Message - from urllib.error import HTTPError - - with pytest.raises(HTTPError, match="Not Found") as exc_info: - raise HTTPError( - code=404, msg="Not Found", fp=io.BytesIO(), hdrs=Message(), url="" - ) - exc_info.value.close() # avoid a resource warning - - def test_raises_match_compiled_regex(self) -> None: - """Test that compiled regex patterns work with pytest.raises.""" - # Test with a compiled pattern that matches - pattern = re.compile(r"with base \d+") - with pytest.raises(ValueError, match=pattern): - int("asdf") - - # Test with a compiled pattern that doesn't match - pattern_nomatch = re.compile(r"with base 16") - expr = ( - "Regex pattern did not match.\n" - f" Expected regex: {pattern_nomatch.pattern!r}\n" - f" Actual message: \"invalid literal for int() with base 10: 'asdf'\"" - ) - with pytest.raises(AssertionError, match="^" + re.escape(expr) + "$"): - with pytest.raises(ValueError, match=pattern_nomatch): - int("asdf", base=10) - - # Test compiled pattern with flags - pattern_with_flags = re.compile(r"INVALID LITERAL", re.IGNORECASE) - with pytest.raises(ValueError, match=pattern_with_flags): - int("asdf") - - def test_pipe_is_treated_as_regex_metacharacter(self) -> None: - """| (pipe) must be recognized as a regex metacharacter.""" - from _pytest.raises import is_fully_escaped - from _pytest.raises import unescape - - assert not is_fully_escaped("foo|bar") - assert is_fully_escaped(r"foo\|bar") - assert unescape(r"foo\|bar") == "foo|bar" - - def test_consecutive_backslashes_in_escape_check(self) -> None: - """Consecutive backslashes escape each other, leaving the metachar unescaped.""" - from _pytest.raises import is_fully_escaped - - # r"\." -> one backslash escapes the dot -> fully escaped - assert is_fully_escaped(r"\.") - # r"\\." -> two backslashes: the first escapes the second, dot is unescaped - assert not is_fully_escaped(r"\\.") - # r"\\\." -> three backslashes: pair escapes pair, last escapes dot -> fully escaped - assert is_fully_escaped(r"\\\.") - # Same idea with pipe metachar - # "\\\\|" is the string \\| (2 backslashes + pipe): even count, pipe is unescaped - assert not is_fully_escaped("\\\\|") - # r"\\\\|" is the string \\\\| (4 backslashes + pipe): even count, pipe is unescaped - assert not is_fully_escaped(r"\\\\|") +# mypy: allow-untyped-defs +from __future__ import annotations + +import io +import re +import sys + +from _pytest.outcomes import Failed +from _pytest.pytester import Pytester +from _pytest.warning_types import PytestWarning +import pytest + + +def wrap_escape(s: str) -> str: + return "^" + re.escape(s) + "$" + + +class TestRaises: + def test_check_callable(self) -> None: + with pytest.raises(TypeError, match=r".* must be callable"): + pytest.raises(RuntimeError, "int('qwe')") # type: ignore[call-overload] + + def test_raises(self): + with pytest.raises(ValueError) as excinfo: + int("qwe") + assert "invalid literal" in str(excinfo.value) + + def test_raises_function(self): + with pytest.raises(ValueError) as excinfo: + int("hello") + assert "invalid literal" in str(excinfo.value) + + def test_raises_does_not_allow_none(self): + with pytest.raises( + ValueError, + match=wrap_escape("You must specify at least one parameter to match on."), + ): + # We're testing that this invalid usage gives a helpful error, + # so we can ignore Mypy telling us that None is invalid. + pytest.raises(expected_exception=None) # type: ignore + + # it's unclear if this message is helpful, and if it is, should it trigger more + # liberally? Usually you'd get a TypeError here + def test_raises_false_and_arg(self): + with pytest.raises( + ValueError, + match=wrap_escape( + "Expected an exception type or a tuple of exception types, but got `False`. " + "Raising exceptions is already understood as failing the test, so you don't need " + "any special code to say 'this should never raise an exception'." + ), + ): + pytest.raises(False, int) # type: ignore[call-overload] + + def test_raises_does_not_allow_empty_tuple(self): + with pytest.raises( + ValueError, + match=wrap_escape("You must specify at least one parameter to match on."), + ): + pytest.raises(expected_exception=()) + + def test_raises_callable_no_exception(self) -> None: + class A: + def __call__(self): + pass + + try: + pytest.raises(ValueError, A()) + except pytest.fail.Exception: + pass + + def test_raises_falsey_type_error(self) -> None: + with pytest.raises(TypeError): + with pytest.raises(AssertionError, match=0): # type: ignore[call-overload] + raise AssertionError("ohai") + + def test_raises_repr_inflight(self): + """Ensure repr() on an exception info inside a pytest.raises with block works (#4386)""" + + class E(Exception): + pass + + with pytest.raises(E) as excinfo: + # this test prints the inflight uninitialized object + # using repr and str as well as pprint to demonstrate + # it works + print(str(excinfo)) + print(repr(excinfo)) + import pprint + + pprint.pprint(excinfo) + raise E() + + def test_raises_as_contextmanager(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + import _pytest._code + + def test_simple(): + with pytest.raises(ZeroDivisionError) as excinfo: + assert isinstance(excinfo, _pytest._code.ExceptionInfo) + 1/0 + print(excinfo) + assert excinfo.type == ZeroDivisionError + assert isinstance(excinfo.value, ZeroDivisionError) + + def test_noraise(): + with pytest.raises(pytest.raises.Exception): + with pytest.raises(ValueError): + int() + + def test_raise_wrong_exception_passes_by(): + with pytest.raises(ZeroDivisionError): + with pytest.raises(ValueError): + 1/0 + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*3 passed*"]) + + def test_does_not_raise(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + from contextlib import nullcontext as does_not_raise + import pytest + + @pytest.mark.parametrize('example_input,expectation', [ + (3, does_not_raise()), + (2, does_not_raise()), + (1, does_not_raise()), + (0, pytest.raises(ZeroDivisionError)), + ]) + def test_division(example_input, expectation): + '''Test how much I know division.''' + with expectation: + assert (6 / example_input) is not None + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*4 passed*"]) + + def test_does_not_raise_does_raise(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + from contextlib import nullcontext as does_not_raise + import pytest + + @pytest.mark.parametrize('example_input,expectation', [ + (0, does_not_raise()), + (1, pytest.raises(ZeroDivisionError)), + ]) + def test_division(example_input, expectation): + '''Test how much I know division.''' + with expectation: + assert (6 / example_input) is not None + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*2 failed*"]) + + def test_raises_with_invalid_regex(self, pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + def test_invalid_regex(): + with pytest.raises(ValueError, match="invalid regex character ["): + raise ValueError() + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "*Invalid regex pattern provided to 'match': unterminated character set at position 24*", + ] + ) + result.stdout.no_fnmatch_line("*Traceback*") + result.stdout.no_fnmatch_line("*File*") + result.stdout.no_fnmatch_line("*line*") + + def test_noclass(self) -> None: + with pytest.raises(TypeError): + with pytest.raises("wrong"): # type: ignore[call-overload] + ... # pragma: no cover + + def test_invalid_arguments_to_raises(self) -> None: + with pytest.raises(TypeError, match="unknown"): + with pytest.raises(TypeError, unknown="bogus"): # type: ignore[call-overload] + raise ValueError() + + def test_tuple(self): + with pytest.raises((KeyError, ValueError)): + raise KeyError("oops") + + def test_no_raise_message(self) -> None: + try: + with pytest.raises(ValueError): + int("0") + except pytest.fail.Exception as e: + assert e.msg == "DID NOT RAISE ValueError" + else: + assert False, "Expected pytest.raises.Exception" + + try: + with pytest.raises(ValueError): + pass + except pytest.fail.Exception as e: + assert e.msg == "DID NOT RAISE ValueError" + else: + assert False, "Expected pytest.raises.Exception" + + @pytest.mark.parametrize( + "method", ["function", "function_match", "with", "with_raisesexc", "with_group"] + ) + def test_raises_cyclic_reference(self, method): + """Ensure pytest.raises does not leave a reference cycle (#1965).""" + import gc + + class T: + def __call__(self): + raise ValueError + + t = T() + refcount = len(gc.get_referrers(t)) + + if method == "function": + pytest.raises(ValueError, t) + elif method == "function_match": + pytest.raises(ValueError, t).match("^$") + elif method == "with": + with pytest.raises(ValueError): + t() + elif method == "with_raisesexc": + with pytest.RaisesExc(ValueError): + t() + elif method == "with_group": + with pytest.RaisesGroup(ValueError, allow_unwrapped=True): + t() + else: # pragma: no cover + raise AssertionError("bad parametrization") + + # ensure both forms of pytest.raises don't leave exceptions in sys.exc_info() + assert sys.exc_info() == (None, None, None) + + assert refcount == len(gc.get_referrers(t)) + + def test_raises_match(self) -> None: + msg = r"with base \d+" + with pytest.raises(ValueError, match=msg): + int("asdf") + + msg = "with base 10" + with pytest.raises(ValueError, match=msg): + int("asdf") + + msg = "with base 16" + expr = ( + "Regex pattern did not match.\n" + f" Expected regex: {msg!r}\n" + f" Actual message: \"invalid literal for int() with base 10: 'asdf'\"" + ) + with pytest.raises(AssertionError, match="^" + re.escape(expr) + "$"): + with pytest.raises(ValueError, match=msg): + int("asdf", base=10) + + # "match" without context manager. + pytest.raises(ValueError, int, "asdf").match("invalid literal") + with pytest.raises(AssertionError) as excinfo: + pytest.raises(ValueError, int, "asdf").match(msg) + assert str(excinfo.value) == expr + + pytest.raises(TypeError, int, match="invalid") # type: ignore[call-overload] + + def tfunc(match): + raise ValueError(f"match={match}") + + pytest.raises(ValueError, tfunc, match="asdf").match("match=asdf") + pytest.raises(ValueError, tfunc, match="").match("match=") + + # empty string matches everything, which is probably not what the user wants + with pytest.warns( + PytestWarning, + match=wrap_escape( + "matching against an empty string will *always* pass. If you want to check for an empty message you " + "need to pass '^$'. If you don't want to match you should pass `None` or leave out the parameter." + ), + ): + pytest.raises(match="") + + def test_match_failure_string_quoting(self): + with pytest.raises(AssertionError) as excinfo: + with pytest.raises(AssertionError, match="'foo"): + raise AssertionError("'bar") + (msg,) = excinfo.value.args + assert ( + msg + == '''Regex pattern did not match.\n Expected regex: "'foo"\n Actual message: "'bar"''' + ) + + def test_match_failure_exact_string_message(self): + message = "Oh here is a message with (42) numbers in parameters" + with pytest.raises(AssertionError) as excinfo: + with pytest.raises(AssertionError, match=message): + raise AssertionError(message) + (msg,) = excinfo.value.args + assert msg == ( + "Regex pattern did not match.\n" + " Expected regex: 'Oh here is a message with (42) numbers in parameters'\n" + " Actual message: 'Oh here is a message with (42) numbers in parameters'\n" + " Did you mean to `re.escape()` the regex?" + ) + + def test_raises_match_wrong_type(self): + """Raising an exception with the wrong type and match= given. + + pytest should throw the unexpected exception - the pattern match is not + really relevant if we got a different exception. + """ + with pytest.raises( + ValueError, + match=wrap_escape("invalid literal for int() with base 10: 'asdf'"), + ): + with pytest.raises(IndexError, match="nomatch"): + int("asdf") + + def test_raises_exception_looks_iterable(self): + class Meta(type): + def __getitem__(self, item): + return 1 / 0 # pragma: no cover + + def __len__(self): + return 1 # pragma: no cover + + class ClassLooksIterableException(Exception, metaclass=Meta): + pass + + with pytest.raises( + Failed, + match=r"DID NOT RAISE ClassLooksIterableException", + ): + with pytest.raises(ClassLooksIterableException): + ... # pragma: no cover + + def test_raises_with_raising_dunder_class(self) -> None: + """Test current behavior with regard to exceptions via __class__ (#4284).""" + + class CrappyClass(Exception): + # Type ignored because it's bypassed intentionally. + @property # type: ignore + def __class__(self): + assert False, "via __class__" + + with pytest.raises(AssertionError) as excinfo: + with pytest.raises(CrappyClass()): # type: ignore[call-overload] + pass + assert "via __class__" in excinfo.value.args[0] + + def test_raises_context_manager_with_kwargs(self): + with pytest.raises(expected_exception=ValueError): + raise ValueError + with pytest.raises( + TypeError, + match=wrap_escape( + "Unexpected keyword arguments passed to pytest.raises: foo\n" + "Use context-manager form instead?" + ), + ): + with pytest.raises(OSError, foo="bar"): # type: ignore[call-overload] + pass + + def test_expected_exception_is_not_a_baseexception(self) -> None: + with pytest.raises( + TypeError, + match=wrap_escape("Expected a BaseException type, but got 'str'"), + ): + with pytest.raises("hello"): # type: ignore[call-overload] + pass # pragma: no cover + + class NotAnException: + pass + + with pytest.raises( + ValueError, + match=wrap_escape( + "Expected a BaseException type, but got 'NotAnException'" + ), + ): + with pytest.raises(NotAnException): # type: ignore[type-var] + pass # pragma: no cover + + with pytest.raises( + TypeError, + match=wrap_escape("Expected a BaseException type, but got 'str'"), + ): + with pytest.raises(("hello", NotAnException)): # type: ignore[arg-type] + pass # pragma: no cover + + def test_issue_11872(self) -> None: + """Regression test for #11872. + + urllib.error.HTTPError on some Python 3.10/11 minor releases raises + KeyError instead of AttributeError on invalid attribute access. + + https://github.com/python/cpython/issues/98778 + """ + from email.message import Message + from urllib.error import HTTPError + + with pytest.raises(HTTPError, match="Not Found") as exc_info: + raise HTTPError( + code=404, msg="Not Found", fp=io.BytesIO(), hdrs=Message(), url="" + ) + exc_info.value.close() # avoid a resource warning + + def test_raises_match_compiled_regex(self) -> None: + """Test that compiled regex patterns work with pytest.raises.""" + # Test with a compiled pattern that matches + pattern = re.compile(r"with base \d+") + with pytest.raises(ValueError, match=pattern): + int("asdf") + + # Test with a compiled pattern that doesn't match + pattern_nomatch = re.compile(r"with base 16") + expr = ( + "Regex pattern did not match.\n" + f" Expected regex: {pattern_nomatch.pattern!r}\n" + f" Actual message: \"invalid literal for int() with base 10: 'asdf'\"" + ) + with pytest.raises(AssertionError, match="^" + re.escape(expr) + "$"): + with pytest.raises(ValueError, match=pattern_nomatch): + int("asdf", base=10) + + # Test compiled pattern with flags + pattern_with_flags = re.compile(r"INVALID LITERAL", re.IGNORECASE) + with pytest.raises(ValueError, match=pattern_with_flags): + int("asdf") + + def test_pipe_is_treated_as_regex_metacharacter(self) -> None: + """| (pipe) must be recognized as a regex metacharacter.""" + from _pytest.raises import is_fully_escaped + from _pytest.raises import unescape + + assert not is_fully_escaped("foo|bar") + assert is_fully_escaped(r"foo\|bar") + assert unescape(r"foo\|bar") == "foo|bar" + + def test_consecutive_backslashes_in_escape_check(self) -> None: + """Consecutive backslashes escape each other, leaving the metachar unescaped.""" + from _pytest.raises import is_fully_escaped + + # r"\." -> one backslash escapes the dot -> fully escaped + assert is_fully_escaped(r"\.") + # r"\\." -> two backslashes: the first escapes the second, dot is unescaped + assert not is_fully_escaped(r"\\.") + # r"\\\." -> three backslashes: pair escapes pair, last escapes dot -> fully escaped + assert is_fully_escaped(r"\\\.") + # Same idea with pipe metachar + # "\\\\|" is the string \\| (2 backslashes + pipe): even count, pipe is unescaped + assert not is_fully_escaped("\\\\|") + # r"\\\\|" is the string \\\\| (4 backslashes + pipe): even count, pipe is unescaped + assert not is_fully_escaped(r"\\\\|") From 66b18b27c566942c553d675133e35878c9854fd3 Mon Sep 17 00:00:00 2001 From: EternalRights <162705204+EternalRights@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:32:03 +0800 Subject: [PATCH 5/6] Update changelog/14392.bugfix.rst Co-authored-by: Ran Benita --- changelog/14392.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/14392.bugfix.rst b/changelog/14392.bugfix.rst index ef768560467..b67ff0ffec9 100644 --- a/changelog/14392.bugfix.rst +++ b/changelog/14392.bugfix.rst @@ -1 +1 @@ -Fixed ``is_fully_escaped`` not handling consecutive backslashes correctly: an escaped backslash before a metacharacter (e.g. ``\\\\.``) was incorrectly treated as escaping the metacharacter itself, causing ``pytest.raises(match=...)`` to skip the regex diff display when it should have shown one. +Fixed a bug in :func:`pytest.raises(match=...) ` "fully escaped" detection, causing the regex diff display to be shown in some instances when the raw string diff display should be shown instead. From 5b839bf93d053c7decacd2997b52682068f014c5 Mon Sep 17 00:00:00 2001 From: EternalRights Date: Sun, 26 Apr 2026 02:37:12 +0800 Subject: [PATCH 6/6] Refactor is_fully_escaped to use regex approach as suggested by bluetech --- src/_pytest/raises.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/_pytest/raises.py b/src/_pytest/raises.py index 4dc55d2c813..77a32dcac84 100644 --- a/src/_pytest/raises.py +++ b/src/_pytest/raises.py @@ -345,20 +345,10 @@ def _check_raw_type( def is_fully_escaped(s: str) -> bool: # we know we won't compile with re.VERBOSE, so whitespace doesn't need to be escaped metacharacters = "{}()+.*?^$[]|" - for i, c in enumerate(s): - if c in metacharacters: - # Count consecutive backslashes preceding this metacharacter. - # An odd number of backslashes means the metacharacter is escaped - # (the last backslash does the escaping); an even number means - # it is not escaped (backslashes escape each other in pairs). - n_backslashes = 0 - j = i - 1 - while j >= 0 and s[j] == "\\": - n_backslashes += 1 - j -= 1 - if n_backslashes % 2 == 0: - return False - return True + # Strip all escape sequences (backslash + any char), then check if any + # metacharacter remains unescaped in the resulting string. + stripped = re.sub(r"\\.", "", s) + return not any(c in metacharacters for c in stripped) def unescape(s: str) -> str: