Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/ty_python_semantic/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ bitflags = { workspace = true }
char_str = { workspace = true }
compact_str = { workspace = true }
drop_bomb = { workspace = true }
get-size2 = { workspace = true, features = ["indexmap", "ordermap"] }
get-size2 = { workspace = true, features = ["indexmap", "ordermap", "thin-vec"] }
indexmap = { workspace = true }
itertools = { workspace = true }
memchr = { workspace = true }
Expand All @@ -47,6 +47,7 @@ static_assertions = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
thiserror = { workspace = true }
thin-vec = { workspace = true }
tracing = { workspace = true }

[dev-dependencies]
Expand Down
74 changes: 74 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/cycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,80 @@ class Cached:
reveal_type(Cached().metadata) # revealed: int
```

## Guarded instance attributes when the base is checked first

A guarded bound-method initializer remains valid, while another initializer still reports an
attribute that is missing from the base class.

`base.py`:

```py
class Base:
def __init__(self):
if not hasattr(self, "x"):
self.x = self.__str__
if not hasattr(self, "z"):
self.z = self.y # error: [unresolved-attribute]
```

`child.py`:

```py
from base import Base

class Child(Base):
x = Base.__str__

def z(self): ...
def y(self): ...
```

## Guarded instance attributes when the subclass is checked first

Checking the subclass first preserves the valid initializer and the missing-attribute diagnostic.

`child.py`:

```py
from base import Base

class Child(Base):
x = Base.__str__

def z(self): ...
def y(self): ...
```

`base.py`:

```py
class Base:
def __init__(self):
if not hasattr(self, "x"):
self.x = self.__str__
if not hasattr(self, "z"):
self.z = self.y # error: [unresolved-attribute]
```

## Assignments in the opposite guard branch do not initialize an attribute

Assigning an existing attribute when `hasattr` succeeds does not initialize it in the opposite
branch. That branch remains unreachable and cannot create another instance attribute.

```py
class C:
def __init__(self):
self.x = 1

def update(self):
if hasattr(self, "x"):
self.x = 2
else:
self.y = self.missing

C().y # error: [unresolved-attribute]
```

## Decorator defined on a base class with constrained typevars, accessed from a subclass with decorated generic parameters

This example was minimized from
Expand Down
143 changes: 126 additions & 17 deletions crates/ty_python_semantic/resources/mdtest/typed_dict.md
Original file line number Diff line number Diff line change
Expand Up @@ -2653,8 +2653,9 @@ def _(item: Item | str) -> None:
```

A successful membership test for an undeclared key narrows each union member to an intersection with
a synthesized `TypedDict`. Its mapping methods should retain their precise types, and copying the
narrowed union should remain efficient even when each member has a distinct optional field:
a synthesized protocol that records membership and subscript access. Its mapping methods should
retain their precise types, and copying the narrowed union should remain efficient even when each
member has a distinct optional field:

```py
from typing import NotRequired
Expand Down Expand Up @@ -2693,7 +2694,7 @@ def _(item: MembershipItem) -> None:

def _(item: MembershipA) -> None:
if "missing" in item:
reveal_type(item.copy()) # revealed: MembershipA & <TypedDict with items 'missing'>
reveal_type(item.copy()) # revealed: MembershipA & <Protocol with members '__contains__', '__getitem__'>
```

Adding a regular dictionary to the union should not make copying it slow:
Expand Down Expand Up @@ -5180,7 +5181,7 @@ def _(p: Person) -> None:
reveal_type(p.setdefault("name", "Alice")) # revealed: str

# __contains__
reveal_type("name" in p) # revealed: bool
reveal_type("name" in p) # revealed: Literal[True]

# __setitem__
p["name"] = "Alice"
Expand Down Expand Up @@ -6109,7 +6110,8 @@ the key, because "extra items" are allowed by default. For example, even though
define a `"foo"` field, it could be _assigned to_ with another `TypedDict` that does:

```py
from typing_extensions import Literal
from collections.abc import Mapping
from typing_extensions import Final, Literal, NotRequired, TypeGuard

class Foo(TypedDict):
foo: int
Expand All @@ -6120,14 +6122,14 @@ class Bar(TypedDict):
def disappointment(u: Foo | Bar, v: Literal["foo"]):
if "foo" in u:
# We don't narrow to just `Foo` here...
reveal_type(u) # revealed: Foo | (Bar & <TypedDict with items 'foo'>)
reveal_type(u) # revealed: Foo | (Bar & <Protocol with members '__contains__', '__getitem__'>)
reveal_type(u["foo"]) # revealed: object
else:
# ...(even though we *can* narrow it here)...
reveal_type(u) # revealed: Bar

if v in u:
reveal_type(u) # revealed: Foo | (Bar & <TypedDict with items 'foo'>)
reveal_type(u) # revealed: Foo | (Bar & <Protocol with members '__contains__', '__getitem__'>)
reveal_type(u["foo"]) # revealed: object
else:
reveal_type(u) # revealed: Bar
Expand All @@ -6139,16 +6141,124 @@ class FooBar(TypedDict):

static_assert(is_assignable_to(FooBar, Foo))
static_assert(is_assignable_to(FooBar, Bar))
```

A successful membership check permits subscript access even when the key is not included in the
annotated key type:

```py
def dictionary_union(u: Foo | dict[Literal["a", "b"], int]):
if "c" in u:
# TODO: This should stop erroring if we prove that the `dict` arm cannot contain `"c"`.
# error: [invalid-argument-type]
reveal_type(u["c"]) # revealed: object

def mapping_union(u: Foo | Mapping[Literal["a", "b"], int]):
if "c" in u:
reveal_type(u["c"]) # revealed: object

def mapping_membership(mapping: Mapping[Literal["a", "b"], int]):
if "c" in mapping:
reveal_type(mapping["c"]) # revealed: object
```

When a condition checks multiple keys, each successful check is retained:

```py
def combined_typed_dict_checks(u: Foo | Bar):
has_foo = "foo" in u
has_bar = "bar" in u
if has_foo and has_bar:
reveal_type(u["foo"]) # revealed: object
reveal_type(u["bar"]) # revealed: object

def combined_mapping_checks(mapping: Mapping[Literal["a", "b"], int]):
has_c = "c" in mapping
has_d = "d" in mapping
if has_c and has_d:
reveal_type(mapping["c"]) # revealed: object
reveal_type(mapping["d"]) # revealed: object

def either_key_is_present(mapping: Mapping[Literal["a"], int]):
if "c" in mapping or "d" in mapping:
if "c" not in mapping:
reveal_type(mapping["d"]) # revealed: object
```

Membership checks that occur after a `TypeGuard` still apply to the replacement type. However, a
`TypeGuard` discards membership facts from preceding conditions, along with all other previously
known type information:

```py
def guard_object(value: object) -> TypeGuard[object]:
return True

def guard_bar(value: object) -> TypeGuard[Bar]:
return True

def membership_after_typeguard(value: Foo | Literal["abc"]):
has_z = "z" in value
if guard_bar(value) and has_z:
reveal_type(value["z"]) # revealed: object
if has_z and guard_bar(value):
value["z"] # error: [invalid-key]

class OptionalKey(TypedDict):
x: NotRequired[int]

def optional_key_after_typeguard(value: OptionalKey):
has_x = "x" in value
if guard_bar(value) and has_x:
reveal_type(value["x"]) # revealed: object

class AlwaysContains(Mapping[str, int]):
def __contains__(self, key: object, /) -> Literal[True]:
return True

class SometimesContains(Mapping[str, int]): ...
class Target: ...

def guard_mapping_or_target(value: object) -> TypeGuard[AlwaysContains | Target]:
return True

def absent_key_after_typeguard(value: SometimesContains):
lacks_x = "x" not in value
if guard_mapping_or_target(value) and lacks_x:
reveal_type(value) # revealed: Target
if lacks_x and guard_mapping_or_target(value):
reveal_type(value) # revealed: AlwaysContains | Target

def mapping_membership_after_typeguard(u: Foo | Mapping[Literal["a", "b"], int]):
has_c = "c" in u
if guard_object(u) and has_c:
reveal_type(u["c"]) # revealed: object
```

Precomputed refinements, such as filtering a union by a nominal tag, preserve preceding membership
facts:

```py
class UserSettings(Mapping[Literal["user_id"], int]):
kind: Final[Literal["user"]] = "user"

class SystemSettings(Mapping[Literal["system_id"], int]):
kind: Final[Literal["system"]] = "system"

def read_timeout(settings: UserSettings | SystemSettings) -> object:
has_timeout = "timeout" in settings
is_user_settings = settings.kind == "user"

if has_timeout and is_user_settings:
return settings["timeout"]

raise KeyError("timeout")
```

For other objects, a successful membership check does not imply that the same value can be used as a
subscript:

```py
def literal_union(u: Foo | Literal["abc"]):
if "a" in u:
# revealed: (Foo & <TypedDict with items 'a'>) | (Literal["abc"] & <Protocol with members '__contains__'>)
# revealed: (Foo & <Protocol with members '__contains__', '__getitem__'>) | (Literal["abc"] & <Protocol with members '__contains__'>)
reveal_type(u)

def literal_union_key_access(obj: Foo | Literal["a"]):
Expand Down Expand Up @@ -6198,9 +6308,8 @@ def _(t: Bar, u: Foo | Intersection[Bar, Any], v: Intersection[Bar, Any], w: Lit
if "bar" not in u:
reveal_type(u) # revealed: Foo
else:
# TODO: This should simplify to `Foo | (Bar & Any)`, since `Foo` is a
# subtype of the synthesized protocol.
reveal_type(u) # revealed: (Foo & <TypedDict with items 'bar'>) | (Bar & Any)
# `Foo` is open, so it may contain an undeclared `"bar"` key.
reveal_type(u) # revealed: (Foo & <Protocol with members '__contains__', '__getitem__'>) | (Bar & Any)

if "bar" not in v:
reveal_type(v) # revealed: Never
Expand All @@ -6210,12 +6319,12 @@ def _(t: Bar, u: Foo | Intersection[Bar, Any], v: Intersection[Bar, Any], w: Lit
if w not in u:
reveal_type(u) # revealed: Foo
else:
reveal_type(u) # revealed: (Foo & <TypedDict with items 'bar'>) | (Bar & Any)
reveal_type(u) # revealed: (Foo & <Protocol with members '__contains__', '__getitem__'>) | (Bar & Any)

if "bar" not in (u2 := u):
reveal_type(u2) # revealed: Foo
else:
reveal_type(u2) # revealed: (Foo & <TypedDict with items 'bar'>) | (Bar & Any)
reveal_type(u2) # revealed: (Foo & <Protocol with members '__contains__', '__getitem__'>) | (Bar & Any)
```

With `closed=True`, the narrowing that we couldn't do above becomes possible, because a [closed]
Expand Down Expand Up @@ -6479,7 +6588,7 @@ def test_in(x: ThingWithBaz):
if "baz" not in x:
reveal_type(x) # revealed: Foo
else:
reveal_type(x) # revealed: (Foo & <TypedDict with items 'baz'>) | Baz
reveal_type(x) # revealed: (Foo & <Protocol with members '__contains__', '__getitem__'>) | Baz
```

Nested PEP 695 type aliases (an alias referring to another alias) also work:
Expand Down Expand Up @@ -6508,7 +6617,7 @@ def test_nested_in(x: OuterWithBaz):
if "baz" not in x:
reveal_type(x) # revealed: Foo
else:
reveal_type(x) # revealed: (Foo & <TypedDict with items 'baz'>) | Baz
reveal_type(x) # revealed: (Foo & <Protocol with members '__contains__', '__getitem__'>) | Baz
```

## Only annotated declarations are allowed in the class body
Expand Down
Loading
Loading