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
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def _() -> TD:

def _() -> TD:
# error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor"
# error: [invalid-return-type]
return {}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1685,8 +1685,7 @@ def int_or_str() -> int | str:
x = f([{"x": 1}], int_or_str())
reveal_type(x) # revealed: int | str

# TODO: error: [no-matching-overload] "No overload of function `f` matches arguments"
# we currently incorrectly consider `list[dict[str, int]]` a subtype of `list[T]`
# error: [no-matching-overload] "No overload of function `f` matches arguments"
f([{"y": 1}], int_or_str())
```

Expand Down
3 changes: 1 addition & 2 deletions crates/ty_python_semantic/resources/mdtest/call/union.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,6 @@ def _(flag: bool):
x = f({"x": 1})
reveal_type(x) # revealed: int

# TODO: error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[str, int]`"
# we currently consider `TypedDict` instances to be subtypes of `dict`
# error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[Unknown | str, Unknown | int]`"
f({"y": 1})
```
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,13 @@ The type context is propagated down into the comprehension:
class Person(TypedDict):
name: str

# TODO: This should not error.
# error: [invalid-assignment]
persons: list[Person] = [{"name": n} for n in ["Alice", "Bob"]]
Copy link
Member Author

Choose a reason for hiding this comment

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

@sharkdp it looks like the type context is not being propagated correctly here, and only seemed so because of dict[str, str] being assignable to Person.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, absolutely. I was aware about this, but should have added a comment. But this is why I added the other test below (where it's supposed to be an error).

Copy link
Contributor

Choose a reason for hiding this comment

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

See also this comment: #20962 (comment)

reveal_type(persons) # revealed: list[Person]

# TODO: This should be an error
# TODO: This should be an invalid-key error.
# error: [invalid-assignment]
invalid: list[Person] = [{"misspelled": n} for n in ["Alice", "Bob"]]
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,19 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
25 | person[str_key] = "Alice" # error: [invalid-key]
26 |
27 | def create_with_invalid_string_key():
28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key]
29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key]
30 | from typing_extensions import ReadOnly
31 |
32 | class Employee(TypedDict):
33 | id: ReadOnly[int]
34 | name: str
35 |
36 | def write_to_readonly_key(employee: Employee):
37 | employee["id"] = 42 # error: [invalid-assignment]
28 | # error: [invalid-key]
29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
30 |
31 | # error: [invalid-key]
32 | bob = Person(name="Bob", age=25, unknown="Bar")
33 | from typing_extensions import ReadOnly
34 |
35 | class Employee(TypedDict):
36 | id: ReadOnly[int]
37 | name: str
38 |
39 | def write_to_readonly_key(employee: Employee):
40 | employee["id"] = 42 # error: [invalid-assignment]
```

# Diagnostics
Expand Down Expand Up @@ -158,52 +161,52 @@ info: rule `invalid-key` is enabled by default

```
error[invalid-key]: Invalid key for TypedDict `Person`
--> src/mdtest_snippet.py:28:21
--> src/mdtest_snippet.py:29:21
|
27 | def create_with_invalid_string_key():
28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key]
28 | # error: [invalid-key]
29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
| -----------------------------^^^^^^^^^--------
| | |
| | Unknown key "unknown"
| TypedDict `Person`
29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key]
30 | from typing_extensions import ReadOnly
30 |
31 | # error: [invalid-key]
|
info: rule `invalid-key` is enabled by default

```

```
error[invalid-key]: Invalid key for TypedDict `Person`
--> src/mdtest_snippet.py:29:11
--> src/mdtest_snippet.py:32:11
|
27 | def create_with_invalid_string_key():
28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key]
29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key]
31 | # error: [invalid-key]
32 | bob = Person(name="Bob", age=25, unknown="Bar")
| ------ TypedDict `Person` ^^^^^^^^^^^^^ Unknown key "unknown"
30 | from typing_extensions import ReadOnly
33 | from typing_extensions import ReadOnly
|
info: rule `invalid-key` is enabled by default

```

```
error[invalid-assignment]: Cannot assign to key "id" on TypedDict `Employee`
--> src/mdtest_snippet.py:37:5
--> src/mdtest_snippet.py:40:5
|
36 | def write_to_readonly_key(employee: Employee):
37 | employee["id"] = 42 # error: [invalid-assignment]
39 | def write_to_readonly_key(employee: Employee):
40 | employee["id"] = 42 # error: [invalid-assignment]
| -------- ^^^^ key is marked read-only
| |
| TypedDict `Employee`
|
info: Item declaration
--> src/mdtest_snippet.py:33:5
--> src/mdtest_snippet.py:36:5
|
32 | class Employee(TypedDict):
33 | id: ReadOnly[int]
35 | class Employee(TypedDict):
36 | id: ReadOnly[int]
| ----------------- Read-only item declared here
34 | name: str
37 | name: str
|
info: rule `invalid-assignment` is enabled by default

Expand Down
52 changes: 40 additions & 12 deletions crates/ty_python_semantic/resources/mdtest/typed_dict.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,29 +96,29 @@ The construction of a `TypedDict` is checked for type correctness:
```py
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`"
eve1a: Person = {"name": b"Eve", "age": None}

# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`"
eve1b = Person(name=b"Eve", age=None)

# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts)
reveal_type(eve1a) # revealed: dict[Unknown | str, Unknown | bytes | None]
reveal_type(eve1a) # revealed: Person
reveal_type(eve1b) # revealed: Person

# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
eve2a: Person = {"age": 22}

# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
eve2b = Person(age=22)

# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts)
reveal_type(eve2a) # revealed: dict[Unknown | str, Unknown | int]
reveal_type(eve2a) # revealed: Person
reveal_type(eve2b) # revealed: Person

# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
eve3a: Person = {"name": "Eve", "age": 25, "extra": True}

# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
eve3b = Person(name="Eve", age=25, extra=True)

# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts)
reveal_type(eve3a) # revealed: dict[Unknown | str, Unknown | str | int]
reveal_type(eve3a) # revealed: Person
reveal_type(eve3b) # revealed: Person
```

Expand Down Expand Up @@ -238,15 +238,19 @@ All of these are missing the required `age` field:
```py
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
alice2: Person = {"name": "Alice"}

# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
Person(name="Alice")

# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
Person({"name": "Alice"})

# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
# error: [invalid-argument-type]
accepts_person({"name": "Alice"})

# TODO: this should be an error, similar to the above
# TODO: this should be an invalid-key error, similar to the above
# error: [invalid-assignment]
house.owner = {"name": "Alice"}

a_person: Person
Expand All @@ -259,19 +263,25 @@ All of these have an invalid type for the `name` field:
```py
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
alice3: Person = {"name": None, "age": 30}

# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
Person(name=None, age=30)

# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
Person({"name": None, "age": 30})

# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
# error: [invalid-argument-type]
accepts_person({"name": None, "age": 30})
# TODO: this should be an error, similar to the above

# TODO: this should be an invalid-key error
# error: [invalid-assignment]
house.owner = {"name": None, "age": 30}

a_person: Person
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
a_person = {"name": None, "age": 30}

# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
(a_person := {"name": None, "age": 30})
```
Expand All @@ -281,19 +291,25 @@ All of these have an extra field that is not defined in the `TypedDict`:
```py
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
alice4: Person = {"name": "Alice", "age": 30, "extra": True}

# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
Person(name="Alice", age=30, extra=True)

# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
Person({"name": "Alice", "age": 30, "extra": True})

# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-argument-type]
accepts_person({"name": "Alice", "age": 30, "extra": True})
# TODO: this should be an error

# TODO: this should be an invalid-key error
# error: [invalid-assignment]
house.owner = {"name": "Alice", "age": 30, "extra": True}

a_person: Person
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
a_person = {"name": "Alice", "age": 30, "extra": True}

# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
(a_person := {"name": "Alice", "age": 30, "extra": True})
```
Expand Down Expand Up @@ -490,6 +506,15 @@ dangerous(alice)
reveal_type(alice["name"]) # revealed: str
```

Likewise, `dict`s are not assignable to typed dictionaries:

```py
alice: dict[str, str] = {"name": "Alice"}

# error: [invalid-assignment] "Object of type `dict[str, str]` is not assignable to `Person`"
alice: Person = alice
```

## Key-based access

### Reading
Expand Down Expand Up @@ -977,7 +1002,7 @@ class Person(TypedDict):
name: str
age: int | None

# TODO: this should be an error
# error: [invalid-assignment] "Object of type `MyDict` is not assignable to `Person`"
x: Person = MyDict({"name": "Alice", "age": 30})
```

Expand Down Expand Up @@ -1029,8 +1054,11 @@ def write_to_non_literal_string_key(person: Person, str_key: str):
person[str_key] = "Alice" # error: [invalid-key]

def create_with_invalid_string_key():
alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key]
bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key]
# error: [invalid-key]
alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}

# error: [invalid-key]
bob = Person(name="Bob", age=25, unknown="Bar")
```

Assignment to `ReadOnly` keys:
Expand Down
5 changes: 4 additions & 1 deletion crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1975,11 +1975,14 @@ impl<'db> Type<'db> {
ConstraintSet::from(false)
}

(Type::TypedDict(_), _) | (_, Type::TypedDict(_)) => {
(Type::TypedDict(_), _) => {
// TODO: Implement assignability and subtyping for TypedDict
ConstraintSet::from(relation.is_assignability())
}

// A non-`TypedDict` cannot subtype a `TypedDict`
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess intersections (of a TypedDict and something else), typevars, and Never would be types that can subtype TypedDict, but they were all handled above...

(_, Type::TypedDict(_)) => ConstraintSet::from(false),

// Note that the definition of `Type::AlwaysFalsy` depends on the return value of `__bool__`.
// If `__bool__` always returns True or False, it can be treated as a subtype of `AlwaysTruthy` or `AlwaysFalsy`, respectively.
(left, Type::AlwaysFalsy) => ConstraintSet::from(left.bool(db).is_always_false()),
Expand Down
5 changes: 5 additions & 0 deletions crates/ty_python_semantic/src/types/call/bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3582,6 +3582,11 @@ impl<'db> BindingError<'db> {
expected_ty,
provided_ty,
} => {
// TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments
// here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have
// silenced diagnostics during overload evaluation, and rely on the assignability
// diagnostic being emitted here.

let range = Self::get_node(node, *argument_index);
let Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, range) else {
return;
Expand Down
39 changes: 35 additions & 4 deletions crates/ty_python_semantic/src/types/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2003,6 +2003,20 @@ pub(super) fn report_slice_step_size_zero(context: &InferContext, node: AnyNodeR
builder.into_diagnostic("Slice step size can not be zero");
}

// We avoid emitting invalid assignment diagnostic for literal assignments to a `TypedDict`, as
// they can only occur if we already failed to validate the dict (and emitted some diagnostic).
pub(crate) fn is_invalid_typed_dict_literal(
db: &dyn Db,
target_ty: Type,
source: AnyNodeRef<'_>,
) -> bool {
target_ty
.filter_union(db, Type::is_typed_dict)
.as_typed_dict()
.is_some()
&& matches!(source, AnyNodeRef::ExprDict(_))
}

fn report_invalid_assignment_with_message(
context: &InferContext,
node: AnyNodeRef,
Expand Down Expand Up @@ -2040,15 +2054,27 @@ pub(super) fn report_invalid_assignment<'db>(
target_ty: Type,
mut source_ty: Type<'db>,
) {
let value_expr = match definition.kind(context.db()) {
DefinitionKind::Assignment(def) => Some(def.value(context.module())),
DefinitionKind::AnnotatedAssignment(def) => def.value(context.module()),
DefinitionKind::NamedExpression(def) => Some(&*def.node(context.module()).value),
_ => None,
};

if let Some(value_expr) = value_expr
&& is_invalid_typed_dict_literal(context.db(), target_ty, value_expr.into())
{
return;
}

let settings =
DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, source_ty);

if let DefinitionKind::AnnotatedAssignment(annotated_assignment) = definition.kind(context.db())
&& let Some(value) = annotated_assignment.value(context.module())
{
if let Some(value_expr) = value_expr {
// Re-infer the RHS of the annotated assignment, ignoring the type context for more precise
// error messages.
source_ty = infer_isolated_expression(context.db(), definition.scope(context.db()), value);
source_ty =
infer_isolated_expression(context.db(), definition.scope(context.db()), value_expr);
}

report_invalid_assignment_with_message(
Expand All @@ -2070,6 +2096,11 @@ pub(super) fn report_invalid_attribute_assignment(
source_ty: Type,
attribute_name: &'_ str,
) {
// TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments
// here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have
// silenced diagnostics during attribute resolution, and rely on the assignability
// diagnostic being emitted here.

report_invalid_assignment_with_message(
context,
node,
Expand Down
Loading
Loading