Skip to content

exception tracking

python tells you nothing about what a call can raise. the information exists — it is right there in the body — but it stops at the function boundary, so every caller either guesses, over-catches, or finds out in production. docstrings carry the contract, and nothing checks them

basedpython tracks it instead. every function has an exception set: the exceptions that can escape a call to it. the set is inferred from the body and propagates through calls, and a raises clause declares it explicitly

def f():  # raises TypeError
    raise TypeError

def g():  # raises TypeError — propagated from f
    f()

def h():  # raises nothing — the call is handled
    try:
        g()
    except TypeError:
        pass

def main():
    g()  # error: `TypeError` can escape `main`, the entry point

nothing here changes what the program does. like abstract and override, the clause is erased in the lowered python, and everything it promises is checked before a line runs — with an opt-in runtime guard in the same spirit as soundness checks

the raises clause

raises follows the return annotation and holds an ordinary type expression, so there is no second algebra to learn — the type system already has one:

def parse(text: str) -> int raises ValueError:
    ...

def read(path: str) -> bytes raises OSError | ValueError:
    ...

def pure(x: int) -> int raises Never:
    ...

def plugin() raises ...:
    ...
clause means
raises T at most T
raises A \| B at most A or B
raises Never cannot raise
raises ... may raise anything — opt out of tracking
no clause inferred from the body, and propagated to callers

Never is the empty set of exceptions and ... is the gradual one, which is exactly what those types mean everywhere else

the body is checked against the clause, and callers see the clause rather than the body:

def f() raises TypeError:
    raise ValueError  # error: `f` can raise `ValueError`, which its `raises` clause does not include

negation

not T is accepted, and means the negation type it always means. that is strict here: any two exception classes can be combined by a third that inherits both, so ValueError is not provably outside TypeError and is reported too

def f() raises not TypeError:
    raise ValueError  # error: `ValueError` is not provably outside `TypeError`

the practical way to rule an exception out is to declare what the function does raise, or raises Never

type parameters

a clause may name a type parameter, and a call reads it as whatever it solved that parameter to:

def rethrow[T: BaseException](error: T) raises T:
    raise error

def f() raises KeyError:
    rethrow(KeyError())  # raises KeyError, not BaseException

an explicit specialization is read the same way, and so is a call back into the same function that solves the parameter to something else:

def g() raises FileNotFoundError:
    rethrow[FileNotFoundError](FileNotFoundError())

a method may name its class's type parameter, and the receiver says which exception that is — Reader[KeyError].read as much as reader.read(). a function nested in a generic one can name the enclosing function's parameter, which keeps meaning what the enclosing call was made with

the parameter has to be declared an exception, since it stands for one type the caller chooses:

def bad[T](error: T) raises T:  # error: `T@bad` is not always an exception
    ...

a parameter left unsolved that names nothing where the call is written stands for everything it was declared to allow — its bound, or its set of constraints

what is inferred

the analysis reports what it can see in the body:

  • raise X and raise X(...), and a bare raise inside a handler
  • assert, which raises AssertionError
  • calls to functions whose body is visible, transitively

everything else contributes nothing. in particular a call into a stub raises nothing — the standard library, any third-party dependency — until that stub carries a raises clause of its own. that is the only workable default: assuming an unannotated callee raises anything would make every set BaseException and the feature useless

try narrows the set. exceptions raised in the try body that an except clause catches do not escape, while the handler, else and finally bodies contribute their own raises:

def f():  # raises ValueError — TypeError is handled, the handler's own raise is not
    try:
        raise TypeError
    except TypeError:
        raise ValueError

recursion is fine, including mutual recursion: the set is a least fixed point, and a function's own raises are the identity of the union it contributes to

an overloaded function contributes the union of what all its overloads and its implementation may raise. which overload a given call matched is not known to this analysis, so the set is an upper bound — deliberately, since the safe direction for an escape check is to name an exception that cannot happen rather than to miss one that can

where escapes are reported

an undeclared function simply propagates to its callers, so it is never an error on its own — that is what makes the feature usable on existing code without annotating anything. an escape is reported at exactly two boundaries:

  • a function with a raises clause that can raise outside it — undeclared-raise
  • main, the entry point, which has no caller to propagate to — unhandled-exception

both point at the raise or the call that produces the exception, not at the signature, so the fix is where the diagnostic is. a clause that contains no exception at all is invalid-raises-clause

main may declare a clause of its own, which opts it into the ordinary rule:

def main() raises TypeError:  # fine — main says it may exit this way
    raise TypeError

overrides

a call is checked against the type it can see. when a base method cannot raise, nothing at a call on the base type says an exception can escape — yet a subclass substituted for it can still raise from that call:

def a() -> A:
    return B()

class A:
    def foo(self):
        pass

class B(A):
    override def foo(self):
        raise TypeError

def main():
    a().foo()  # statically raises nothing; at runtime raises TypeError

override-raise closes that hole by bounding every override with the exception set of the method it overrides. it is on under the default type checking preset, and it comes at a price: honouring it makes a base method's set part of its contract, so adding a raise to a base method is a breaking change for every subclass. turn it off per project if that is the wrong trade:

[tool.basedpython.rules]
override-raise = "ignore"

B.foo above is reported, and the fixes are the ordinary ones: declare the base as raising (def foo(self) raises TypeError), handle the exception inside the override, or opt that method out with raises ...

only the nearest superclass defining the method is blamed, so a violation introduced part-way up a hierarchy is reported once rather than at every descendant. constructors are exempt, matching ty's existing policy for override compatibility

inlay hints

a function with no clause gets its inferred set as an inlay hint, written where the clause would go, so accepting it reads as ordinary source:

def leaf()⟨ raises TypeError⟩:
    raise TypeError

def caller()⟨ raises TypeError⟩:
    leaf()

runtime guards

static checking is unconditional. the runtime_raises_checks option (by run --runtime-raises-checks, off by default) additionally wraps each declared function in a guard that fails when it raises outside its clause, defending the contract against callers the checker never saw — untyped or third-party code:

def f() raises ValueError:
    ...

lowers to

@_by_raises(ValueError, "f")
def f():
    ...

the guard is a decorator, not a try around the body, so it never disturbs the lowering of anything inside the function. it reaches every declared function, including one defined inside if / try / with / for, and it picks its wrapper shape at decoration time so a coroutine, a generator and an async generator are each entered before the check rather than after

only a clause with a faithful runtime test is guarded: a gradual raises ... and any set with no runtime spelling are left alone, and raises Never becomes the empty tuple, which nothing is an instance of

a type parameter has no runtime spelling of its own — which exception it is was chosen by the caller, and the guard runs inside the callee — so the guard tests the parameter's ceiling, the bound or set of constraints it was declared with. that never rejects an exception the clause allows, and still catches one it does not:

def rethrow[T: OSError](error: T) raises T:
    raise error

lowers to

@_by_raises(OSError, "rethrow")
def rethrow[T: OSError](error: T):
    raise error

a reified parameter does carry the type the caller chose, and where the guard can read it, it tests exactly that:

def rethrow[reified T: OSError](error: dynamic) raises T:
    raise error

rethrow[FileNotFoundError](PermissionError())  # AssertionError: not a FileNotFoundError
  • a function's own parameter comes from the specialization it is called through
  • a class's parameter comes from the instance a method is called on. a function nested in a method has no receiver, so there it stays on the ceiling
  • an enclosing function's parameter is already bound where the nested function's guard is evaluated
  • a subscripted argument, which isinstance refuses, is tested by its origin, the way list[str] is tested as list

the guard never asks for an argument a parameter does not already carry: making one reified changes how the program is built, and turning a check on must not do that. an unreified parameter stays on its ceiling, where a PermissionError passes

a decorated function whose def another lowering rewrites — numbering a repeated _ parameter is one — cannot carry the guard: the def is printed from its syntax tree, and the insertion has nowhere in it to go. that is a transpile error rather than a silently missing check

known gaps

deliberate, for now:

  • context-manager __enter__ / __exit__, constructor calls, and operators and other implicit dunder dispatch contribute nothing
  • a finally block that swallows an in-flight exception by returning is not modelled
  • except* is treated as catching nothing, since what escapes it is a regrouped ExceptionGroup
  • the clause does not participate in callable assignability, so a raising function is still assignable to a raises Never callable type
  • an overloaded function's set is the union over its overloads rather than the one the call actually matched (see above)