Repository: pytest-dev/pytest
Pull request: Fixed handling of invalid regex pattern
<!--
Thanks for submitting a PR, your contribution is really appreciated!

Here is a quick checklist that should be present in PRs.

- [X] Include documentation when adding new features.
- [X] Include new tests or update existing tests when applicable.
- [X] Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself.

If this change fixes an issue, please:

- [X] Add text like ``closes #XYZW`` to the PR description and/or commits (where ``XYZW`` is the issue number). See the [github docs](https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) for more information.

Unless your change is trivial or a small documentation fix (e.g., a typo or reword of a small section) please:

- [X] Create a new changelog file in the `changelog` folder, with a name like `<ISSUE NUMBER>.<TYPE>.rst`. See [changelog/README.rst](https://github.com/pytest-dev/pytest/blob/main/changelog/README.rst) for details.

  Write sentences in the **past or present tense**, examples:

  * *Improved verbose diff output with sequences.*
  * *Terminal summary statistics now use multiple colors.*

  Also make sure to end the sentence with a `.`.

- [X] Add yourself to `AUTHORS` in alphabetical order.
-->

closes #12505 

- Updated the `RaisesContext` class in `src/_pytest/python_api.py` to catch `re.error` and call `pytest.fail` with a clear error message.
- Added a test case in `testing/python/raises.py` to validate the new behavior.
- Created a changelog fragment to document this improvement.

Problem statement:
`pytest.raises` with invalid regex in match kwarg fails with internal `re.error`
<!--
Thanks for submitting an issue!

Quick check-list while reporting bugs:
-->
Using an invalid regex in `pytest.raises` fails in a messy way. 

```py
with pytest.raises(ValueError, match="invalid regex character ["):
    raise ValueError()
```

```shell
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ValueError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "C:\Users\user\Documents\Projects\external\pytest\src\_pytest\python_api.py", line 1011, in __exit__
    self.excinfo.match(self.match_expr)
  File "C:\Users\user\Documents\Projects\external\pytest\src\_pytest\_code\code.py", line 733, in match
    assert re.search(regexp, value), msg
  File "C:\Python38\lib\re.py", line 201, in search
    return _compile(pattern, flags).search(string)
  File "C:\Python38\lib\re.py", line 304, in _compile
    p = sre_compile.compile(pattern, flags)
  File "C:\Python38\lib\sre_compile.py", line 764, in compile
    p = sre_parse.parse(p, flags)
  File "C:\Python38\lib\sre_parse.py", line 948, in parse
    p = _parse_sub(source, state, flags & SRE_FLAG_VERBOSE, 0)
  File "C:\Python38\lib\sre_parse.py", line 443, in _parse_sub
    itemsappend(_parse(source, state, verbose, nested + 1,
  File "C:\Python38\lib\sre_parse.py", line 549, in _parse
    raise source.error("unterminated character set",
re.error: unterminated character set at position 24
```

Proposal would be to handle regex failures within `pytest.raises` and fail nicely for the user (`UsageError`).

- [ ] a detailed description of the bug or problem you are having
- [ ] output of `pip list` from the virtual environment you are using
- [ ] pytest and operating system versions
- [ ] minimal example if possible

Hints from issue discussion:
Here's how I would go about this:

- Take the example from above and turn it into a test case
    - `testing/python/raises.py` is where the existing tests are for `pytest.raises`
    - Since we are mostly interested in the *output* of pytest, a test [using `pytester`](https://doc.pytest.org/en/latest/how-to/writing_plugins.html#testing-plugins) would be a good fit here.
    - You could use one of the cases in `test_raises_as_contextmanager` as inspiration, but instead of matching that the test case passed, you use `result.stdout.fnmatch_lines` to see what output pytest produces when the pattern is invalid.
    - I'd actually make the test pass initially, i.e. test the current output.
- Improve pytest's output in that case.
    - If you look at the traceback from above, you can see that the relevant lines *inside* pytest where this is happening are:

https://github.com/pytest-dev/pytest/blob/329d3712146e69c471be3e30883d54bdde2f76cb/src/_pytest/python_api.py#L1005

https://github.com/pytest-dev/pytest/blob/329d3712146e69c471be3e30883d54bdde2f76cb/src/_pytest/_code/code.py#L728

(line numbers slightly different, not sure what version @lovetheguitar was on above)

- Find the correct place do do a change like this
    - The latter (`code.py`) is the more internal machinery in pytest. It might be used across different places, some of which are not user-facing. So this would be the wrong place to do this kind of change.
    - The former (`RaisesContext`) seems like the better place.
    - In fact, a few lines above you can already spot [a call to `pytest.fail(...)`](https://github.com/pytest-dev/pytest/blob/329d3712146e69c471be3e30883d54bdde2f76cb/src/_pytest/python_api.py#L997), which is how we [explicitely fail a test case nicely](https://docs.pytest.org/en/8.2.x/reference/reference.html#pytest-fail).
    - ...so the only thing that's left would be to catch the exception (`re.error`) and turn it into a `pytest.fail` call with a nice message
    - Make sure you format the original exception text into the new message to retain the information about what's wrong exactly
- Finally, adjust your test case to test the new output. Maybe [add an assertion](https://docs.pytest.org/en/8.2.x/reference/reference.html#pytest.LineMatcher.no_fnmatch_line) that makes sure that we don't print a full traceback, only a nice short message.
- This will also need a changelog fragment - see the [contribution docs](https://github.com/pytest-dev/pytest/blob/main/CONTRIBUTING.rst#preparing-pull-requests) for more details.

Commit message: [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Patch to review:
diff --git a/AUTHORS b/AUTHORS
index 5ae80eb02d3..50e44038d4d 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -429,6 +429,7 @@ Victor Rodriguez
 Victor Uriarte
 Vidar T. Fauske
 Vijay Arora
+Virendra Patil
 Virgil Dupras
 Vitaly Lashmanov
 Vivaan Verma
diff --git a/changelog/12505.bugfix.rst b/changelog/12505.bugfix.rst
new file mode 100644
index 00000000000..f43e4acb4bd
--- /dev/null
+++ b/changelog/12505.bugfix.rst
@@ -0,0 +1 @@
+Handle invalid regex patterns in `pytest.raises` by providing a clear error message, improving user experience.
diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py
index c1e851391b0..e3c599e4fc9 100644
--- a/src/_pytest/python_api.py
+++ b/src/_pytest/python_api.py
@@ -7,6 +7,7 @@
 import math
 from numbers import Complex
 import pprint
+import re
 from types import TracebackType
 from typing import Any
 from typing import Callable
@@ -1006,6 +1007,9 @@ def __exit__(
         # Cast to narrow the exception type now that it's verified.
         exc_info = cast(Tuple[Type[E], E, TracebackType], (exc_type, exc_val, exc_tb))
         self.excinfo.fill_unfilled(exc_info)
-        if self.match_expr is not None:
-            self.excinfo.match(self.match_expr)
+        try:
+            if self.match_expr is not None:
+                self.excinfo.match(self.match_expr)
+        except re.error as e:
+            fail(f"Invalid regex pattern provided to 'match': {e}")
         return True
diff --git a/testing/python/raises.py b/testing/python/raises.py
index 271dd3e5a8c..2011c81615e 100644
--- a/testing/python/raises.py
+++ b/testing/python/raises.py
@@ -132,6 +132,26 @@ def test_division(example_input, expectation):
         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):
             pytest.raises("wrong", lambda: None)  # type: ignore[call-overload]


Code review comments:
diff_hunk: @@ -1006,6 +1007,9 @@ def __exit__(
         # Cast to narrow the exception type now that it's verified.
         exc_info = cast(Tuple[Type[E], E, TracebackType], (exc_type, exc_val, exc_tb))
         self.excinfo.fill_unfilled(exc_info)
-        if self.match_expr is not None:
-            self.excinfo.match(self.match_expr)
+        try:
+            if self.match_expr is not None:
+                self.excinfo.match(self.match_expr)
+        except re.error as e:
+            fail(f"Invalid regex pattern provided to 'match': {e}")
path: src/_pytest/python_api.py
text: @user1:
Let's minimize the scope of the `try`:

```suggestion
        if self.match_expr is not None:
            try:
                self.excinfo.match(self.match_expr)
            except re.error as e:
                fail(f"Invalid regex pattern provided to 'match': {e}")
```

diff_hunk: @@ -0,0 +1 @@
+Handle invalid regex patterns in `pytest.raises` by providing a clear error message, improving user experience.
path: changelog/12505.bugfix.rst
text: @user1:
```suggestion
Improve handling of invalid regex patterns in :func:`pytest.raises(match=r'...') <pytest.raises>` by providing a clear error message.
```