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
what is inferred¶
the analysis reports what it can see in the body:
raise Xandraise X(...), and a bareraiseinside a handlerassert, which raisesAssertionError- 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
raisesclause 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:
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 a
strictness option and off by default, because honouring it makes a base
method's set part of its contract — adding a raise to a base method is then a
breaking change for every subclass. enable it per project:
with it on, 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:
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:
lowers to
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 decorated function whose statement another lowering re-renders cannot carry the guard — the insertion sits inside the range being rebuilt — and 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
finallyblock that swallows an in-flight exception by returning is not modelled except*is treated as catching nothing, since what escapes it is a regroupedExceptionGroup- the clause does not participate in callable assignability, so a raising
function is still assignable to a
raises Nevercallable type - an overloaded function's set is the union over its overloads rather than the one the call actually matched (see above)