Skip to content
Merged
1 change: 1 addition & 0 deletions changelog/14392.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
EternalRights marked this conversation as resolved.
Outdated
17 changes: 14 additions & 3 deletions src/_pytest/raises.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a regex for it instead? I'm thinking we can do it in 2 steps:

  1. Strip all escapes (re.sub(r'\\.', '', pattern))
  2. Check if still contains any metacharacters (simple containment check).

Step 1 should ensure that none of the chars found in step 2 are escaped.

I think that would be shorter and a bit more obvious.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! Updated the changelog and refactored is_fully_escaped to use the regex approach — it's cleaner this way.

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:
Expand Down
16 changes: 16 additions & 0 deletions testing/python/raises.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,19 @@ 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
# "\\\\|" 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"\\\\|")