Skip to content

Commit a24a4b5

Browse files
authored
[ty] TypedDict: Add support for typing.ReadOnly (#20241)
## Summary Add support for `typing.ReadOnly` as a type qualifier to mark `TypedDict` fields as being read-only. If you try to mutate them, you get a new diagnostic: <img width="787" height="234" alt="image" src="https://github.com/user-attachments/assets/f62fddf9-4961-4bcd-ad1c-747043ebe5ff" /> ## Test Plan * New Markdown tests * The typing conformance changes are all correct. There are some false negatives, but those are related to the missing support for the functional form of `TypedDict`, or to overriding of fields via inheritance. Both of these topics are tracked in astral-sh/ty#154
1 parent 888a22e commit a24a4b5

File tree

5 files changed

+131
-7
lines changed

5 files changed

+131
-7
lines changed

crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
3737
23 |
3838
24 | def write_to_non_literal_string_key(person: Person, str_key: str):
3939
25 | person[str_key] = "Alice" # error: [invalid-key]
40+
26 | from typing_extensions import ReadOnly
41+
27 |
42+
28 | class Employee(TypedDict):
43+
29 | id: ReadOnly[int]
44+
30 | name: str
45+
31 |
46+
32 | def write_to_readonly_key(employee: Employee):
47+
33 | employee["id"] = 42 # error: [invalid-assignment]
4048
```
4149

4250
# Diagnostics
@@ -127,7 +135,22 @@ error[invalid-key]: Cannot access `Person` with a key of type `str`. Only string
127135
24 | def write_to_non_literal_string_key(person: Person, str_key: str):
128136
25 | person[str_key] = "Alice" # error: [invalid-key]
129137
| ^^^^^^^
138+
26 | from typing_extensions import ReadOnly
130139
|
131140
info: rule `invalid-key` is enabled by default
132141
133142
```
143+
144+
```
145+
error[invalid-assignment]: Can not assign to key "id" on TypedDict `Employee`
146+
--> src/mdtest_snippet.py:33:5
147+
|
148+
32 | def write_to_readonly_key(employee: Employee):
149+
33 | employee["id"] = 42 # error: [invalid-assignment]
150+
| -------- ^^^^ key is marked read-only
151+
| |
152+
| TypedDict `Employee`
153+
|
154+
info: rule `invalid-assignment` is enabled by default
155+
156+
```

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,7 @@ def _(person: Person, unknown_key: Any):
444444

445445
## `ReadOnly`
446446

447-
`ReadOnly` is not supported yet, but this test makes sure that we do not emit any false positive
448-
diagnostics:
447+
Assignments to keys that are marked `ReadOnly` will produce an error:
449448

450449
```py
451450
from typing_extensions import TypedDict, ReadOnly, Required
@@ -458,10 +457,26 @@ class Person(TypedDict, total=False):
458457
alice: Person = {"id": 1, "name": "Alice", "age": 30}
459458
alice["age"] = 31 # okay
460459

461-
# TODO: this should be an error
460+
# error: [invalid-assignment] "Can not assign to key "id" on TypedDict `Person`: key is marked read-only"
462461
alice["id"] = 2
463462
```
464463

464+
This also works if all fields on a `TypedDict` are `ReadOnly`, in which case we synthesize a
465+
`__setitem__` method with a `key` type of `Never`:
466+
467+
```py
468+
class Config(TypedDict):
469+
host: ReadOnly[str]
470+
port: ReadOnly[int]
471+
472+
config: Config = {"host": "localhost", "port": 8080}
473+
474+
# error: [invalid-assignment] "Can not assign to key "host" on TypedDict `Config`: key is marked read-only"
475+
config["host"] = "127.0.0.1"
476+
# error: [invalid-assignment] "Can not assign to key "port" on TypedDict `Config`: key is marked read-only"
477+
config["port"] = 80
478+
```
479+
465480
## Methods on `TypedDict`
466481

467482
```py
@@ -846,6 +861,19 @@ def write_to_non_literal_string_key(person: Person, str_key: str):
846861
person[str_key] = "Alice" # error: [invalid-key]
847862
```
848863

864+
Assignment to `ReadOnly` keys:
865+
866+
```py
867+
from typing_extensions import ReadOnly
868+
869+
class Employee(TypedDict):
870+
id: ReadOnly[int]
871+
name: str
872+
873+
def write_to_readonly_key(employee: Employee):
874+
employee["id"] = 42 # error: [invalid-assignment]
875+
```
876+
849877
## Import aliases
850878

851879
`TypedDict` can be imported with aliases and should work correctly:

crates/ty_python_semantic/src/place.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,11 @@ impl<'db> PlaceAndQualifiers<'db> {
589589
self.qualifiers.contains(TypeQualifiers::NOT_REQUIRED)
590590
}
591591

592+
/// Returns `true` if the place has a `ReadOnly` type qualifier.
593+
pub(crate) fn is_read_only(&self) -> bool {
594+
self.qualifiers.contains(TypeQualifiers::READ_ONLY)
595+
}
596+
592597
/// Returns `Some(…)` if the place is qualified with `typing.Final` without a specified type.
593598
pub(crate) fn is_bare_final(&self) -> Option<TypeQualifiers> {
594599
match self {

crates/ty_python_semantic/src/types/class.rs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,8 @@ pub(crate) enum FieldKind<'db> {
12711271
TypedDict {
12721272
/// Whether this field is required
12731273
is_required: bool,
1274+
/// Whether this field is marked read-only
1275+
is_read_only: bool,
12741276
},
12751277
}
12761278

@@ -1292,7 +1294,14 @@ impl Field<'_> {
12921294
FieldKind::Dataclass {
12931295
init, default_ty, ..
12941296
} => default_ty.is_none() && *init,
1295-
FieldKind::TypedDict { is_required } => *is_required,
1297+
FieldKind::TypedDict { is_required, .. } => *is_required,
1298+
}
1299+
}
1300+
1301+
pub(crate) const fn is_read_only(&self) -> bool {
1302+
match &self.kind {
1303+
FieldKind::TypedDict { is_read_only, .. } => *is_read_only,
1304+
_ => false,
12961305
}
12971306
}
12981307
}
@@ -2276,8 +2285,36 @@ impl<'db> ClassLiteral<'db> {
22762285
(CodeGeneratorKind::TypedDict, "__setitem__") => {
22772286
let fields = self.fields(db, specialization, field_policy);
22782287

2279-
// Add (key type, value type) overloads for all TypedDict items ("fields"):
2280-
let overloads = fields.iter().map(|(name, field)| {
2288+
// Add (key type, value type) overloads for all TypedDict items ("fields") that are not read-only:
2289+
2290+
let mut writeable_fields = fields
2291+
.iter()
2292+
.filter(|(_, field)| !field.is_read_only())
2293+
.peekable();
2294+
2295+
if writeable_fields.peek().is_none() {
2296+
// If there are no writeable fields, synthesize a `__setitem__` that takes
2297+
// a `key` of type `Never` to signal that no keys are accepted. This leads
2298+
// to slightly more user-friendly error messages compared to returning an
2299+
// empty overload set.
2300+
return Some(Type::Callable(CallableType::new(
2301+
db,
2302+
CallableSignature::single(Signature::new(
2303+
Parameters::new([
2304+
Parameter::positional_only(Some(Name::new_static("self")))
2305+
.with_annotated_type(instance_ty),
2306+
Parameter::positional_only(Some(Name::new_static("key")))
2307+
.with_annotated_type(Type::Never),
2308+
Parameter::positional_only(Some(Name::new_static("value")))
2309+
.with_annotated_type(Type::any()),
2310+
]),
2311+
Some(Type::none(db)),
2312+
)),
2313+
true,
2314+
)));
2315+
}
2316+
2317+
let overloads = writeable_fields.map(|(name, field)| {
22812318
let key_type = Type::StringLiteral(StringLiteralType::new(db, name.as_str()));
22822319

22832320
Signature::new(
@@ -2680,7 +2717,11 @@ impl<'db> ClassLiteral<'db> {
26802717
.expect("TypedDictParams should be available for CodeGeneratorKind::TypedDict")
26812718
.contains(TypedDictParams::TOTAL)
26822719
};
2683-
FieldKind::TypedDict { is_required }
2720+
2721+
FieldKind::TypedDict {
2722+
is_required,
2723+
is_read_only: attr.is_read_only(),
2724+
}
26842725
}
26852726
};
26862727

crates/ty_python_semantic/src/types/typed_dict.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ impl TypedDictAssignmentKind {
122122
Self::Constructor => &INVALID_ARGUMENT_TYPE,
123123
}
124124
}
125+
126+
const fn is_subscript(self) -> bool {
127+
matches!(self, Self::Subscript)
128+
}
125129
}
126130

127131
/// Validates assignment of a value to a specific key on a `TypedDict`.
@@ -153,6 +157,29 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>(
153157
return false;
154158
};
155159

160+
if assignment_kind.is_subscript() && item.is_read_only() {
161+
if let Some(builder) =
162+
context.report_lint(assignment_kind.diagnostic_type(), key_node.into())
163+
{
164+
let typed_dict_ty = Type::TypedDict(typed_dict);
165+
let typed_dict_d = typed_dict_ty.display(db);
166+
167+
let mut diagnostic = builder.into_diagnostic(format_args!(
168+
"Can not assign to key \"{key}\" on TypedDict `{typed_dict_d}`",
169+
));
170+
171+
diagnostic.set_primary_message(format_args!("key is marked read-only"));
172+
173+
diagnostic.annotate(
174+
context
175+
.secondary(typed_dict_node.into())
176+
.message(format_args!("TypedDict `{typed_dict_d}`")),
177+
);
178+
}
179+
180+
return false;
181+
}
182+
156183
// Key exists, check if value type is assignable to declared type
157184
if value_ty.is_assignable_to(db, item.declared_ty) {
158185
return true;

0 commit comments

Comments
 (0)