crates/ty_python_semantic/resources/mdtest/exception/control_flow.md
These tests describe which names are defined and what types they have in the branches of a
try/except/else/finally statement.
For a full writeup on the semantics of exception handlers, see this document.
Functions whose names start with could_raise_ make it clear that a call may raise an exception
before an assignment completes. Any other function call can raise as well.
An exception handler can run only if the try block contains an operation that can raise. Assigning
a literal to a local name cannot raise:
x = 1
try:
x = 2
except:
x = "unreachable"
reveal_type(x) # revealed: Literal[2]
Testing literals, comparing identities, combining these conditions, and iterating over a list literal cannot raise either:
def known_safe_conditions(value: int | None) -> None:
state = 0
try:
if not False:
state = 1
if not (value is None):
state = 1
if True and True:
state = 1
if False or True:
state = 1
for _ in [0]:
state = 1
except:
state = 2
reveal_type(state) # revealed: Literal[1]
An undefined name raises NameError, so an exception handler can provide its value:
try:
fallback # ty: ignore[unresolved-reference]
except NameError:
fallback = 1
def use_fallback() -> None:
reveal_type(fallback) # revealed: Literal[1]
A conditionally defined name may retain its original value or receive a value from the handler:
def possibly_bound(flag: bool) -> None:
if flag:
value = 1
try:
value # ty: ignore[possibly-unresolved-reference]
except NameError:
value = 2
def use_value() -> None:
reveal_type(value) # revealed: Literal[1, 2]
A name that is definitely defined in the current scope cannot raise NameError:
def definitely_bound(local_value: int) -> None:
state = 0
try:
local_value
except NameError:
state = 1
reveal_type(state) # revealed: Literal[0]
A later local assignment can shadow a builtin and make an earlier reference raise
UnboundLocalError:
def shadowed_builtin() -> None:
try:
int # ty: ignore[unresolved-reference]
except NameError:
int = 1
def use_shadowed_builtin() -> None:
reveal_type(int) # revealed: Literal[1]
An exception handler can also provide a missing name when that name is used as an attribute receiver:
try:
receiver.attribute # ty: ignore[unresolved-reference]
except NameError:
receiver = object()
def use_receiver() -> None:
reveal_type(receiver) # revealed: object
Likewise, a subscript receiver may raise before its index is evaluated. The subscript itself may raise after the index has been evaluated:
state = "before"
try:
state = 0
missing[(state := 1)] # ty: ignore[unresolved-reference]
except NameError:
reveal_type(state) # revealed: Literal[0, 1]
If a function call raises, an assignment in one of its arguments has already completed:
def may_raise(value: object) -> None: ...
x = 0
try:
may_raise(x := 1)
except:
reveal_type(x) # revealed: Literal[1]
When an import fails, its target has not been assigned. An exception handler can therefore provide a fallback without conflicting with the imported module's type:
try:
import ssl
except ImportError:
ssl = None
When importing several names, an earlier name may already be defined when a later import fails:
first = 0
try:
from collections.abc import Awaitable as first, Iterable as second
except ImportError:
second = None
reveal_type(first) # revealed: Literal[0] | <class 'Awaitable'>
A raise statement runs after earlier assignments have completed:
x = 1
try:
x = 2
raise RuntimeError
except:
reveal_type(x) # revealed: Literal[2]
A failing assertion preserves the narrowing implied by its failed condition:
def check_assertion(x: int | None) -> None:
try:
assert x is not None
except:
reveal_type(x) # revealed: None
Short-circuiting determines whether an assignment inside the assertion has run:
def check_short_circuit_assertion(flag: bool) -> None:
state = 2
try:
assert flag and (state := 0)
except:
reveal_type(state) # revealed: Literal[2, 0]
Attribute access can raise after its receiver has been evaluated:
class C:
value: int
def attribute_access(c: C) -> None:
state: C | int = 0
try:
(state := c).value
except:
reveal_type(state) # revealed: C
A subscript can raise after its index has been evaluated:
def subscript_access(values: list[int]) -> None:
state = 0
try:
values[state := 1]
except:
reveal_type(state) # revealed: Literal[1]
Repeated calls with unchanged bindings do not alter the values visible to an exception handler, but a later reassignment must still be included:
def may_raise() -> None: ...
def repeated_calls() -> None:
state = 0
try:
may_raise()
may_raise()
state = "changed"
may_raise()
except:
reveal_type(state) # revealed: Literal[0, "changed"]
Branch narrowing changes the state visible to the handler even when neither branch introduces a new binding:
def narrowed_branch(value: int | None) -> None:
try:
if value is not None:
may_raise()
may_raise()
except:
reveal_type(value) # revealed: int
Both sides of a restored branch remain visible when each can raise:
def restored_branches(value: int | None) -> None:
try:
if value is not None:
may_raise()
else:
may_raise()
except:
reveal_type(value) # revealed: int | None
Deleting a binding changes the flow state even though the name remains present in the scope:
def deleted_binding() -> None:
state = 1
try:
may_raise()
del state
may_raise()
except:
# error: [possibly-unresolved-reference]
reveal_type(state) # revealed: Literal[1]
A call that cannot return still prevents later assignments from reaching the exception handler:
from typing import NoReturn
def stop() -> NoReturn:
raise RuntimeError
def call_never_returns() -> None:
state = 0
try:
stop()
state = "unreachable"
may_raise()
except:
reveal_type(state) # revealed: Literal[0]
An arithmetic operator can raise after evaluating both operands:
class Number:
def __truediv__(self, other: int) -> int:
raise NotImplementedError
def __lt__(self, other: int) -> bool:
raise NotImplementedError
def division(number: Number) -> None:
state = 0
try:
number / (state := 1)
except:
reveal_type(state) # revealed: Literal[1]
A comparison is also evaluated after its operands:
def comparison(number: Number) -> None:
state = 0
try:
number < (state := 1)
except:
reveal_type(state) # revealed: Literal[1]
Augmented assignment evaluates the target before its right-hand side. Reading the target can raise before the right-hand side runs:
def augmented_assignment(values: list[int]) -> None:
target_state = 0
rhs_state = 0
try:
values[target_state := 1] += (rhs_state := 1)
except:
reveal_type(target_state) # revealed: Literal[1]
reveal_type(rhs_state) # revealed: Literal[0, 1]
Evaluating an if condition can call __bool__ or __len__ and raise before its body runs:
def if_condition(value: object) -> None:
state = 0
try:
if value:
state = 1
except:
reveal_type(state) # revealed: Literal[0]
An assignment expression with a safe value cannot raise, including when it appears in an identity comparison:
def safe_named_expressions() -> None:
caught = False
try:
if value := 1:
pass
if (value := 1) is not None:
pass
except:
caught = True
reveal_type(caught) # revealed: Literal[False]
An assignment expression can still raise while calling its right-hand side or testing an unknown value's truthiness:
def unsafe_named_expressions(value: object, may_raise) -> None:
caught = False
try:
if bound := may_raise():
pass
except:
caught = True
reveal_type(caught) # revealed: bool
caught = False
try:
if bound := value:
pass
except:
caught = True
reveal_type(caught) # revealed: bool
A while condition can fail before its first iteration or after an earlier iteration:
def while_condition(value: object) -> None:
state = 0
try:
while value:
state = 1
except:
reveal_type(state) # revealed: Literal[0, 1]
A sequence pattern can raise before its capture target or case body is assigned:
def sequence_pattern(value: object) -> None:
state = 0
try:
state = 1
match value:
case [item]:
state = 2
except:
reveal_type(state) # revealed: Literal[1]
item # error: [unresolved-reference]
Mapping, class, and literal patterns can call user-defined matching or equality operations:
class Point:
x: int
def mapping_pattern(value: object) -> None:
state = 0
try:
state = 1
match value:
case {"x": item}:
state = 2
except:
reveal_type(state) # revealed: Literal[1]
def class_pattern(value: object) -> None:
state = 0
try:
state = 1
match value:
case Point(x=item):
state = 2
except:
reveal_type(state) # revealed: Literal[1]
def literal_pattern(value: object) -> None:
state = 0
try:
state = 1
match value:
case 1:
state = 2
except:
reveal_type(state) # revealed: Literal[1]
Wildcard, capture, and singleton patterns do not invoke user-defined operations:
def safe_patterns(value: object) -> None:
caught = False
try:
match value:
case None:
pass
case captured:
pass
match value:
case _:
pass
except:
caught = True
reveal_type(caught) # revealed: Literal[False]
An iterator can fail before producing its first item or after an earlier iteration has completed:
from collections.abc import AsyncIterable, Iterable
def iteration(values: Iterable[int]) -> None:
state = 0
try:
state = 1
for _ in values:
state = 2
except:
reveal_type(state) # revealed: Literal[1, 2]
Assigning an iteration target can also fail before or after an earlier iteration:
class C:
value: int
def iteration_target(target: C) -> None:
state = 0
try:
state = 1
for target.value in [0, 1]:
state = 2
except:
reveal_type(state) # revealed: Literal[1, 2]
The same possibilities apply to asynchronous iteration:
async def async_iteration(values: AsyncIterable[int]) -> None:
state = 0
try:
state = 1
async for _ in values:
state = 2
except:
reveal_type(state) # revealed: Literal[1, 2]
A context manager can raise before its body runs or after the body completes:
def context_manager_entry_and_exit(manager) -> None:
state = 0
try:
with manager:
state = 1
except:
reveal_type(state) # revealed: Literal[0, 1]
Asynchronous context managers have the same entry and exit behavior:
async def async_context_manager_entry_and_exit(manager) -> None:
state = 0
try:
async with manager:
state = 1
except:
reveal_type(state) # revealed: Literal[0, 1]
If entering the context manager fails, its as target has not yet been assigned:
def context_manager_target_may_be_unbound(manager) -> None:
try:
with manager as value:
pass
except:
value # error: [possibly-unresolved-reference]
Earlier context managers have already entered when a later manager raises:
from typing import Literal
class FirstManager:
def __enter__(self) -> Literal[1]:
return 1
def __exit__(self, *_):
pass
def multiple_context_managers(first: FirstManager, second) -> None:
state = 0
try:
with first as state, second:
state = 2
except:
reveal_type(state) # revealed: Literal[0, 1, 2]
Unpacking can fail before the assignments following it run:
from collections.abc import Iterable
def unpacking(values: Iterable[int]) -> None:
state = 0
try:
first, second = values
state = 1
except:
reveal_type(state) # revealed: Literal[0]
Awaiting can raise when a coroutine resumes:
from collections.abc import Awaitable, Iterable
async def awaiting(value: Awaitable[int]) -> None:
state = 0
try:
state = 1
await value
except:
reveal_type(state) # revealed: Literal[1]
Delegating to another iterable can raise while the generator is resumed:
def yielding_from(values: Iterable[int]):
state = 0
try:
state = 1
yield from values
except:
reveal_type(state) # revealed: Literal[1]
A plain yield can also raise when an exception is sent into the generator:
def yielding():
state = 0
try:
state = 1
yield
except:
reveal_type(state) # revealed: Literal[1]
A class body runs immediately, so an exception raised there reaches the surrounding handler:
def may_raise() -> None: ...
x = 0
try:
class C:
may_raise()
except:
x = 1
reveal_type(x) # revealed: Literal[0, 1]
A class-body assignment to a nonlocal variable is visible when the body raises:
def class_nonlocal_assignment_raises() -> None:
state = "before"
try:
class C:
nonlocal state
state = 1
raise ValueError
except ValueError:
reveal_type(state) # revealed: Literal["before", 1]
A nested class body also runs eagerly, so its nonlocal assignment reaches the surrounding handler:
def nested_class_nonlocal_assignment_raises() -> None:
state = "before"
try:
class Outer:
class Inner:
nonlocal state
state = 1
raise ValueError
except ValueError:
reveal_type(state) # revealed: Literal["before", 1]
A class can fail during construction even when its body contains only an assignment:
def class_construction_can_raise() -> None:
state = "before"
caught = False
try:
class C:
nonlocal state
state = 1
except:
caught = True
reveal_type(caught) # revealed: bool
A class-construction hook runs after the class body's nonlocal assignment:
class RaisingBase:
def __init_subclass__(cls) -> None:
raise ValueError
def class_construction_hook_raises() -> None:
state = 0
try:
class C(RaisingBase):
nonlocal state
state = 1
except ValueError:
reveal_type(state) # revealed: Literal[0, 1]
A class decorator is applied after the class body has run:
def class_decorator_raises(decorator) -> None:
state = 0
try:
@decorator
class C:
nonlocal state
state = 1
except ValueError:
reveal_type(state) # revealed: Literal[0, 1]
A function decorator is applied after its parameter defaults have been evaluated:
def function_decorator_raises(decorator) -> None:
state = 0
try:
@decorator
def inner(value=(state := 1)) -> None:
pass
except Exception:
reveal_type(state) # revealed: Literal[1]
Decorator application can also raise when the function has no parameter defaults:
def function_decorator_without_defaults(decorator) -> None:
caught = False
try:
@decorator
def inner() -> None:
pass
except Exception:
caught = True
reveal_type(caught) # revealed: bool
A list comprehension also runs immediately:
y = 0
try:
[may_raise() for _ in [0]]
except:
y = 1
reveal_type(y) # revealed: Literal[0, 1]
A generator expression does not run its body until the generator is consumed:
z = 0
try:
(may_raise() for _ in [0])
except:
z = 1
reveal_type(z) # revealed: Literal[0]
A nested function body also runs later, so its exceptions cannot reach the handler surrounding its definition:
function_caught = False
try:
def nested_function() -> None:
may_raise()
except:
function_caught = True
reveal_type(function_caught) # revealed: Literal[False]
An exception handler inside a lazily evaluated function still catches exceptions raised within that function:
outer_caught = False
try:
def nested_function_with_handler() -> None:
inner_caught = False
try:
may_raise()
except:
inner_caught = True
reveal_type(inner_caught) # revealed: bool
except:
outer_caught = True
reveal_type(outer_caught) # revealed: Literal[False]
A handler includes the value from before a comprehension and the value visible once it finishes. Assignments overwritten inside the comprehension are not tracked separately, while normal completion still preserves the final assignment:
def comprehension_may_raise() -> None: ...
def overwritten_comprehension_assignment() -> None:
state = None
try:
[(state := 1, comprehension_may_raise(), state := "later") for _ in [0]]
except:
# TODO: Include `int` from the assignment before the raising call.
reveal_type(state) # revealed: None | str
return
reveal_type(state) # revealed: str
Dictionary comprehensions are also evaluated eagerly:
def dict_comprehension_assignment() -> None:
state = "before"
try:
{item: (state := 1, comprehension_may_raise()) for item in [0]}
except:
reveal_type(state) # revealed: Literal["before"] | int
Assignments and calls in a generator expression do not execute when the generator is created, so they do not make the surrounding exception handler reachable:
def generator_may_raise() -> None: ...
def lazy_generator_assignment() -> None:
state = 0
caught = False
try:
((state := 1, generator_may_raise()) for _ in [0])
except:
caught = True
reveal_type(caught) # revealed: Literal[False]
An assignment in an inner comprehension still updates the scope containing the outermost comprehension:
def comprehension_may_raise() -> None: ...
def nested_comprehension_assignments() -> None:
state = None
try:
[[(state := 1, comprehension_may_raise()) for _ in [0]] for _ in [0]]
except:
reveal_type(state) # revealed: None | int
An assignment expression in a module-level comprehension updates the module-level name:
def comprehension_may_raise() -> None: ...
module_comprehension_state = "before"
try:
[(module_comprehension_state := 1, comprehension_may_raise()) for _ in [0]]
except:
reveal_type(module_comprehension_state) # revealed: Literal["before"] | int
An explicitly global assignment updates the same name from inside a function:
global_comprehension_state = "before"
def global_comprehension_assignment() -> None:
global global_comprehension_state
try:
[(global_comprehension_state := 1, comprehension_may_raise()) for _ in [0]]
except:
reveal_type(global_comprehension_state) # revealed: int | Literal["before"]
An asynchronous comprehension can raise during iteration or after an assignment has completed:
from collections.abc import AsyncIterable, Awaitable
async def async_comprehension_assignment(values: AsyncIterable[int], awaitable: Awaitable[int]) -> None:
state = "before"
try:
state = "ready"
[(state := 1, await awaitable) async for _ in values]
except:
reveal_type(state) # revealed: Literal["ready"] | int
finally clauseAn outer handler includes assignments from an intervening finally clause:
state = 0
try:
try:
state = 1
raise ValueError
finally:
state = 2
except ValueError:
reveal_type(state) # revealed: Literal[1, 2]
Cleanup also runs before an exception escapes an inner handler for a different exception type:
state = 0
try:
try:
state = 1
raise ValueError
except TypeError:
state = 3
finally:
state = 2
except ValueError:
reveal_type(state) # revealed: Literal[1, 2]
An exception path must not contaminate the normal continuation after cleanup:
def may_raise() -> None: ...
state = 0
try:
try:
may_raise()
state = 1
finally:
pass
reveal_type(state) # revealed: Literal[1]
except:
pass
Cleanup remains visible when earlier and later calls share the same exception checkpoint:
state = 0
try:
may_raise()
try:
may_raise()
state = 1
finally:
state = 2
except:
reveal_type(state) # revealed: Literal[0, 2]
A return passing through non-raising cleanup does not make an outer exception handler reachable:
def return_through_cleanup() -> None:
try:
try:
return
finally:
state = 2
except:
reveal_type(state) # revealed: Never
A bare inner handler catches an exception before it can reach the outer handler:
def may_raise() -> None: ...
x = 0
try:
try:
x = 1
may_raise()
except:
x = 2
except:
x = "outer"
reveal_type(x) # revealed: Literal[1, 2]
An exception raised inside the inner handler can still reach the outer handler:
try:
try:
may_raise()
except:
x = 3
may_raise()
except:
reveal_type(x) # revealed: Literal[3]
A newly entered inner handler receives exceptions even if an earlier call already reached the outer handler without changing any bindings:
def inner_handler_after_outer_checkpoint() -> None:
try:
may_raise()
try:
may_raise()
except:
caught_inside = True
reveal_type(caught_inside) # revealed: Literal[True]
except:
pass
Code in an unreachable inner handler cannot make the outer handler reachable:
z = 0
try:
try:
pass
except:
may_raise()
except:
z = 1
reveal_type(z) # revealed: Literal[0]
exceptConsider the following try/except block, with a single bare except:. There are different types
for the variable x in the two branches of this block, and we can't determine which branch might
have been taken from the perspective of code following this block. The inferred type after the
block's conclusion is therefore the union of the type at the end of the try suite (str) and the
type at the end of the except suite (Literal[2]).
Within the except suite, we infer a union of the definition states at each exception checkpoint
in the try suite. The type of x at the beginning of the except suite in this example is
therefore Literal[1] | str: the call on the right-hand side can raise before the redefinition
completes, while the later reveal_type call can raise after it completes.
def could_raise_returns_str() -> str:
return "foo"
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_str()
reveal_type(x) # revealed: str
except:
reveal_type(x) # revealed: Literal[1] | str
x = 2
reveal_type(x) # revealed: Literal[2]
reveal_type(x) # revealed: str | Literal[2]
If x has the same type at the end of both branches, however, the branches unify and x is not
inferred as having a union type following the try/except block:
x = 1
try:
x = could_raise_returns_str()
except:
x = could_raise_returns_str()
reveal_type(x) # revealed: str
exceptFor simple try/except blocks, an except TypeError: handler has the same control flow semantics
as an except: handler. An except TypeError: handler will not catch all exceptions: if this is
the only handler, it opens up the possibility that an exception might occur that would not be
handled. However, as described in the document on exception-handling semantics, that would lead
to termination of the scope. It's therefore irrelevant to consider this possibility when it comes to
control-flow analysis.
def could_raise_returns_str() -> str:
return "foo"
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_str()
reveal_type(x) # revealed: str
except TypeError:
reveal_type(x) # revealed: Literal[1] | str
x = 2
reveal_type(x) # revealed: Literal[2]
reveal_type(x) # revealed: str | Literal[2]
except branchesIf the scope reaches the final reveal_type call in this example, either the try-block suite of
statements was executed in its entirety, or exactly one except suite was executed in its entirety.
The inferred type of x at this point is the union of the types at the end of the three suites:
try, type(x) == strexcept TypeError, x == 2except ValueError, x == 3def could_raise_returns_str() -> str:
return "foo"
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_str()
reveal_type(x) # revealed: str
except TypeError:
reveal_type(x) # revealed: Literal[1] | str
x = 2
reveal_type(x) # revealed: Literal[2]
except ValueError:
reveal_type(x) # revealed: Literal[1] | str
x = 3
reveal_type(x) # revealed: Literal[3]
reveal_type(x) # revealed: str | Literal[2, 3]
else branches (but no finally)If we reach the reveal_type call at the end of this scope, either the try and else suites were
both executed in their entireties, or the except suite was executed in its entirety. The type of
x at this point is the union of the type at the end of the else suite and the type at the end of
the except suite:
else, x == 3except, x == 2def could_raise_returns_str() -> str:
return "foo"
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_str()
reveal_type(x) # revealed: str
except TypeError:
reveal_type(x) # revealed: Literal[1] | str
x = 2
reveal_type(x) # revealed: Literal[2]
else:
reveal_type(x) # revealed: str
x = 3
reveal_type(x) # revealed: Literal[3]
reveal_type(x) # revealed: Literal[2, 3]
For a block that has multiple except branches and an else branch, the same principle applies. In
order to reach the final reveal_type call, either exactly one of the except suites must have
been executed in its entirety, or the try suite and the else suite must both have been executed
in their entireties:
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_str()
reveal_type(x) # revealed: str
except TypeError:
reveal_type(x) # revealed: Literal[1] | str
x = 2
reveal_type(x) # revealed: Literal[2]
except ValueError:
reveal_type(x) # revealed: Literal[1] | str
x = 3
reveal_type(x) # revealed: Literal[3]
else:
reveal_type(x) # revealed: str
x = 4
reveal_type(x) # revealed: Literal[4]
reveal_type(x) # revealed: Literal[2, 3, 4]
finally branches (but no except branches)A finally suite is always executed. As such, if we reach the reveal_type call at the end of
this example, we know that x must have been reassigned to 2 during the finally suite. The
type of x at the end of the example is therefore Literal[2]:
def could_raise_returns_str() -> str:
return "foo"
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_str()
reveal_type(x) # revealed: str
finally:
x = 2
reveal_type(x) # revealed: Literal[2]
reveal_type(x) # revealed: Literal[2]
If x was not redefined in the finally suite, however, things are somewhat more complicated. If
we reach the final reveal_type call, unlike the state when we're visiting the finally suite, we
know that the try-block suite ran to completion. This means that there are fewer possible states
at this point than there were when we were inside the finally block.
(Our current model does not correctly infer the types inside finally suites, however; this is
still a TODO item for us.)
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_str()
reveal_type(x) # revealed: str
finally:
# TODO: should be Literal[1] | str
reveal_type(x) # revealed: str
reveal_type(x) # revealed: str
except branch with a finally branchAs previously stated, we do not yet have accurate inference for types inside finally suites.
When we do, however, we will have to take account of the following possibilities inside finally
suites:
try suite could have run to completiontry suite to an except suite, and the
except suite ran to completiontry suite straight to the finally suite due
to an unhandled exceptiontry suite to an except suite, only for an
exception raised in the except suite to cause us to jump to the finally suite before the
except suite ran to completionclass A: ...
class B: ...
class C: ...
def could_raise_returns_A() -> A:
return A()
def could_raise_returns_B() -> B:
return B()
def could_raise_returns_C() -> C:
return C()
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_A()
reveal_type(x) # revealed: A
except TypeError:
reveal_type(x) # revealed: Literal[1] | A
x = could_raise_returns_B()
reveal_type(x) # revealed: B
x = could_raise_returns_C()
reveal_type(x) # revealed: C
finally:
# TODO: should be `Literal[1] | A | B | C`
reveal_type(x) # revealed: A | C
x = 2
reveal_type(x) # revealed: Literal[2]
reveal_type(x) # revealed: Literal[2]
Now for an example without a redefinition in the finally suite. As before, there should be fewer
possibilities after completion of the finally suite than there were during the finally suite
itself. (In some control-flow possibilities, some exceptions were merely suspended during the
finally suite; these lead to the scope's termination following the conclusion of the finally
suite.)
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_A()
reveal_type(x) # revealed: A
except TypeError:
reveal_type(x) # revealed: Literal[1] | A
x = could_raise_returns_B()
reveal_type(x) # revealed: B
x = could_raise_returns_C()
reveal_type(x) # revealed: C
finally:
# TODO: should be `Literal[1] | A | B | C`
reveal_type(x) # revealed: A | C
reveal_type(x) # revealed: A | C
An example with multiple except branches and a finally branch:
class D: ...
class E: ...
def could_raise_returns_D() -> D:
return D()
def could_raise_returns_E() -> E:
return E()
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_A()
reveal_type(x) # revealed: A
except TypeError:
reveal_type(x) # revealed: Literal[1] | A
x = could_raise_returns_B()
reveal_type(x) # revealed: B
x = could_raise_returns_C()
reveal_type(x) # revealed: C
except ValueError:
reveal_type(x) # revealed: Literal[1] | A
x = could_raise_returns_D()
reveal_type(x) # revealed: D
x = could_raise_returns_E()
reveal_type(x) # revealed: E
finally:
# TODO: should be `Literal[1] | A | B | C | D | E`
reveal_type(x) # revealed: A | C | E
reveal_type(x) # revealed: A | C | E
except, else and finally branchesIf the exception handler has an else branch, we must also take into account the possibility that
control flow could have jumped to the finally suite from partway through the else suite due to
an exception raised there.
class A: ...
class B: ...
class C: ...
class D: ...
class E: ...
def could_raise_returns_A() -> A:
return A()
def could_raise_returns_B() -> B:
return B()
def could_raise_returns_C() -> C:
return C()
def could_raise_returns_D() -> D:
return D()
def could_raise_returns_E() -> E:
return E()
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_A()
reveal_type(x) # revealed: A
except TypeError:
reveal_type(x) # revealed: Literal[1] | A
x = could_raise_returns_B()
reveal_type(x) # revealed: B
x = could_raise_returns_C()
reveal_type(x) # revealed: C
else:
reveal_type(x) # revealed: A
x = could_raise_returns_D()
reveal_type(x) # revealed: D
x = could_raise_returns_E()
reveal_type(x) # revealed: E
finally:
# TODO: should be `Literal[1] | A | B | C | D | E`
reveal_type(x) # revealed: C | E
reveal_type(x) # revealed: C | E
The same again, this time with multiple except branches:
class F: ...
class G: ...
def could_raise_returns_F() -> F:
return F()
def could_raise_returns_G() -> G:
return G()
x = 1
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_A()
reveal_type(x) # revealed: A
except TypeError:
reveal_type(x) # revealed: Literal[1] | A
x = could_raise_returns_B()
reveal_type(x) # revealed: B
x = could_raise_returns_C()
reveal_type(x) # revealed: C
except ValueError:
reveal_type(x) # revealed: Literal[1] | A
x = could_raise_returns_D()
reveal_type(x) # revealed: D
x = could_raise_returns_E()
reveal_type(x) # revealed: E
else:
reveal_type(x) # revealed: A
x = could_raise_returns_F()
reveal_type(x) # revealed: F
x = could_raise_returns_G()
reveal_type(x) # revealed: G
finally:
# TODO: should be `Literal[1] | A | B | C | D | E | F | G`
reveal_type(x) # revealed: C | E | G
reveal_type(x) # revealed: C | E | G
try/except blocksA checkpoint in a nested try suite propagates to both the nested and enclosing handlers unless the
nested statement has a bare handler. Checkpoints in its except, else, and finally suites
propagate only to the enclosing handler, because exceptions raised there are not handled by the same
try statement.
class A: ...
class B: ...
class C: ...
class D: ...
class E: ...
class F: ...
class G: ...
class H: ...
class I: ...
class J: ...
class K: ...
def could_raise_returns_A() -> A:
return A()
def could_raise_returns_B() -> B:
return B()
def could_raise_returns_C() -> C:
return C()
def could_raise_returns_D() -> D:
return D()
def could_raise_returns_E() -> E:
return E()
def could_raise_returns_F() -> F:
return F()
def could_raise_returns_G() -> G:
return G()
def could_raise_returns_H() -> H:
return H()
def could_raise_returns_I() -> I:
return I()
def could_raise_returns_J() -> J:
return J()
def could_raise_returns_K() -> K:
return K()
x = 1
try:
try:
reveal_type(x) # revealed: Literal[1]
x = could_raise_returns_A()
reveal_type(x) # revealed: A
except TypeError:
reveal_type(x) # revealed: Literal[1] | A
x = could_raise_returns_B()
reveal_type(x) # revealed: B
x = could_raise_returns_C()
reveal_type(x) # revealed: C
except ValueError:
reveal_type(x) # revealed: Literal[1] | A
x = could_raise_returns_D()
reveal_type(x) # revealed: D
x = could_raise_returns_E()
reveal_type(x) # revealed: E
else:
reveal_type(x) # revealed: A
x = could_raise_returns_F()
reveal_type(x) # revealed: F
x = could_raise_returns_G()
reveal_type(x) # revealed: G
finally:
# TODO: should be `Literal[1] | A | B | C | D | E | F | G`
reveal_type(x) # revealed: C | E | G
x = 2
reveal_type(x) # revealed: Literal[2]
reveal_type(x) # revealed: Literal[2]
except:
reveal_type(x) # revealed: Literal[1, 2] | A | B | C | D | E | F | G
x = could_raise_returns_H()
reveal_type(x) # revealed: H
x = could_raise_returns_I()
reveal_type(x) # revealed: I
else:
reveal_type(x) # revealed: Literal[2]
x = could_raise_returns_J()
reveal_type(x) # revealed: J
x = could_raise_returns_K()
reveal_type(x) # revealed: K
finally:
# TODO: should be `Literal[1, 2] | A | B | C | D | E | F | G | H | I | J | K`
reveal_type(x) # revealed: I | K
# Either one `except` branch or the `else`
# must have been taken and completed to get here:
reveal_type(x) # revealed: I | K
try blocksShadowing a variable in an inner scope has no effect on type inference of the variable by that name in the outer scope:
class A: ...
class B: ...
class C: ...
class D: ...
class E: ...
def could_raise_returns_A() -> A:
return A()
def could_raise_returns_B() -> B:
return B()
def could_raise_returns_C() -> C:
return C()
def could_raise_returns_D() -> D:
return D()
def could_raise_returns_E() -> E:
return E()
x = 1
try:
def foo(param=could_raise_returns_A()):
x = could_raise_returns_A()
try:
reveal_type(x) # revealed: A
x = could_raise_returns_B()
reveal_type(x) # revealed: B
except:
reveal_type(x) # revealed: A | B
x = could_raise_returns_C()
reveal_type(x) # revealed: C
x = could_raise_returns_D()
reveal_type(x) # revealed: D
finally:
# TODO: should be `A | B | C | D`
reveal_type(x) # revealed: B | D
reveal_type(x) # revealed: B | D
x = foo
reveal_type(x) # revealed: def foo(param=...) -> Unknown
except:
reveal_type(x) # revealed: Literal[1] | (def foo(param=...) -> Unknown)
class Bar:
x = could_raise_returns_E()
reveal_type(x) # revealed: E
x = Bar
reveal_type(x) # revealed: <class 'Bar'>
finally:
# TODO: should be `Literal[1] | <class 'foo'> | <class 'Bar'>`
reveal_type(x) # revealed: (def foo(param=...) -> Unknown) | <class 'Bar'>
reveal_type(x) # revealed: (def foo(param=...) -> Unknown) | <class 'Bar'>