Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,106 @@ if (x := f()) in (1,):
else:
reveal_type(x) # revealed: Literal[2, 3]
```

## Union with `Literal`, `None` and `int`

```py
from typing import Literal

def test(x: Literal["a", "b", "c"] | None | int = None):
if x in ("a", "b"):
# int is included because custom __eq__ methods could make
# an int equal to "a" or "b", so we can't eliminate it
reveal_type(x) # revealed: Literal["a", "b"] | int
else:
reveal_type(x) # revealed: Literal["c"] | None | int
```

## Direct `not in` conditional

```py
from typing import Literal

def test(x: Literal["a", "b", "c"] | None | int = None):
if x not in ("a", "c"):
# int is included because custom __eq__ methods could make
# an int equal to "a" or "b", so we can't eliminate it
reveal_type(x) # revealed: Literal["b"] | None | int
else:
reveal_type(x) # revealed: Literal["a", "c"] | int
```

## bool

```py
def _(x: bool):
if x in (True,):
reveal_type(x) # revealed: Literal[True]
else:
reveal_type(x) # revealed: Literal[False]

def _(x: bool | str):
if x in (False,):
# `str` remains due to possible custom __eq__ methods on a subclass
reveal_type(x) # revealed: Literal[False] | str
else:
reveal_type(x) # revealed: Literal[True] | str
```

## LiteralString

```py
from typing_extensions import LiteralString

def _(x: LiteralString):
if x in ("a", "b", "c"):
reveal_type(x) # revealed: Literal["a", "b", "c"]
else:
reveal_type(x) # revealed: LiteralString & ~Literal["a"] & ~Literal["b"] & ~Literal["c"]

def _(x: LiteralString | int):
if x in ("a", "b", "c"):
reveal_type(x) # revealed: Literal["a", "b", "c"] | int
else:
reveal_type(x) # revealed: (LiteralString & ~Literal["a"] & ~Literal["b"] & ~Literal["c"]) | int
```

## enums

```py
from enum import Enum

class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"

def _(x: Color):
if x in (Color.RED, Color.GREEN):
# TODO should be `Literal[Color.RED, Color.GREEN]`
reveal_type(x) # revealed: Color
else:
# TODO should be `Literal[Color.BLUE]`
reveal_type(x) # revealed: Color
```

## Union with enum and `int`

```py
from enum import Enum

class Status(Enum):
PENDING = 1
APPROVED = 2
REJECTED = 3

def test(x: Status | int):
if x in (Status.PENDING, Status.APPROVED):
# TODO should be `Literal[Status.PENDING, Status.APPROVED] | int`
# int is included because custom __eq__ methods could make
# an int equal to Status.PENDING or Status.APPROVED, so we can't eliminate it
reveal_type(x) # revealed: Status | int
else:
# TODO should be `Literal[Status.REJECTED] | int`
reveal_type(x) # revealed: Status | int
```
18 changes: 10 additions & 8 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,16 @@ impl<'db> Type<'db> {
|| self.is_literal_string()
}

pub(crate) fn is_union_with_single_valued(&self, db: &'db dyn Db) -> bool {
self.into_union().is_some_and(|union| {
union
.elements(db)
.iter()
.any(|ty| ty.is_single_valued(db) || ty.is_bool(db) || ty.is_literal_string())
}) || self.is_bool(db)
|| self.is_literal_string()
Comment on lines +1062 to +1064
Copy link
Contributor

@carljm carljm Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We include bool and LiteralString here as types that make a type be a "union with single valued", but where we actually use this method, we only really handle unions, and we don't do any special handling for bool or LiteralString. So I don't think there's currently any benefit to including them here.

And probably enums should be considered as well as an effective "union of single-valued types" both here and above.

Maybe we could at least add some tests that have bool, LiteralString and enum types on the LHS -- I think that would illuminate cases that we'd want to handle. They could stay TODOs in this PR, if they aren't easy to handle.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/astral-sh/ruff/pull/20164/files#diff-4d769c34388fdd9011945264bed8fb2199b60ffefcbaa09d0804e4e6c347fd4dR134
added some TODOs in the in.md. If the tests looks good to you, I will take a look tomorrow to see if it's doable for me in the near future.

}

pub(crate) fn into_string_literal(self) -> Option<StringLiteralType<'db>> {
match self {
Type::StringLiteral(string_literal) => Some(string_literal),
Expand Down Expand Up @@ -9956,14 +9966,6 @@ impl<'db> StringLiteralType<'db> {
pub(crate) fn python_len(self, db: &'db dyn Db) -> usize {
self.value(db).chars().count()
}

/// Return an iterator over each character in the string literal.
/// as would be returned by Python's `iter()`.
pub(crate) fn iter_each_char(self, db: &'db dyn Db) -> impl Iterator<Item = Self> {
self.value(db)
.chars()
.map(|c| StringLiteralType::new(db, c.to_string().into_boxed_str()))
}
}

/// # Ordering
Expand Down
98 changes: 80 additions & 18 deletions crates/ty_python_semantic/src/types/narrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -615,24 +615,88 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> {
}
}

// TODO `expr_in` and `expr_not_in` should perhaps be unified with `expr_eq` and `expr_ne`,
// since `eq` and `ne` are equivalent to `in` and `not in` with only one element in the RHS.
fn evaluate_expr_in(&mut self, lhs_ty: Type<'db>, rhs_ty: Type<'db>) -> Option<Type<'db>> {
if lhs_ty.is_single_valued(self.db) || lhs_ty.is_union_of_single_valued(self.db) {
if let Type::StringLiteral(string_literal) = rhs_ty {
Some(UnionType::from_elements(
self.db,
string_literal
.iter_each_char(self.db)
.map(Type::StringLiteral),
))
} else if let Some(tuple_spec) = rhs_ty.tuple_instance_spec(self.db) {
// N.B. Strictly speaking this is unsound, since a tuple subclass might override `__contains__`
// but we'd still apply the narrowing here. This seems unlikely, however, and narrowing is
// generally unsound in numerous ways anyway (attribute narrowing, subscript, narrowing,
// narrowing of globals, etc.). So this doesn't seem worth worrying about too much.
Some(UnionType::from_elements(self.db, tuple_spec.all_elements()))
} else {
None
rhs_ty
.try_iterate(self.db)
.ok()
.map(|iterable| iterable.homogeneous_element_type(self.db))
} else if lhs_ty.is_union_with_single_valued(self.db) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that our handling of == narrowing is also missing this same logic, when the LHS isn't all single-valued types, but does contain some single-valued types that could be eliminated.

Really, == and in narrowing are closely related: == is equivalent to in with just a single element on the RHS. Right now are implementations of them are totally separate. Is it possible that we could add support for this case to our == narrowing, and then re-implement our in narrowing in terms of repeated application of our == narrowing?

One last thought: rather than using all of is_single_valued and is_union_of_single_valued and now also is_union_with_single_valued, where the latter two each also have complicated semantics involving special handling of bool and LiteralString (and probably also should include enum types, but don't), I think it might be clearer if we just eliminate those methods and use direct matches on lhs_ty here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried use the in/not_in to replace the eq/not_eq, got many errors(11) include

  crates/ty_python_semantic/resources/mdtest/scopes/global.md:84 unexpected error: [invalid-assignment] "Object of type `int | None` is not assignable to `int`"
  
    crates/ty_python_semantic/resources/mdtest/type_compendium/integer_literals.md:77 unmatched assertion: revealed: int & ~Literal[54165]
  crates/ty_python_semantic/resources/mdtest/type_compendium/integer_literals.md:77 unexpected error: 21 [revealed-type] "Revealed type: `int`"

  crates/ty_python_semantic/resources/mdtest/narrow/while.md:57 unmatched assertion: revealed: Literal[3]
  crates/ty_python_semantic/resources/mdtest/narrow/while.md:57 unexpected error: 21 [revealed-type] "Revealed type: `Literal[1, 2, 3]`"

Maybe we shall create a new thread to discuss about how to combine in and eq, and the proposed behavior change of existing eq function.

let rhs_values = rhs_ty
.try_iterate(self.db)
.ok()?
.homogeneous_element_type(self.db);

let mut builder = UnionBuilder::new(self.db);

// Add the narrowed values from the RHS first, to keep literals before broader types.
builder = builder.add(rhs_values);

if let Some(lhs_union) = lhs_ty.into_union() {
for element in lhs_union.elements(self.db) {
// Keep only the non-single-valued portion of the original type.
if !element.is_single_valued(self.db)
&& !element.is_literal_string()
&& !element.is_bool(self.db)
{
builder = builder.add(*element);
}
}
}
Some(builder.build())
} else {
None
}
}

fn evaluate_expr_not_in(&mut self, lhs_ty: Type<'db>, rhs_ty: Type<'db>) -> Option<Type<'db>> {
let rhs_values = rhs_ty
.try_iterate(self.db)
.ok()?
.homogeneous_element_type(self.db);

if lhs_ty.is_single_valued(self.db) || lhs_ty.is_union_of_single_valued(self.db) {
// Exclude the RHS values from the entire (single-valued) LHS domain.
let complement = IntersectionBuilder::new(self.db)
.add_positive(lhs_ty)
.add_negative(rhs_values)
.build();
Some(complement)
} else if lhs_ty.is_union_with_single_valued(self.db) {
// Split LHS into single-valued portion and the rest. Exclude RHS values from the
// single-valued portion, keep the rest intact.
let mut single_builder = UnionBuilder::new(self.db);
let mut rest_builder = UnionBuilder::new(self.db);

if let Some(lhs_union) = lhs_ty.into_union() {
for element in lhs_union.elements(self.db) {
if element.is_single_valued(self.db)
|| element.is_literal_string()
|| element.is_bool(self.db)
{
single_builder = single_builder.add(*element);
} else {
rest_builder = rest_builder.add(*element);
}
}
}

let single_union = single_builder.build();
let rest_union = rest_builder.build();

let narrowed_single = IntersectionBuilder::new(self.db)
.add_positive(single_union)
.add_negative(rhs_values)
.build();

// Keep order: first literal complement, then broader arms.
let result = UnionBuilder::new(self.db)
.add(narrowed_single)
.add(rest_union)
.build();
Some(result)
} else {
None
}
Expand Down Expand Up @@ -660,9 +724,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> {
ast::CmpOp::Eq => self.evaluate_expr_eq(lhs_ty, rhs_ty),
ast::CmpOp::NotEq => self.evaluate_expr_ne(lhs_ty, rhs_ty),
ast::CmpOp::In => self.evaluate_expr_in(lhs_ty, rhs_ty),
ast::CmpOp::NotIn => self
.evaluate_expr_in(lhs_ty, rhs_ty)
.map(|ty| ty.negate(self.db)),
ast::CmpOp::NotIn => self.evaluate_expr_not_in(lhs_ty, rhs_ty),
_ => None,
}
}
Expand Down
Loading