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
148 changes: 74 additions & 74 deletions crates/ty/docs/rules.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: typed_dict.md - `TypedDict` - Redundant cast warnings
mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
---

# Python source files

## mdtest_snippet.py

```
1 | from typing import TypedDict, cast
2 |
3 | class Foo2(TypedDict):
4 | x: int
5 |
6 | class Bar2(TypedDict):
7 | x: int
8 |
9 | foo: Foo2 = {"x": 1}
10 | _ = cast(Foo2, foo) # error: [redundant-cast]
11 | _ = cast(Bar2, foo) # error: [redundant-cast]
```

# Diagnostics

```
warning[redundant-cast]: Value is already of type `Foo2`
--> src/mdtest_snippet.py:10:5
|
9 | foo: Foo2 = {"x": 1}
10 | _ = cast(Foo2, foo) # error: [redundant-cast]
| ^^^^^^^^^^^^^^^
11 | _ = cast(Bar2, foo) # error: [redundant-cast]
|
info: rule `redundant-cast` is enabled by default

```

```
warning[redundant-cast]: Value is already of type `Bar2`
--> src/mdtest_snippet.py:11:5
|
9 | foo: Foo2 = {"x": 1}
10 | _ = cast(Foo2, foo) # error: [redundant-cast]
11 | _ = cast(Bar2, foo) # error: [redundant-cast]
| ^^^^^^^^^^^^^^^
|
info: `Bar2` is equivalent to `Foo2`
info: rule `redundant-cast` is enabled by default

```
166 changes: 166 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/typed_dict.md
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,172 @@ def _(o1: Outer1, o2: Outer2, o3: Outer3, o4: Outer4):
static_assert(is_subtype_of(Outer4, Outer4))
```

## Structural equivalence

Two `TypedDict`s with equivalent fields are equivalent types. This includes fields with gradual
types:

```py
from typing_extensions import Any, TypedDict, ReadOnly, assert_type
from ty_extensions import is_assignable_to, is_equivalent_to, static_assert

class Foo(TypedDict):
x: int
y: Any

# exactly the same fields
class Bar(TypedDict):
x: int
y: Any

# the same fields but in a different order
class Baz(TypedDict):
y: Any
x: int

static_assert(is_assignable_to(Foo, Bar))
static_assert(is_equivalent_to(Foo, Bar))
static_assert(is_assignable_to(Foo, Baz))
static_assert(is_equivalent_to(Foo, Baz))

foo: Foo = {"x": 1, "y": "hello"}
assert_type(foo, Foo)
assert_type(foo, Bar)
assert_type(foo, Baz)
```

Equivalent `TypedDict`s within unions can also produce equivalent unions, which currently relies on
"normalization" machinery:

```py
def f(var: Foo | int):
assert_type(var, Foo | int)
assert_type(var, Bar | int)
assert_type(var, Baz | int)
# TODO: Union simplification compares `TypedDict`s by name/identity to avoid cycles. This assert
# should also pass once that's fixed.
assert_type(var, Foo | Bar | Baz | int) # error: [type-assertion-failure]
```

Here are several cases that are not equivalent. In particular, assignability does not imply
equivalence:

```py
class FewerFields(TypedDict):
x: int

static_assert(is_assignable_to(Foo, FewerFields))
static_assert(not is_equivalent_to(Foo, FewerFields))

class DifferentMutability(TypedDict):
x: int
y: ReadOnly[Any]

static_assert(is_assignable_to(Foo, DifferentMutability))
static_assert(not is_equivalent_to(Foo, DifferentMutability))

class MoreFields(TypedDict):
x: int
y: Any
z: str

static_assert(not is_assignable_to(Foo, MoreFields))
static_assert(not is_equivalent_to(Foo, MoreFields))

class DifferentFieldStaticType(TypedDict):
x: str
y: Any

static_assert(not is_assignable_to(Foo, DifferentFieldStaticType))
static_assert(not is_equivalent_to(Foo, DifferentFieldStaticType))

class DifferentFieldGradualType(TypedDict):
x: int
y: Any | str

static_assert(is_assignable_to(Foo, DifferentFieldGradualType))
static_assert(not is_equivalent_to(Foo, DifferentFieldGradualType))
```

## Structural equivalence understands the interaction between `Required`/`NotRequired` and `total`

```py
from ty_extensions import static_assert, is_equivalent_to
from typing_extensions import TypedDict, Required, NotRequired

class Foo1(TypedDict, total=False):
x: int
y: str

class Foo2(TypedDict):
y: NotRequired[str]
x: NotRequired[int]

static_assert(is_equivalent_to(Foo1, Foo2))
static_assert(is_equivalent_to(Foo1 | int, int | Foo2))

class Bar1(TypedDict, total=False):
x: int
y: Required[str]

class Bar2(TypedDict):
y: str
x: NotRequired[int]

static_assert(is_equivalent_to(Bar1, Bar2))
static_assert(is_equivalent_to(Bar1 | int, int | Bar2))
```

## Assignability and equivalence work with recursive `TypedDict`s

```py
from typing_extensions import TypedDict
from ty_extensions import static_assert, is_assignable_to, is_equivalent_to

class Node1(TypedDict):
value: int
next: "Node1" | None

class Node2(TypedDict):
value: int
next: "Node2" | None

static_assert(is_assignable_to(Node1, Node2))
static_assert(is_equivalent_to(Node1, Node2))

class Person1(TypedDict):
name: str
friends: list["Person1"]

class Person2(TypedDict):
name: str
friends: list["Person2"]

static_assert(is_assignable_to(Person1, Person2))
static_assert(is_equivalent_to(Person1, Person2))
```

## Redundant cast warnings

<!-- snapshot-diagnostics -->

Casting between equivalent types produces a redundant cast warning. When the types have different
names, the warning makes that clear:

```py
from typing import TypedDict, cast

class Foo2(TypedDict):
x: int

class Bar2(TypedDict):
x: int

foo: Foo2 = {"x": 1}
_ = cast(Foo2, foo) # error: [redundant-cast]
_ = cast(Bar2, foo) # error: [redundant-cast]
```

## Key-based access

### Reading
Expand Down
22 changes: 16 additions & 6 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,7 @@ impl<'db> Type<'db> {
/// - Strips the types of default values from parameters in `Callable` types: only whether a parameter
/// *has* or *does not have* a default value is relevant to whether two `Callable` types are equivalent.
/// - Converts class-based protocols into synthesized protocols
/// - Converts class-based typeddicts into synthesized typeddicts
#[must_use]
pub(crate) fn normalized(self, db: &'db dyn Db) -> Self {
self.normalized_impl(db, &NormalizedVisitor::default())
Expand Down Expand Up @@ -1523,10 +1524,9 @@ impl<'db> Type<'db> {
// Always normalize single-member enums to their class instance (`Literal[Single.VALUE]` => `Single`)
enum_literal.enum_class_instance(db)
}
Type::TypedDict(_) => {
// TODO: Normalize TypedDicts
self
}
Type::TypedDict(typed_dict) => visitor.visit(self, || {
Type::TypedDict(typed_dict.normalized_impl(db, visitor))
}),
Type::TypeAlias(alias) => alias.value_type(db).normalized_impl(db, visitor),
Type::NewTypeInstance(newtype) => {
visitor.visit(self, || {
Expand Down Expand Up @@ -3047,6 +3047,10 @@ impl<'db> Type<'db> {
left.is_equivalent_to_impl(db, right, inferable, visitor)
}

(Type::TypedDict(left), Type::TypedDict(right)) => visitor.visit((self, other), || {
left.is_equivalent_to_impl(db, right, inferable, visitor)
}),

_ => ConstraintSet::from(false),
}
}
Expand Down Expand Up @@ -7550,7 +7554,13 @@ impl<'db> Type<'db> {
Type::ProtocolInstance(protocol) => protocol.to_meta_type(db),
// `TypedDict` instances are instances of `dict` at runtime, but its important that we
// understand a more specific meta type in order to correctly handle `__getitem__`.
Type::TypedDict(typed_dict) => SubclassOfType::from(db, typed_dict.defining_class()),
Type::TypedDict(typed_dict) => match typed_dict {
TypedDictType::Class(class) => SubclassOfType::from(db, class),
TypedDictType::Synthesized(_) => SubclassOfType::from(
db,
todo_type!("TypedDict synthesized meta-type").expect_dynamic(),
),
},
Type::TypeAlias(alias) => alias.value_type(db).to_meta_type(db),
Type::NewTypeInstance(newtype) => Type::from(newtype.base_class_type(db)),
}
Expand Down Expand Up @@ -8259,7 +8269,7 @@ impl<'db> Type<'db> {
},

Self::TypedDict(typed_dict) => {
Some(TypeDefinition::Class(typed_dict.defining_class().definition(db)))
typed_dict.definition(db).map(TypeDefinition::Class)
}

Self::Union(_) | Self::Intersection(_) => None,
Expand Down
7 changes: 3 additions & 4 deletions crates/ty_python_semantic/src/types/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ use crate::semantic_index::place::{PlaceTable, ScopedPlaceId};
use crate::semantic_index::{global_scope, place_table, use_def_map};
use crate::suppression::FileSuppressionId;
use crate::types::call::CallError;
use crate::types::class::{
CodeGeneratorKind, DisjointBase, DisjointBaseKind, Field, MethodDecorator,
};
use crate::types::class::{CodeGeneratorKind, DisjointBase, DisjointBaseKind, MethodDecorator};
use crate::types::function::{FunctionDecorators, FunctionType, KnownFunction, OverloadLiteral};
use crate::types::infer::UnsupportedComparisonError;
use crate::types::overrides::MethodKind;
Expand All @@ -26,6 +24,7 @@ use crate::types::string_annotation::{
RAW_STRING_TYPE_ANNOTATION,
};
use crate::types::tuple::TupleSpec;
use crate::types::typed_dict::TypedDictSchema;
use crate::types::{
BoundTypeVarInstance, ClassType, DynamicType, LintDiagnosticGuard, Protocol,
ProtocolInstanceType, SpecialFormType, SubclassOfInner, Type, TypeContext, binding_type,
Expand Down Expand Up @@ -3471,7 +3470,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
typed_dict_ty: Type<'db>,
full_object_ty: Option<Type<'db>>,
key_ty: Type<'db>,
items: &FxIndexMap<Name, Field<'db>>,
items: &TypedDictSchema<'db>,
) {
let db = context.db();
if let Some(builder) = context.report_lint(&INVALID_KEY, key_node) {
Expand Down
Loading
Loading