<a id="smp507"></a>

<a id="error-message-matching"></a>

# error-message-matching (SMP507)

<div class="badges"><span class="badge code">SMP507</span><span class="badge fix-none">No fix</span><span class="badge">Logic</span><a class="badge cross-file" href="../../explanation/how-it-works.html#cross-file-rules" title="Other files of the run can change what this rule reports">🔗 Cross-file</a><a class="badge" href="../../rules.html">All rules</a></div>

Flags an `except` handler that tells exceptions apart by their text, by comparing or searching `str(exc)`, `exc.args` or
`exc.message`.

## Why is this bad?

The text of an exception is a message for people, and it changes with the wording, the translation and the library
version. A check on the text stops matching without any error, and the handler then takes the wrong branch. A specific
exception type says what happened, and it does not change with the text.

## Example

```python
try:
    data = load(path)
except OSError as exc:
    if "timed out" in str(exc):
        data = load(path, retries=3)
    else:
        raise
```

```python
try:
    data = load(path)
except TimeoutError:
    data = load(path, retries=3)
```

## What it does

Each case is checked inside the body of an `except ... as exc:` handler. Test modules are left alone, since their
asserts may check the messages the code raises.

### Active on

- **Comparisons and `in` tests with the text**: an operand that is `str(exc)`, `exc.args`, `exc.args[N]` or
  `exc.message`, in an `if` or `elif` condition or anywhere else in the handler.

  ### Example: reported

  ```python
  def run(exc_source):
      try:
          exc_source()
      except OSError as exc:
          if "timed out" in str(exc):
              return None
          if exc.args[0] == "x":
              return ""
          return exc
  ```
- **`str` methods that search the text**: `startswith`, `endswith`, `find`, `index` and `count` called on `str(exc)`.

  ### Example: reported

  ```python
  def run(exc_source):
      try:
          exc_source()
      except OSError as exc:
          if str(exc).startswith("timed"):
              return None
          return exc
  ```
- **`re` functions matching the text**: `re.search`, `re.match` and `re.fullmatch` with `str(exc)` as an argument.

  ### Example: reported

  ```python
  import re


  def run(exc_source):
      try:
          exc_source()
      except OSError as exc:
          if re.search("gone", str(exc)):
              return None
          return exc
  ```
- **`match` subjects**: a `match` statement whose subject is one of the text forms.

  ### Example: reported

  ```python
  def run(exc_source):
      try:
          exc_source()
      except OSError as exc:
          match str(exc):
              case "missing":
                  return None
          return exc
  ```

### Exceptions

- **The text in logging, formatting or re-raising**: a message built for a person does not pick a branch.

  ### Example: not reported

  ```python
  import logging

  logger = logging.getLogger(__name__)


  def run(exc_source):
      try:
          exc_source()
      except OSError as exc:
          logger.error("failed: %s", str(exc))
          print(f"failed: {exc}")
          raise
  ```
- **Attributes that hold data**: `exc.errno` and other fields are values, not text.

  ### Example: not reported

  ```python
  def run(exc_source):
      try:
          exc_source()
      except OSError as exc:
          if exc.errno == 2:
              return None
          raise
  ```
- **`assert` statements that name the exception**: Ruff’s
  [pytest-assert-in-except](https://docs.astral.sh/ruff/rules/pytest-assert-in-except/) (PT017) reports an `assert` in
  an `except` body that names the exception, so this rule leaves the comparison inside it to Ruff while the project’s
  Ruff configuration selects that rule. Ruff’s own suppressions of it, through `per-file-ignores` or `noqa`, are
  respected too.

  ### Example: not reported

  `pyproject.toml`
  ```toml
  [tool.ruff.lint]
  select = ["PT017"]
  ```

  ```python
  def run(exc_source):
      try:
          exc_source()
      except OSError as exc:
          assert "gone" in str(exc)
  ```
- **Comparisons of two exceptions’ text**: every operand is the text of an exception, such as `str(exc) == str(other)`.
  Both sides are text the runtime produces, so no fixed wording is written into the code that could drift. A name is an
  exception when each of its bindings in scope is an `except` clause name, or a parameter annotated with an exception
  type: a builtin one, a class of the run or one it imports from there, alone, in a union with `None`, in
  `Optional[...]`, or in a string annotation of those.

  ### Example: not reported

  ```python
  def replayed(target, error: OSError):
      try:
          target()
      except OSError as replay_error:
          return type(replay_error) is type(error) and replay_error.args == error.args
  ```
- **Test modules**: tests may assert on the messages the code raises.

  ### Example: not reported

  `test_errors.py`
  ```python
  def test_load():
      try:
          load()
      except OSError as exc:
          assert "gone" in str(exc)
  ```
- **`pytest.raises` with `match`**: the `match` argument of `pytest.raises` is a pattern the test expects, not a branch.

  ### Example: not reported

  ```python
  import pytest


  def test_load():
      with pytest.raises(OSError, match="gone"):
          load()
  ```

## Other tools

- **[Ruff](https://github.com/astral-sh/ruff):** variation of released rule
  [PT017](https://docs.astral.sh/ruff/rules/pytest-assert-in-except/), which reports an `assert` on the caught exception
  and which this rule leaves to it; branching on the message text is not requested yet.
