error-message-matching (SMP507)

SMP507No fixLogic🔗 Cross-fileAll rules

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

Flagged
1try:
2    data = load(path)
3except OSError as exc:
4    if "timed out" in str(exc):
5        data = load(path, retries=3)
6    else:
7        raise
Preferred
1try:
2    data = load(path)
3except TimeoutError:
4    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
    1def run(exc_source):
    2    try:
    3        exc_source()
    4    except OSError as exc:
    5        if "timed out" in str(exc):
    6            return None
    7        if exc.args[0] == "x":
    8            return ""
    9        return exc
    
  • str methods that search the text: startswith, endswith, find, index and count called on str(exc).

    Example: reported
    1def run(exc_source):
    2    try:
    3        exc_source()
    4    except OSError as exc:
    5        if str(exc).startswith("timed"):
    6            return None
    7        return exc
    
  • re functions matching the text: re.search, re.match and re.fullmatch with str(exc) as an argument.

    Example: reported
     1import re
     2
     3
     4def run(exc_source):
     5    try:
     6        exc_source()
     7    except OSError as exc:
     8        if re.search("gone", str(exc)):
     9            return None
    10        return exc
    
  • match subjects: a match statement whose subject is one of the text forms.

    Example: reported
    1def run(exc_source):
    2    try:
    3        exc_source()
    4    except OSError as exc:
    5        match str(exc):
    6            case "missing":
    7                return None
    8        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
     1import logging
     2
     3logger = logging.getLogger(__name__)
     4
     5
     6def run(exc_source):
     7    try:
     8        exc_source()
     9    except OSError as exc:
    10        logger.error("failed: %s", str(exc))
    11        print(f"failed: {exc}")
    12        raise
    
  • Attributes that hold data: exc.errno and other fields are values, not text.

    Example: not reported
    1def run(exc_source):
    2    try:
    3        exc_source()
    4    except OSError as exc:
    5        if exc.errno == 2:
    6            return None
    7        raise
    
  • assert statements that name the exception: Ruff’s 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

    1[tool.ruff.lint]
    2select = ["PT017"]
    
    1def run(exc_source):
    2    try:
    3        exc_source()
    4    except OSError as exc:
    5        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
    1def replayed(target, error: OSError):
    2    try:
    3        target()
    4    except OSError as replay_error:
    5        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

    1def test_load():
    2    try:
    3        load()
    4    except OSError as exc:
    5        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
    1import pytest
    2
    3
    4def test_load():
    5    with pytest.raises(OSError, match="gone"):
    6        load()
    

Other tools

  • Ruff: variation of released rule PT017, which reports an assert on the caught exception and which this rule leaves to it; branching on the message text is not requested yet.