Files
ruff/crates/red_knot_python_semantic/resources/mdtest/narrow/assert.md
Alex Waygood 8a6787b39e [red-knot] Fix control flow for assert statements (#17702)
## Summary

@sharkdp and I realised in our 1:1 this morning that our control flow
for `assert` statements isn't quite accurate at the moment. Namely, for
something like this:

```py
def _(x: int | None):
    assert x is None, reveal_type(x)
```

we currently reveal `None` for `x` here, but this is incorrect. In
actual fact, the `msg` expression of an `assert` statement (the
expression after the comma) will only be evaluated if the test (`x is
None`) evaluates to `False`. As such, we should be adding a constraint
of `~None` to `x` in the `msg` expression, which should simplify the
inferred type of `x` to `int` in that context (`(int | None) & ~None` ->
`int`).

## Test Plan

Mdtests added.

---------

Co-authored-by: David Peter <mail@david-peter.de>
2025-04-30 09:57:49 +02:00

2.9 KiB

Narrowing with assert statements

assert a value is None or is not None

def _(x: str | None, y: str | None):
    assert x is not None
    reveal_type(x)  # revealed: str
    assert y is None
    reveal_type(y)  # revealed: None

assert a value is truthy or falsy

def _(x: bool, y: bool):
    assert x
    reveal_type(x)  # revealed: Literal[True]
    assert not y
    reveal_type(y)  # revealed: Literal[False]

assert with is and == for literals

from typing import Literal

def _(x: Literal[1, 2, 3], y: Literal[1, 2, 3]):
    assert x is 2
    reveal_type(x)  # revealed: Literal[2]
    assert y == 2
    reveal_type(y)  # revealed: Literal[2]

assert with isinstance

def _(x: int | str):
    assert isinstance(x, int)
    reveal_type(x)  # revealed: int

assert a value in a tuple

from typing import Literal

def _(x: Literal[1, 2, 3], y: Literal[1, 2, 3]):
    assert x in (1, 2)
    reveal_type(x)  # revealed: Literal[1, 2]
    assert y not in (1, 2)
    reveal_type(y)  # revealed: Literal[3]

Assertions with messages

def _(x: int | None, y: int | None):
    reveal_type(x)  # revealed: int | None
    assert x is None, reveal_type(x)  # revealed: int
    reveal_type(x)  # revealed: None

    reveal_type(y)  # revealed: int | None
    assert isinstance(y, int), reveal_type(y)  # revealed: None
    reveal_type(y)  # revealed: int

Assertions with definitions inside the message

def one(x: int | None):
    assert x is None, (y := x * 42) * reveal_type(y)  # revealed: int

    # error: [unresolved-reference]
    reveal_type(y)  # revealed: Unknown

def two(x: int | None, y: int | None):
    assert x is None, (y := 42) * reveal_type(y)  # revealed: Literal[42]
    reveal_type(y)  # revealed: int | None

Assertions with test predicates that are statically known to always be True

assert True, (x := 1)

# error: [unresolved-reference]
reveal_type(x)  # revealed: Unknown

assert False, (y := 1)

# The `assert` statement is terminal if `test` resolves to `False`,
# so even though we know the `msg` branch will have been taken here
# (we know what the truthiness of `False is!), we also know that the
# `y` definition is not visible from this point in control flow
# (because this point in control flow is unreachable).
# We make sure that this does not emit an `[unresolved-reference]`
# diagnostic by adding a reachability constraint,
# but the inferred type is `Unknown`.
#
reveal_type(y)  # revealed: Unknown

Assertions with messages that reference definitions from the test

def one(x: int | None):
    assert (y := x), reveal_type(y)  # revealed: (int & ~AlwaysTruthy) | None
    reveal_type(y)  # revealed: int & ~AlwaysFalsy

def two(x: int | None):
    assert isinstance((y := x), int), reveal_type(y)  # revealed: None
    reveal_type(y)  # revealed: int