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:
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:
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 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 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:
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 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:
lowers to
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
isinstancerefuses, is tested by its origin, the waylist[str]is tested aslist
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
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)