Rename Red Knot (#17820)

This commit is contained in:
Micha Reiser 2025-05-03 19:49:15 +02:00 committed by GitHub
parent e6a798b962
commit b51c4f82ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1564 changed files with 1598 additions and 1578 deletions

View file

@ -0,0 +1,611 @@
# Control flow for exception handlers
These tests assert that we understand the possible "definition states" (which symbols might or might
not be defined) in the various branches of a `try`/`except`/`else`/`finally` block.
For a full writeup on the semantics of exception handlers, see [this document][1].
The tests throughout this Markdown document use functions with names starting with `could_raise_*`
to mark definitions that might or might not succeed (as the function could raise an exception). A
type checker must assume that any arbitrary function call could raise an exception in Python; this
is just a naming convention used in these tests for clarity, and to future-proof the tests against
possible future improvements whereby certain statements or expressions could potentially be inferred
as being incapable of causing an exception to be raised.
## A single bare `except`
Consider 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 must infer a union of all possible "definition states" we could have
been in at any point during the `try` suite. This is because control flow could have jumped to the
`except` suite without any of the `try`-suite definitions successfully completing, with only *some*
of the `try`-suite definitions successfully completing, or indeed with *all* of them successfully
completing. The type of `x` at the beginning of the `except` suite in this example is therefore
`Literal[1] | str`, taking into account that we might have jumped to the `except` suite before the
`x = could_raise_returns_str()` redefinition, but we *also* could have jumped to the `except` suite
*after* that redefinition.
```py
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:
```py
x = 1
try:
x = could_raise_returns_str()
except:
x = could_raise_returns_str()
reveal_type(x) # revealed: str
```
## A non-bare `except`
For 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][1], that would lead
to termination of the scope. It's therefore irrelevant to consider this possibility when it comes to
control-flow analysis.
```py
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]
```
## Multiple `except` branches
If 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:
- At the end of `try`, `type(x) == str`
- At the end of `except TypeError`, `x == 2`
- At the end of `except ValueError`, `x == 3`
```py
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]
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]
```
## Exception handlers with `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:
- At the end of `else`, `x == 3`
- At the end of `except`, `x == 2`
```py
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]
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:
```py
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]
```
## Exception handlers with `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]`:
```py
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.)
```py
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
```
## Combining an `except` branch with a `finally` branch
As 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:
- The `try` suite could have run to completion
- Or we could have jumped from halfway through the `try` suite to an `except` suite, and the
`except` suite ran to completion
- Or we could have jumped from halfway through the `try` suite straight to the `finally` suite due
to an unhandled exception
- Or we could have jumped from halfway through the `try` 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 completion
```py
class 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.)
```py
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:
```py
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
```
## Combining `except`, `else` and `finally` branches
If 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*.
```py
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:
```py
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
```
## Nested `try`/`except` blocks
It would take advanced analysis, which we are not yet capable of, to be able to determine that an
exception handler always suppresses all exceptions. This is partly because it is possible for
statements in `except`, `else` and `finally` suites to raise exceptions as well as statements in
`try` suites. This means that if an exception handler is nested inside the `try` statement of an
enclosing exception handler, it should (at least for now) be treated the same as any other node: as
a suite containing statements that could possibly raise exceptions, which would lead to control flow
jumping out of that suite prior to the suite running to completion.
```py
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
```
## Nested scopes inside `try` blocks
Shadowing a variable in an inner scope has no effect on type inference of the variable by that name
in the outer scope:
```py
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=A) -> Unknown
except:
reveal_type(x) # revealed: Literal[1] | (def foo(param=A) -> Unknown)
class Bar:
x = could_raise_returns_E()
reveal_type(x) # revealed: E
x = Bar
reveal_type(x) # revealed: Literal[Bar]
finally:
# TODO: should be `Literal[1] | Literal[foo] | Literal[Bar]`
reveal_type(x) # revealed: (def foo(param=A) -> Unknown) | Literal[Bar]
reveal_type(x) # revealed: (def foo(param=A) -> Unknown) | Literal[Bar]
```
[1]: https://astral-sh.notion.site/Exception-handler-control-flow-11348797e1ca80bb8ce1e9aedbbe439d