[ty] TypedDict: Add support for typing.ReadOnly (#20241)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

## 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
https://github.com/astral-sh/ty/issues/154
This commit is contained in:
David Peter 2025-09-05 00:37:42 +02:00 committed by GitHub
parent 888a22e849
commit a24a4b55ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 131 additions and 7 deletions

View file

@ -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<TypeQualifiers> {
match self {

View file

@ -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(),
}
}
};

View file

@ -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;