ruff/crates/ty_python_semantic/resources/mdtest/exception/basic.md
Alex Waygood 3065f8dbbc
Some checks are pending
CI / cargo fmt (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (${{ github.repository == 'astral-sh/ruff' && 'depot-windows-2022-16' || 'windows-latest' }}) (push) Blocked by required conditions
CI / cargo test (macos-latest) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / ty completion evaluation (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks instrumented (ruff) (push) Blocked by required conditions
CI / benchmarks instrumented (ty) (push) Blocked by required conditions
CI / benchmarks walltime (medium|multithreaded) (push) Blocked by required conditions
CI / benchmarks walltime (small|large) (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run
[ty] Improve diagnostics for invalid exceptions (#21475)
## Summary

Not a high-priority task... but it _is_ a weekend :P

This PR improves our diagnostics for invalid exceptions. Specifically:
- We now give a special-cased ``help: Did you mean
`NotImplementedError`` subdiagnostic for `except NotImplemented`, `raise
NotImplemented` and `raise <EXCEPTION> from NotImplemented`
- If the user catches a tuple of exceptions (`except (foo, bar, baz):`)
and multiple elements in the tuple are invalid, we now collect these
into a single diagnostic rather than emitting a separate diagnostic for
each tuple element
- The explanation of why the `except`/`raise` was invalid ("must be a
`BaseException` instance or `BaseException` subclass", etc.) is
relegated to a subdiagnostic. This makes the top-level diagnostic
summary much more concise.

## Test Plan

Lots of snapshots. And here's some screenshots:

<details>
<summary>Screenshots</summary>

<img width="1770" height="1520" alt="image"
src="https://github.com/user-attachments/assets/7f27fd61-c74d-4ddf-ad97-ea4fd24d06fd"
/>

<img width="1916" height="1392" alt="image"
src="https://github.com/user-attachments/assets/83e5027c-8798-48a6-a0ec-1babfc134000"
/>

<img width="1696" height="588" alt="image"
src="https://github.com/user-attachments/assets/1bc16048-6eb4-4dfa-9ace-dd271074530f"
/>

</details>
2025-11-15 22:12:00 +00:00

5.9 KiB

Exception Handling

Single Exception

import re

try:
    help()
except NameError as e:
    reveal_type(e)  # revealed: NameError
except re.error as f:
    reveal_type(f)  # revealed: error

Unknown type in except handler does not cause spurious diagnostic

from nonexistent_module import foo  # error: [unresolved-import]

try:
    help()
except foo as e:
    reveal_type(foo)  # revealed: Unknown
    reveal_type(e)  # revealed: Unknown

Multiple Exceptions in a Tuple

EXCEPTIONS = (AttributeError, TypeError)

try:
    help()
except (RuntimeError, OSError) as e:
    reveal_type(e)  # revealed: RuntimeError | OSError
except EXCEPTIONS as f:
    reveal_type(f)  # revealed: AttributeError | TypeError

Dynamic exception types

def foo(
    x: type[AttributeError],
    y: tuple[type[OSError], type[RuntimeError]],
    z: tuple[type[BaseException], ...],
    zz: tuple[type[TypeError | RuntimeError], ...],
    zzz: type[BaseException] | tuple[type[BaseException], ...],
):
    try:
        help()
    except x as e:
        reveal_type(e)  # revealed: AttributeError
    except y as f:
        reveal_type(f)  # revealed: OSError | RuntimeError
    except z as g:
        reveal_type(g)  # revealed: BaseException
    except zz as h:
        reveal_type(h)  # revealed: TypeError | RuntimeError
    except zzz as i:
        reveal_type(i)  # revealed: BaseException

We do not emit an invalid-exception-caught if a class is caught that has Any or Unknown in its MRO, as the dynamic element in the MRO could materialize to some subclass of BaseException:

from compat import BASE_EXCEPTION_CLASS  # error: [unresolved-import] "Cannot resolve imported module `compat`"

class Error(BASE_EXCEPTION_CLASS): ...

try:
    ...
except Error as err:
    ...

Exception with no captured type

try:
    {}.get("foo")
except TypeError:
    pass

Exception which catches typevar

[environment]
python-version = "3.12"
from typing import Callable

def silence[T: type[BaseException]](
    func: Callable[[], None],
    exception_type: T,
):
    try:
        func()
    except exception_type as e:
        reveal_type(e)  # revealed: T'instance@silence

def silence2[T: (
    type[ValueError],
    type[TypeError],
)](func: Callable[[], None], exception_type: T,):
    try:
        func()
    except exception_type as e:
        reveal_type(e)  # revealed: T'instance@silence2

Invalid exception handlers

try:
    pass
# error: [invalid-exception-caught]
except 3 as e:
    reveal_type(e)  # revealed: Unknown

try:
    pass
# error: [invalid-exception-caught]
except (ValueError, OSError, "foo", b"bar") as e:
    reveal_type(e)  # revealed: ValueError | OSError | Unknown

def foo(
    x: type[str],
    y: tuple[type[OSError], type[RuntimeError], int],
    z: tuple[type[str], ...],
):
    try:
        help()
    # error: [invalid-exception-caught]
    except x as e:
        reveal_type(e)  # revealed: Unknown
    # error: [invalid-exception-caught]
    except y as f:
        reveal_type(f)  # revealed: OSError | RuntimeError | Unknown
    # error: [invalid-exception-caught]
    except z as g:
        reveal_type(g)  # revealed: Unknown

try:
    {}.get("foo")
# error: [invalid-exception-caught]
except int:
    pass

Object raised is not an exception

try:
    raise AttributeError()  # fine
except:
    ...

try:
    raise FloatingPointError  # fine
except:
    ...

try:
    raise 1  # error: [invalid-raise]
except:
    ...

try:
    raise int  # error: [invalid-raise]
except:
    ...

def _(e: Exception | type[Exception]):
    raise e  # fine

def _(e: Exception | type[Exception] | None):
    raise e  # error: [invalid-raise]

Exception cause is not an exception

def _():
    try:
        raise EOFError() from GeneratorExit  # fine
    except:
        ...

def _():
    try:
        raise StopIteration from MemoryError()  # fine
    except:
        ...

def _():
    try:
        raise BufferError() from None  # fine
    except:
        ...

def _():
    try:
        raise ZeroDivisionError from False  # error: [invalid-raise]
    except:
        ...

def _():
    try:
        raise SystemExit from bool()  # error: [invalid-raise]
    except:
        ...

def _():
    try:
        raise
    except KeyboardInterrupt as e:  # fine
        reveal_type(e)  # revealed: KeyboardInterrupt
        raise LookupError from e  # fine

def _():
    try:
        raise
    except int as e:  # error: [invalid-exception-caught]
        reveal_type(e)  # revealed: Unknown
        raise KeyError from e

def _(e: Exception | type[Exception]):
    raise ModuleNotFoundError from e  # fine

def _(e: Exception | type[Exception] | None):
    raise IndexError from e  # fine

def _(e: int | None):
    raise IndexError from e  # error: [invalid-raise]

The caught exception is cleared at the end of the except clause

e = None
reveal_type(e)  # revealed: None

try:
    raise ValueError()
except ValueError as e:
    reveal_type(e)  # revealed: ValueError
# error: [unresolved-reference]
reveal_type(e)  # revealed: Unknown

e = None

def cond() -> bool:
    return True

try:
    if cond():
        raise ValueError()
except ValueError as e:
    reveal_type(e)  # revealed: ValueError
# error: [possibly-unresolved-reference]
reveal_type(e)  # revealed: None

def f(x: type[Exception]):
    e = None
    try:
        raise x
    except ValueError as e:
        pass
    except:
        pass
    # error: [possibly-unresolved-reference]
    reveal_type(e)  # revealed: None

Special-cased diagnostics for NotImplemented

try:
    # error: [invalid-raise]
    # error: [invalid-raise]
    raise NotImplemented from NotImplemented
# error: [invalid-exception-caught]
except NotImplemented:
    pass
# error: [invalid-exception-caught]
except (TypeError, NotImplemented):
    pass