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
41 changes: 41 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/typed_dict.md
Original file line number Diff line number Diff line change
Expand Up @@ -1635,6 +1635,47 @@ config["host"] = "127.0.0.1"
config["port"] = 80
```

## `update()` with `ReadOnly` items

`update()` also cannot write to `ReadOnly` items, unless the source key is bottom-typed and
therefore cannot be present:

```py
from typing_extensions import Never, NotRequired, ReadOnly, TypedDict

class ReadOnlyPerson(TypedDict):
id: ReadOnly[int]
age: int

class AgePatch(TypedDict, total=False):
age: int

class IdPatch(TypedDict, total=False):
id: int

class ImpossibleIdPatch(TypedDict, total=False):
id: NotRequired[Never]

person: ReadOnlyPerson = {"id": 1, "age": 30}
age_patch: AgePatch = {"age": 31}
id_patch: IdPatch = {"id": 2}
impossible_id_patch: ImpossibleIdPatch = {}

person.update(age_patch)

# error: [invalid-argument-type]
person.update(id_patch)

# error: [invalid-argument-type]
# error: [invalid-argument-type]
person.update({"id": 2})

# error: [invalid-argument-type]
person.update(id=2)

person.update(impossible_id_patch)
```

## Methods on `TypedDict`

```py
Expand Down
6 changes: 3 additions & 3 deletions crates/ty_python_semantic/src/types/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2009,14 +2009,14 @@ pub(super) fn synthesize_typed_dict_update_member<'db>(
instance_ty: Type<'db>,
keyword_parameters: &[Parameter<'db>],
) -> Type<'db> {
let partial_ty = if let Type::TypedDict(typed_dict) = instance_ty {
Type::TypedDict(typed_dict.to_partial(db))
let update_patch_ty = if let Type::TypedDict(typed_dict) = instance_ty {
Type::TypedDict(typed_dict.to_update_patch(db))
} else {
instance_ty
};

let value_ty = UnionBuilder::new(db)
.add(partial_ty)
.add(update_patch_ty)
.add(KnownClass::Iterable.to_specialized_instance(
db,
&[Type::heterogeneous_tuple(
Expand Down
23 changes: 14 additions & 9 deletions crates/ty_python_semantic/src/types/class/static_literal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1978,15 +1978,20 @@ impl<'db> StaticClassLiteral<'db> {
)))
}
(CodeGeneratorKind::TypedDict, "update") => {
let keyword_parameters: Vec<_> = self
.fields(db, specialization, field_policy)
.iter()
.map(|(name, field)| {
Parameter::keyword_only(name.clone())
.with_annotated_type(field.declared_ty)
.with_default_type(field.declared_ty)
})
.collect();
let keyword_parameters: Vec<_> = if let Type::TypedDict(typed_dict) = instance_ty {
typed_dict
.to_update_patch(db)
.items(db)
.iter()
.map(|(name, field)| {
Parameter::keyword_only(name.clone())
.with_annotated_type(field.declared_ty)
.with_default_type(field.declared_ty)
})
.collect()
} else {
Vec::new()
};

Some(synthesize_typed_dict_update_member(
db,
Expand Down
21 changes: 21 additions & 0 deletions crates/ty_python_semantic/src/types/typed_dict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,27 @@ impl<'db> TypedDictType<'db> {
Self::from_patch_items(db, items)
}

/// Returns a patch version of this `TypedDict` for `TypedDict.update()`.
///
/// All fields become optional, and read-only fields become bottom-typed. This preserves the
/// PEP 705 rule that `update()` must reject any source that can write a read-only key, while
/// still accepting `NotRequired[Never]` placeholders for keys that cannot be present.
pub(crate) fn to_update_patch(self, db: &'db dyn Db) -> Self {
let items: TypedDictSchema<'db> = self
.items(db)
.iter()
.map(|(name, field)| {
let mut field = field.clone().with_required(false);
if field.is_read_only() {
field.declared_ty = Type::Never;
}
Copy link
Copy Markdown
Member

@ibraheemdev ibraheemdev Mar 24, 2026

Choose a reason for hiding this comment

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

I'm a little confused wny we need to keep the field present with Never here, it seems like TypedDict subtyping should handle the Never case correctly, and if not we should fix it there instead of here (i.e., that a TypedDict A { id: NotRequired[Never] } is a subtype of B {}. Is there a reason you special-cased it here, is this a pattern that shows up in the ecosystem?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is the argument type for update. If we omit this, it'll accept these fields. Maybe I'm misunderstanding?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ah sorry, I misunderstood.

(name.clone(), field)
})
.collect();

Self::from_patch_items(db, items)
}

pub fn definition(self, db: &'db dyn Db) -> Option<Definition<'db>> {
match self {
TypedDictType::Class(defining_class) => defining_class.definition(db),
Expand Down
Loading