diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap index 22a0861fca..42dfc0e33d 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap @@ -37,6 +37,14 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md 23 | 24 | def write_to_non_literal_string_key(person: Person, str_key: str): 25 | person[str_key] = "Alice" # error: [invalid-key] +26 | from typing_extensions import ReadOnly +27 | +28 | class Employee(TypedDict): +29 | id: ReadOnly[int] +30 | name: str +31 | +32 | def write_to_readonly_key(employee: Employee): +33 | employee["id"] = 42 # error: [invalid-assignment] ``` # Diagnostics @@ -127,7 +135,22 @@ error[invalid-key]: Cannot access `Person` with a key of type `str`. Only string 24 | def write_to_non_literal_string_key(person: Person, str_key: str): 25 | person[str_key] = "Alice" # error: [invalid-key] | ^^^^^^^ +26 | from typing_extensions import ReadOnly | info: rule `invalid-key` is enabled by default ``` + +``` +error[invalid-assignment]: Can not assign to key "id" on TypedDict `Employee` + --> src/mdtest_snippet.py:33:5 + | +32 | def write_to_readonly_key(employee: Employee): +33 | employee["id"] = 42 # error: [invalid-assignment] + | -------- ^^^^ key is marked read-only + | | + | TypedDict `Employee` + | +info: rule `invalid-assignment` is enabled by default + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 9c498e501c..2bd173a8ed 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -444,8 +444,7 @@ def _(person: Person, unknown_key: Any): ## `ReadOnly` -`ReadOnly` is not supported yet, but this test makes sure that we do not emit any false positive -diagnostics: +Assignments to keys that are marked `ReadOnly` will produce an error: ```py from typing_extensions import TypedDict, ReadOnly, Required @@ -458,10 +457,26 @@ class Person(TypedDict, total=False): alice: Person = {"id": 1, "name": "Alice", "age": 30} alice["age"] = 31 # okay -# TODO: this should be an error +# error: [invalid-assignment] "Can not assign to key "id" on TypedDict `Person`: key is marked read-only" alice["id"] = 2 ``` +This also works if all fields on a `TypedDict` are `ReadOnly`, in which case we synthesize a +`__setitem__` method with a `key` type of `Never`: + +```py +class Config(TypedDict): + host: ReadOnly[str] + port: ReadOnly[int] + +config: Config = {"host": "localhost", "port": 8080} + +# error: [invalid-assignment] "Can not assign to key "host" on TypedDict `Config`: key is marked read-only" +config["host"] = "127.0.0.1" +# error: [invalid-assignment] "Can not assign to key "port" on TypedDict `Config`: key is marked read-only" +config["port"] = 80 +``` + ## Methods on `TypedDict` ```py @@ -846,6 +861,19 @@ def write_to_non_literal_string_key(person: Person, str_key: str): person[str_key] = "Alice" # error: [invalid-key] ``` +Assignment to `ReadOnly` keys: + +```py +from typing_extensions import ReadOnly + +class Employee(TypedDict): + id: ReadOnly[int] + name: str + +def write_to_readonly_key(employee: Employee): + employee["id"] = 42 # error: [invalid-assignment] +``` + ## Import aliases `TypedDict` can be imported with aliases and should work correctly: diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index 06c81c093f..ba9d7d47a3 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -589,6 +589,11 @@ impl<'db> PlaceAndQualifiers<'db> { self.qualifiers.contains(TypeQualifiers::NOT_REQUIRED) } + /// Returns `true` if the place has a `ReadOnly` type qualifier. + pub(crate) fn is_read_only(&self) -> bool { + self.qualifiers.contains(TypeQualifiers::READ_ONLY) + } + /// Returns `Some(…)` if the place is qualified with `typing.Final` without a specified type. pub(crate) fn is_bare_final(&self) -> Option { match self { diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index d4a5894053..315c959853 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1271,6 +1271,8 @@ pub(crate) enum FieldKind<'db> { TypedDict { /// Whether this field is required is_required: bool, + /// Whether this field is marked read-only + is_read_only: bool, }, } @@ -1292,7 +1294,14 @@ impl Field<'_> { FieldKind::Dataclass { init, default_ty, .. } => default_ty.is_none() && *init, - FieldKind::TypedDict { is_required } => *is_required, + FieldKind::TypedDict { is_required, .. } => *is_required, + } + } + + pub(crate) const fn is_read_only(&self) -> bool { + match &self.kind { + FieldKind::TypedDict { is_read_only, .. } => *is_read_only, + _ => false, } } } @@ -2276,8 +2285,36 @@ impl<'db> ClassLiteral<'db> { (CodeGeneratorKind::TypedDict, "__setitem__") => { let fields = self.fields(db, specialization, field_policy); - // Add (key type, value type) overloads for all TypedDict items ("fields"): - let overloads = fields.iter().map(|(name, field)| { + // Add (key type, value type) overloads for all TypedDict items ("fields") that are not read-only: + + let mut writeable_fields = fields + .iter() + .filter(|(_, field)| !field.is_read_only()) + .peekable(); + + if writeable_fields.peek().is_none() { + // If there are no writeable fields, synthesize a `__setitem__` that takes + // a `key` of type `Never` to signal that no keys are accepted. This leads + // to slightly more user-friendly error messages compared to returning an + // empty overload set. + return Some(Type::Callable(CallableType::new( + db, + CallableSignature::single(Signature::new( + Parameters::new([ + Parameter::positional_only(Some(Name::new_static("self"))) + .with_annotated_type(instance_ty), + Parameter::positional_only(Some(Name::new_static("key"))) + .with_annotated_type(Type::Never), + Parameter::positional_only(Some(Name::new_static("value"))) + .with_annotated_type(Type::any()), + ]), + Some(Type::none(db)), + )), + true, + ))); + } + + let overloads = writeable_fields.map(|(name, field)| { let key_type = Type::StringLiteral(StringLiteralType::new(db, name.as_str())); Signature::new( @@ -2680,7 +2717,11 @@ impl<'db> ClassLiteral<'db> { .expect("TypedDictParams should be available for CodeGeneratorKind::TypedDict") .contains(TypedDictParams::TOTAL) }; - FieldKind::TypedDict { is_required } + + FieldKind::TypedDict { + is_required, + is_read_only: attr.is_read_only(), + } } }; diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index 8fe8f500bc..e95535271e 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -122,6 +122,10 @@ impl TypedDictAssignmentKind { Self::Constructor => &INVALID_ARGUMENT_TYPE, } } + + const fn is_subscript(self) -> bool { + matches!(self, Self::Subscript) + } } /// 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>( return false; }; + if assignment_kind.is_subscript() && item.is_read_only() { + if let Some(builder) = + context.report_lint(assignment_kind.diagnostic_type(), key_node.into()) + { + let typed_dict_ty = Type::TypedDict(typed_dict); + let typed_dict_d = typed_dict_ty.display(db); + + let mut diagnostic = builder.into_diagnostic(format_args!( + "Can not assign to key \"{key}\" on TypedDict `{typed_dict_d}`", + )); + + diagnostic.set_primary_message(format_args!("key is marked read-only")); + + diagnostic.annotate( + context + .secondary(typed_dict_node.into()) + .message(format_args!("TypedDict `{typed_dict_d}`")), + ); + } + + return false; + } + // Key exists, check if value type is assignable to declared type if value_ty.is_assignable_to(db, item.declared_ty) { return true;