ruff/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/boolean.md
Carl Meyer 2abcd86c57
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run
Revert "[ty] Better control flow for boolean expressions that are inside if (#18010)" (#18150)
This reverts commit 9910ec700c.

## Summary

This change introduced a serious performance regression. Revert it while
we investigate.

Fixes https://github.com/astral-sh/ty/issues/431

## Test Plan

Timing on the snippet in https://github.com/astral-sh/ty/issues/431
again shows times similar to before the regression.
2025-05-17 08:27:32 -04:00

5.5 KiB
Raw Blame History

Narrowing for conditionals with boolean expressions

Narrowing in and conditional

class A: ...
class B: ...

def _(x: A | B):
    if isinstance(x, A) and isinstance(x, B):
        reveal_type(x)  # revealed:  A & B
    else:
        reveal_type(x)  # revealed:  (B & ~A) | (A & ~B)

Arms might not add narrowing constraints

class A: ...
class B: ...

def _(flag: bool, x: A | B):
    if isinstance(x, A) and flag:
        reveal_type(x)  # revealed: A
    else:
        reveal_type(x)  # revealed: A | B

    if flag and isinstance(x, A):
        reveal_type(x)  # revealed: A
    else:
        reveal_type(x)  # revealed: A | B

    reveal_type(x)  # revealed: A | B

Statically known arms

class A: ...
class B: ...

def _(x: A | B):
    if isinstance(x, A) and True:
        reveal_type(x)  # revealed: A
    else:
        reveal_type(x)  # revealed: B & ~A

    if True and isinstance(x, A):
        reveal_type(x)  # revealed: A
    else:
        reveal_type(x)  # revealed: B & ~A

    if False and isinstance(x, A):
        # TODO: should emit an `unreachable code` diagnostic
        reveal_type(x)  # revealed: A
    else:
        reveal_type(x)  # revealed: A | B

    if False or isinstance(x, A):
        reveal_type(x)  # revealed: A
    else:
        reveal_type(x)  # revealed: B & ~A

    if True or isinstance(x, A):
        reveal_type(x)  # revealed: A | B
    else:
        # TODO: should emit an `unreachable code` diagnostic
        reveal_type(x)  # revealed: B & ~A

    reveal_type(x)  # revealed: A | B

The type of multiple symbols can be narrowed down

class A: ...
class B: ...

def _(x: A | B, y: A | B):
    if isinstance(x, A) and isinstance(y, B):
        reveal_type(x)  # revealed: A
        reveal_type(y)  # revealed: B
    else:
        # No narrowing: Only-one or both checks might have failed
        reveal_type(x)  # revealed: A | B
        reveal_type(y)  # revealed: A | B

    reveal_type(x)  # revealed: A | B
    reveal_type(y)  # revealed: A | B

Narrowing in or conditional

class A: ...
class B: ...
class C: ...

def _(x: A | B | C):
    if isinstance(x, A) or isinstance(x, B):
        reveal_type(x)  # revealed:  A | B
    else:
        reveal_type(x)  # revealed:  C & ~A & ~B

In or, all arms should add constraint in order to narrow

class A: ...
class B: ...
class C: ...

def _(flag: bool, x: A | B | C):
    if isinstance(x, A) or isinstance(x, B) or flag:
        reveal_type(x)  # revealed:  A | B | C
    else:
        reveal_type(x)  # revealed:  C & ~A & ~B

in or, all arms should narrow the same set of symbols

class A: ...
class B: ...
class C: ...

def _(x: A | B | C, y: A | B | C):
    if isinstance(x, A) or isinstance(y, A):
        # The predicate might be satisfied by the right side, so the type of `x` cant be narrowed down here.
        reveal_type(x)  # revealed:  A | B | C
        # The same for `y`
        reveal_type(y)  # revealed:  A | B | C
    else:
        reveal_type(x)  # revealed:  (B & ~A) | (C & ~A)
        reveal_type(y)  # revealed:  (B & ~A) | (C & ~A)

    if (isinstance(x, A) and isinstance(y, A)) or (isinstance(x, B) and isinstance(y, B)):
        # Here, types of `x` and `y` can be narrowd since all `or` arms constraint them.
        reveal_type(x)  # revealed:  A | B
        reveal_type(y)  # revealed:  A | B
    else:
        reveal_type(x)  # revealed:  A | B | C
        reveal_type(y)  # revealed:  A | B | C

mixing and and not

class A: ...
class B: ...
class C: ...

def _(x: A | B | C):
    if isinstance(x, B) and not isinstance(x, C):
        reveal_type(x)  # revealed:  B & ~C
    else:
        # ~(B & ~C) -> ~B | C -> (A & ~B) | (C & ~B) | C -> (A & ~B) | C
        reveal_type(x)  # revealed: (A & ~B) | C

mixing or and not

class A: ...
class B: ...
class C: ...

def _(x: A | B | C):
    if isinstance(x, B) or not isinstance(x, C):
        reveal_type(x)  # revealed: B | (A & ~C)
    else:
        reveal_type(x)  # revealed: C & ~B

or with nested and

class A: ...
class B: ...
class C: ...

def _(x: A | B | C):
    if isinstance(x, A) or (isinstance(x, B) and not isinstance(x, C)):
        reveal_type(x)  # revealed:  A | (B & ~C)
    else:
        # ~(A | (B & ~C)) -> ~A & ~(B & ~C) -> ~A & (~B | C) -> (~A & C) | (~A ~ B)
        reveal_type(x)  # revealed:  C & ~A

and with nested or

class A: ...
class B: ...
class C: ...

def _(x: A | B | C):
    if isinstance(x, A) and (isinstance(x, B) or not isinstance(x, C)):
        # A & (B | ~C) -> (A & B) | (A & ~C)
        reveal_type(x)  # revealed:  (A & B) | (A & ~C)
    else:
        # ~((A & B) | (A & ~C)) ->
        # ~(A & B) & ~(A & ~C) ->
        # (~A | ~B) & (~A | C) ->
        # [(~A | ~B) & ~A] | [(~A | ~B) & C] ->
        # ~A | (~A & C) | (~B & C) ->
        # ~A | (C & ~B) ->
        # ~A | (C & ~B)  The positive side of ~A is  A | B | C ->
        reveal_type(x)  # revealed:  (B & ~A) | (C & ~A) | (C & ~B)

Boolean expression internal narrowing

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

    # Neither of the conditions alone is sufficient for narrowing y's type:
    if x is None:
        reveal_type(y)  # revealed: str | None

    if y is not x:
        reveal_type(y)  # revealed: str | None

Assignment expressions

def f() -> bool:
    return True

if x := f():
    reveal_type(x)  # revealed: Literal[True]
else:
    reveal_type(x)  # revealed: Literal[False]