Skip to content

Commit 6c8101a

Browse files
[ty] Disallow read-only fields in TypedDict updates (#24128)
## Summary Closes astral-sh/ty#3098.
1 parent d72371f commit 6c8101a

4 files changed

Lines changed: 79 additions & 12 deletions

File tree

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,6 +1657,47 @@ config["host"] = "127.0.0.1"
16571657
config["port"] = 80
16581658
```
16591659

1660+
## `update()` with `ReadOnly` items
1661+
1662+
`update()` also cannot write to `ReadOnly` items, unless the source key is bottom-typed and
1663+
therefore cannot be present:
1664+
1665+
```py
1666+
from typing_extensions import Never, NotRequired, ReadOnly, TypedDict
1667+
1668+
class ReadOnlyPerson(TypedDict):
1669+
id: ReadOnly[int]
1670+
age: int
1671+
1672+
class AgePatch(TypedDict, total=False):
1673+
age: int
1674+
1675+
class IdPatch(TypedDict, total=False):
1676+
id: int
1677+
1678+
class ImpossibleIdPatch(TypedDict, total=False):
1679+
id: NotRequired[Never]
1680+
1681+
person: ReadOnlyPerson = {"id": 1, "age": 30}
1682+
age_patch: AgePatch = {"age": 31}
1683+
id_patch: IdPatch = {"id": 2}
1684+
impossible_id_patch: ImpossibleIdPatch = {}
1685+
1686+
person.update(age_patch)
1687+
1688+
# error: [invalid-argument-type]
1689+
person.update(id_patch)
1690+
1691+
# error: [invalid-argument-type]
1692+
# error: [invalid-argument-type]
1693+
person.update({"id": 2})
1694+
1695+
# error: [invalid-argument-type]
1696+
person.update(id=2)
1697+
1698+
person.update(impossible_id_patch)
1699+
```
1700+
16601701
## Methods on `TypedDict`
16611702

16621703
```py

crates/ty_python_semantic/src/types/class.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2027,14 +2027,14 @@ pub(super) fn synthesize_typed_dict_update_member<'db>(
20272027
instance_ty: Type<'db>,
20282028
keyword_parameters: &[Parameter<'db>],
20292029
) -> Type<'db> {
2030-
let partial_ty = if let Type::TypedDict(typed_dict) = instance_ty {
2031-
Type::TypedDict(typed_dict.to_partial(db))
2030+
let update_patch_ty = if let Type::TypedDict(typed_dict) = instance_ty {
2031+
Type::TypedDict(typed_dict.to_update_patch(db))
20322032
} else {
20332033
instance_ty
20342034
};
20352035

20362036
let value_ty = UnionBuilder::new(db)
2037-
.add(partial_ty)
2037+
.add(update_patch_ty)
20382038
.add(KnownClass::Iterable.to_specialized_instance(
20392039
db,
20402040
&[Type::heterogeneous_tuple(

crates/ty_python_semantic/src/types/class/static_literal.rs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,15 +1978,20 @@ impl<'db> StaticClassLiteral<'db> {
19781978
)))
19791979
}
19801980
(CodeGeneratorKind::TypedDict, "update") => {
1981-
let keyword_parameters: Vec<_> = self
1982-
.fields(db, specialization, field_policy)
1983-
.iter()
1984-
.map(|(name, field)| {
1985-
Parameter::keyword_only(name.clone())
1986-
.with_annotated_type(field.declared_ty)
1987-
.with_default_type(field.declared_ty)
1988-
})
1989-
.collect();
1981+
let keyword_parameters: Vec<_> = if let Type::TypedDict(typed_dict) = instance_ty {
1982+
typed_dict
1983+
.to_update_patch(db)
1984+
.items(db)
1985+
.iter()
1986+
.map(|(name, field)| {
1987+
Parameter::keyword_only(name.clone())
1988+
.with_annotated_type(field.declared_ty)
1989+
.with_default_type(field.declared_ty)
1990+
})
1991+
.collect()
1992+
} else {
1993+
Vec::new()
1994+
};
19901995

19911996
Some(synthesize_typed_dict_update_member(
19921997
db,

crates/ty_python_semantic/src/types/typed_dict.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,27 @@ impl<'db> TypedDictType<'db> {
151151
Self::from_patch_items(db, items)
152152
}
153153

154+
/// Returns a patch version of this `TypedDict` for `TypedDict.update()`.
155+
///
156+
/// All fields become optional, and read-only fields become bottom-typed. This preserves the
157+
/// PEP 705 rule that `update()` must reject any source that can write a read-only key, while
158+
/// still accepting `NotRequired[Never]` placeholders for keys that cannot be present.
159+
pub(crate) fn to_update_patch(self, db: &'db dyn Db) -> Self {
160+
let items: TypedDictSchema<'db> = self
161+
.items(db)
162+
.iter()
163+
.map(|(name, field)| {
164+
let mut field = field.clone().with_required(false);
165+
if field.is_read_only() {
166+
field.declared_ty = Type::Never;
167+
}
168+
(name.clone(), field)
169+
})
170+
.collect();
171+
172+
Self::from_patch_items(db, items)
173+
}
174+
154175
pub fn definition(self, db: &'db dyn Db) -> Option<Definition<'db>> {
155176
match self {
156177
TypedDictType::Class(defining_class) => defining_class.definition(db),

0 commit comments

Comments
 (0)