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
6 changes: 3 additions & 3 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ regex-automata = { version = "0.4.9" }
rustc-hash = { version = "2.0.0" }
rustc-stable-hash = { version = "0.1.2" }
# When updating salsa, make sure to also update the revision in `fuzz/Cargo.toml`
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "86ca4a9d70e97dd5107e6111a09647dd10ff7535", default-features = false, features = [
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "d66fe331d546216132ace503512b94d5c68d2c50", default-features = false, features = [
"compact_str",
"macros",
"salsa_unstable",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,28 @@ static_assert(has_member(Answer, "YES"))
static_assert(has_member(Answer, "__members__"))
```

### Unions
### TypedDicts

```py
from ty_extensions import has_member, static_assert
from typing import TypedDict

class Person(TypedDict):
name: str
age: int | None

static_assert(not has_member(Person, "name"))
static_assert(not has_member(Person, "age"))

static_assert(has_member(Person, "__total__"))
static_assert(has_member(Person, "__required_keys__"))

def _(person: Person):
static_assert(not has_member(person, "name"))
static_assert(not has_member(person, "age"))

static_assert(has_member(person, "keys"))
```

For unions, `ide_support::all_members` only returns members that are available on all elements of
Comment on lines 233 to 234
Copy link
Member

Choose a reason for hiding this comment

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

It looks like you maybe deleted the ### Unions header above accidentally here

Suggested change
For unions, `ide_support::all_members` only returns members that are available on all elements of
### Unions
For unions, `ide_support::all_members` only returns members that are available on all elements of

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, yeah. I noticed that to independently and fixed it in #19758

the union.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,54 @@ reveal_type(bool(AmbiguousEnum2.YES)) # revealed: bool
reveal_type(bool(CustomLenEnum.NO)) # revealed: bool
reveal_type(bool(CustomLenEnum.YES)) # revealed: bool
```

## TypedDict

It may be feasible to infer `Literal[True]` for some `TypedDict` types, if `{}` can definitely be
excluded as a possible value. We currently do not attempt to do this.

If `{}` is the *only* possible value, we could infer `Literal[False]`. This might only be possible
if something like <https://peps.python.org/pep-0728> is accepted, a `TypedDict` has no defined
items, and `closed=True` is used.

```py
from typing_extensions import TypedDict, Literal, NotRequired

class Normal(TypedDict):
a: str
b: int

def _(n: Normal) -> None:
# Could be `Literal[True]`
reveal_type(bool(n)) # revealed: bool

class OnlyFalsyItems(TypedDict):
wrong: Literal[False]

def _(n: OnlyFalsyItems) -> None:
# Could be `Literal[True]` (it does not matter if all items are falsy)
reveal_type(bool(n)) # revealed: bool

class Empty(TypedDict):
pass

def _(e: Empty) -> None:
# This should be `bool`. `Literal[False]` would be wrong, as `Empty` can be subclassed.
reveal_type(bool(e)) # revealed: bool

class AllKeysOptional(TypedDict, total=False):
a: str
b: int

def _(a: AllKeysOptional) -> None:
# This should be `bool`. `Literal[True]` would be wrong as `{}` is a valid value.
reveal_type(bool(a)) # revealed: bool

class AllKeysNotRequired(TypedDict):
a: NotRequired[str]
b: NotRequired[int]

def _(a: AllKeysNotRequired) -> None:
# This should be `bool`. `Literal[True]` would be wrong as `{}` is a valid value.
reveal_type(bool(a)) # revealed: bool
```
55 changes: 46 additions & 9 deletions crates/ty_python_semantic/resources/mdtest/typed_dict.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,17 @@ Assignments to keys are also validated:
```py
# TODO: this should be an error
alice["name"] = None
# TODO: this should be an error
bob["name"] = None
```

Assignments to non-existing keys are disallowed:

```py
# TODO: this should be an error
alice["extra"] = True
# TODO: this should be an error
bob["extra"] = True
```

## Structural assignability
Expand Down Expand Up @@ -123,7 +127,7 @@ dangerous(alice)
reveal_type(alice["name"]) # revealed: Unknown
```

## Types of keys and values
## Methods on `TypedDict`

```py
from typing import TypedDict
Expand All @@ -133,8 +137,13 @@ class Person(TypedDict):
age: int | None

def _(p: Person) -> None:
reveal_type(p.keys()) # revealed: @Todo(Support for `TypedDict`)
reveal_type(p.values()) # revealed: @Todo(Support for `TypedDict`)
reveal_type(p.keys()) # revealed: dict_keys[str, object]
reveal_type(p.values()) # revealed: dict_values[str, object]

reveal_type(p.setdefault("name", "Alice")) # revealed: @Todo(Support for `TypedDict`)

reveal_type(p.get("name")) # revealed: @Todo(Support for `TypedDict`)
reveal_type(p.get("name", "Unknown")) # revealed: @Todo(Support for `TypedDict`)
```

## Unlike normal classes
Expand All @@ -149,11 +158,16 @@ class Person(TypedDict):
name: str
age: int | None

# TODO: this should be an error
# error: [unresolved-attribute] "Type `<class 'Person'>` has no attribute `name`"
Person.name

# TODO: this should be an error
Person(name="Alice", age=30).name
def _(P: type[Person]):
# error: [unresolved-attribute] "Type `type[Person]` has no attribute `name`"
P.name

def _(p: Person) -> None:
# error: [unresolved-attribute] "Type `Person` has no attribute `name`"
p.name
```

## Special properties
Expand All @@ -167,9 +181,29 @@ class Person(TypedDict):
name: str
age: int | None

reveal_type(Person.__total__) # revealed: @Todo(Support for `TypedDict`)
reveal_type(Person.__required_keys__) # revealed: @Todo(Support for `TypedDict`)
reveal_type(Person.__optional_keys__) # revealed: @Todo(Support for `TypedDict`)
reveal_type(Person.__total__) # revealed: bool
reveal_type(Person.__required_keys__) # revealed: frozenset[str]
reveal_type(Person.__optional_keys__) # revealed: frozenset[str]
Comment on lines +184 to +186
Copy link
Member

Choose a reason for hiding this comment

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

is it worth also adding some tests that demonstrate that these attributes cannot be accessed from inhabitants of the TypedDict type (unlike most ClassVars)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, thanks. I added a test, but it fails (added a TODO). No other type checker gets this right, so I'm not going to invest time right now.

```

These attributes can not be accessed on inhabitants:

```py
def _(person: Person) -> None:
# TODO: these should be errors
person.__total__
person.__required_keys__
person.__optional_keys__
```

Also, they can not be accessed on `type(person)`, as that would be `dict` at runtime:

```py
def _(t_person: type[Person]) -> None:
# TODO: these should be errors
t_person.__total__
t_person.__required_keys__
t_person.__optional_keys__
```

## Subclassing
Expand Down Expand Up @@ -272,6 +306,9 @@ msg = Message(id=1, content="Hello")
OtherMessage = TypedDict("OtherMessage", {"id": int, "content": str}, closed=True)

reveal_type(Message.__required_keys__) # revealed: @Todo(Support for `TypedDict`)

# TODO: this should be an error
msg.content
```

[`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html
3 changes: 2 additions & 1 deletion crates/ty_python_semantic/src/semantic_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ impl<'db> Completion<'db> {
Type::NominalInstance(_)
| Type::PropertyInstance(_)
| Type::Tuple(_)
| Type::BoundSuper(_) => CompletionKind::Struct,
| Type::BoundSuper(_)
| Type::TypedDict(_) => CompletionKind::Struct,
Type::IntLiteral(_)
| Type::BooleanLiteral(_)
| Type::TypeIs(_)
Expand Down
Loading
Loading