Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,153 @@ For references, see:
- <https://docs.python.org/3/reference/datamodel.html#object.__contains__>
- <https://snarky.ca/unravelling-membership-testing/>

## Inline list and set literals

An immediately consumed list or set literal preserves its elements' values for membership tests.
This lets us determine whether a literal value is present or absent.

```py
reveal_type("a" in ["a", "b"]) # revealed: Literal[True]
reveal_type("c" in ["a", "b"]) # revealed: Literal[False]
reveal_type("a" not in ["a", "b"]) # revealed: Literal[False]
reveal_type("c" not in ["a", "b"]) # revealed: Literal[True]

reveal_type("a" in {"a", "b"}) # revealed: Literal[True]
reveal_type("c" in {"a", "b"}) # revealed: Literal[False]
reveal_type("a" not in {"a", "b"}) # revealed: Literal[False]
reveal_type("c" not in {"a", "b"}) # revealed: Literal[True]
```

Membership uses runtime equality, including equality between integer and boolean values. Duplicate
set elements do not change membership, and an empty list cannot contain any value.

```py
reveal_type(1 in [True]) # revealed: Literal[True]
reveal_type(True in {1, 1}) # revealed: Literal[True]
reveal_type(b"a" in {b"a", b"b"}) # revealed: Literal[True]
reveal_type("a" in []) # revealed: Literal[False]
reveal_type("a" not in []) # revealed: Literal[True]
```

## Inline elements with inferred literal types

Elements can have known literal values without using literal syntax. A function returning a literal
type provides the same membership information as the corresponding literal expression.

```py
from typing import Literal

def first() -> Literal["a"]:
return "a"

second = "b"
reveal_type("a" in [first(), second]) # revealed: Literal[True]
reveal_type("c" in [first(), second]) # revealed: Literal[False]
reveal_type("b" in {first(), second}) # revealed: Literal[True]
reveal_type("c" in {first(), second}) # revealed: Literal[False]
```

An element with a union type contributes only one of its possible values. Neither alternative is
guaranteed to be present, but a value outside the union is definitely absent.

```py
def uncertain_element(value: Literal["a", "b"]):
reveal_type("a" in [value]) # revealed: bool
reveal_type("c" in [value]) # revealed: Literal[False]
reveal_type("a" in {value}) # revealed: bool
reveal_type("c" in {value}) # revealed: Literal[False]

def unknown_element(value: str):
reveal_type("a" in [value]) # revealed: bool
reveal_type("a" in {value}) # revealed: bool
```

An unknown element does not prevent a later known element from establishing membership.

```py
def known_element(value: str):
reveal_type("a" in [value, "a"]) # revealed: Literal[True]
reveal_type("a" not in [value, "a"]) # revealed: Literal[False]
```

## Unpacked inline list elements

Unpacking a fixed-length tuple or another inline list preserves the known elements. Unpacking a list
of unknown length does not guarantee that any value is present.

```py
reveal_type("a" in [*("a", "b")]) # revealed: Literal[True]
reveal_type("c" not in [*["a", "b"]]) # revealed: Literal[True]

def unpack_unknown(values: list[str]):
reveal_type("a" in [*values]) # revealed: bool
```

## Stored mutable containers

A stored list or set can be empty or contain only some of the values permitted by its element type.
A literal element type alone therefore does not establish membership.

```py
from typing import Literal

def stored(values: list[Literal["a", "b"]], keys: set[Literal["a", "b"]]):
reveal_type("a" in values) # revealed: bool
reveal_type("a" not in values) # revealed: bool
reveal_type("a" in keys) # revealed: bool
reveal_type("a" not in keys) # revealed: bool
```

## Inline containers with custom equality

A list tests its elements using identity or equality. A custom equality method that always returns
true can therefore establish membership. Sets also depend on hashes, so equality alone does not
establish membership when custom objects are involved.

```py
from typing import Literal

class MatchesEverything:
def __eq__(self, other: object) -> Literal[True]:
return True

def __hash__(self) -> int:
return 0

reveal_type(1 in [MatchesEverything()]) # revealed: Literal[True]
reveal_type(1 not in [MatchesEverything()]) # revealed: Literal[False]
reveal_type(1 in {MatchesEverything()}) # revealed: bool
reveal_type(1 not in {MatchesEverything()}) # revealed: bool
reveal_type(MatchesEverything() in {1}) # revealed: bool
```

Identity also counts as a match, even when equality always returns false. The equality result alone
therefore cannot establish that a custom object is absent.

```py
class NeverEqual:
def __eq__(self, other: object) -> Literal[False]:
return False

value = NeverEqual()
reveal_type(value in [value]) # revealed: bool
```

## Inline membership in comparison chains

The following comparison receives the actual list type of the shared operand, so its reflected
method can accept the list even when the preceding membership test has a known result.

```py
from typing import Literal

class LargerThanList:
def __gt__(self, other: list[str]) -> Literal[True]:
return True

reveal_type("a" in ["a"] < LargerThanList()) # revealed: Literal[True]
```

## Implements `__contains__`

Classes can support membership tests by implementing the `__contains__` method:
Expand Down
34 changes: 34 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/loops/iterators.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,37 @@ def generator_function():
yield from Iterable()
yield from NotIterable() # error: "Object of type `NotIterable` is not iterable"
```

## Tuple subclasses

A tuple subclass that inherits `tuple.__iter__` yields its stored elements in order. Converting it
to a tuple or unpacking it into an inline list preserves those elements' precise types.

```py
from collections.abc import Iterator
from typing import Literal

class Inherited(tuple[Literal["a"], Literal["b"]]): ...

inherited = Inherited(("a", "b"))
reveal_type(tuple(inherited)) # revealed: tuple[Literal["a"], Literal["b"]]
reveal_type("b" in [*inherited]) # revealed: Literal[True]
```

An overridden iterator can yield different elements, including none at all. Its return annotation
determines the element type, but does not guarantee the tuple's stored values will be yielded. The
same applies when another subclass inherits that override.

```py
class Empty(tuple[Literal["a"]]):
def __iter__(self) -> Iterator[Literal["a"]]:
return iter(())

overridden = Empty(("a",))
reveal_type(tuple(overridden)) # revealed: tuple[Literal["a"], ...]
reveal_type("a" in [*overridden]) # revealed: bool

class AlsoEmpty(Empty): ...

reveal_type(tuple(AlsoEmpty(("a",)))) # revealed: tuple[Literal["a"], ...]
```
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,21 @@ def after_excluding_red_mixed(x: Color | int):
reveal_type(x) # revealed: Literal[Color.BLUE] | int
```

Inline set literals also preserve the individual enum members for narrowing in either branch.

```py
def inline_enum_set(x: Color):
if x in {Color.RED, Color.GREEN}:
reveal_type(x) # revealed: Literal[Color.RED, Color.GREEN]
else:
reveal_type(x) # revealed: Literal[Color.BLUE]

if x not in {Color.RED}:
reveal_type(x) # revealed: Literal[Color.GREEN, Color.BLUE]
else:
reveal_type(x) # revealed: Literal[Color.RED]
```

When the container's element type is a union of enum literals, membership narrows to that union.
Without the annotation, the tuple's elements are widened to `Color`, so the comprehension remains
`list[Color]`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,50 @@ darwin
other
```

### `sys.platform` membership in inline containers

Membership in an inline list or set of platform names is statically known when the target platform
is configured. Assignments in matching branches replace the initial value.

```toml
[environment]
python-platform = "linux"
```

```py
import sys

x = 0
if sys.platform in {"linux", "windows"}:
x = 1
reveal_type(x) # revealed: Literal[1]

y = 0
if sys.platform in ["linux", "windows"]:
y = 1
reveal_type(y) # revealed: Literal[1]
```

An absent platform leaves the initial value unchanged. Negating a membership test reverses which
branch is reachable.

```py
absent = 0
if sys.platform in ["darwin", "win32"]:
absent = 1
reveal_type(absent) # revealed: Literal[0]

excluded = 0
if sys.platform not in {"darwin", "win32"}:
excluded = 1
reveal_type(excluded) # revealed: Literal[1]

present = 0
if sys.platform not in ["linux", "win32"]:
present = 1
reveal_type(present) # revealed: Literal[0]
```

### `typing.TYPE_CHECKING`

```py
Expand Down
6 changes: 6 additions & 0 deletions crates/ty_python_semantic/src/types/equality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1928,6 +1928,12 @@ fn has_reflexive_equality_semantics<'db>(
evaluator: &ComparisonEvaluator<'db>,
ty: Type<'db>,
) -> bool {
if let Type::Union(union) = ty.resolve_type_alias(evaluator.db) {
return union
.elements(evaluator.db)
.iter()
.all(|element| has_reflexive_equality_semantics(evaluator, *element));
}
Comment thread
AlexWaygood marked this conversation as resolved.
evaluator
.comparison_semantics(ty, ComparisonOperator::Equality)
.is_some()
Expand Down
23 changes: 18 additions & 5 deletions crates/ty_python_semantic/src/types/infer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11644,14 +11644,27 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {

let range = TextRange::new(left.start(), right.end());

let ty = comparisons::infer_binary_type_comparison(
let literal_membership = comparisons::infer_literal_membership_comparison(
&builder.context,
left_ty,
*op,
right_ty,
range,
)
.unwrap_or_else(|error| {
right,
|element| builder.expression_type(element),
);

let comparison = literal_membership.map_or_else(
|| {
comparisons::infer_binary_type_comparison(
&builder.context,
left_ty,
*op,
right_ty,
range,
)
},
Ok,
);
Comment thread
carljm marked this conversation as resolved.
Outdated
let ty = comparison.unwrap_or_else(|error| {
report_unsupported_comparison(
&builder.context,
&error,
Expand Down
Loading
Loading