mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-29 13:24:57 +00:00
[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
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:
parent
888a22e849
commit
a24a4b55ee
5 changed files with 131 additions and 7 deletions
|
@ -37,6 +37,14 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
|
||||||
23 |
|
23 |
|
||||||
24 | def write_to_non_literal_string_key(person: Person, str_key: str):
|
24 | def write_to_non_literal_string_key(person: Person, str_key: str):
|
||||||
25 | person[str_key] = "Alice" # error: [invalid-key]
|
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
|
# 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):
|
24 | def write_to_non_literal_string_key(person: Person, str_key: str):
|
||||||
25 | person[str_key] = "Alice" # error: [invalid-key]
|
25 | person[str_key] = "Alice" # error: [invalid-key]
|
||||||
| ^^^^^^^
|
| ^^^^^^^
|
||||||
|
26 | from typing_extensions import ReadOnly
|
||||||
|
|
|
|
||||||
info: rule `invalid-key` is enabled by default
|
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
|
||||||
|
|
||||||
|
```
|
||||||
|
|
|
@ -444,8 +444,7 @@ def _(person: Person, unknown_key: Any):
|
||||||
|
|
||||||
## `ReadOnly`
|
## `ReadOnly`
|
||||||
|
|
||||||
`ReadOnly` is not supported yet, but this test makes sure that we do not emit any false positive
|
Assignments to keys that are marked `ReadOnly` will produce an error:
|
||||||
diagnostics:
|
|
||||||
|
|
||||||
```py
|
```py
|
||||||
from typing_extensions import TypedDict, ReadOnly, Required
|
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: Person = {"id": 1, "name": "Alice", "age": 30}
|
||||||
alice["age"] = 31 # okay
|
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
|
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`
|
## Methods on `TypedDict`
|
||||||
|
|
||||||
```py
|
```py
|
||||||
|
@ -846,6 +861,19 @@ def write_to_non_literal_string_key(person: Person, str_key: str):
|
||||||
person[str_key] = "Alice" # error: [invalid-key]
|
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
|
## Import aliases
|
||||||
|
|
||||||
`TypedDict` can be imported with aliases and should work correctly:
|
`TypedDict` can be imported with aliases and should work correctly:
|
||||||
|
|
|
@ -589,6 +589,11 @@ impl<'db> PlaceAndQualifiers<'db> {
|
||||||
self.qualifiers.contains(TypeQualifiers::NOT_REQUIRED)
|
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.
|
/// Returns `Some(…)` if the place is qualified with `typing.Final` without a specified type.
|
||||||
pub(crate) fn is_bare_final(&self) -> Option<TypeQualifiers> {
|
pub(crate) fn is_bare_final(&self) -> Option<TypeQualifiers> {
|
||||||
match self {
|
match self {
|
||||||
|
|
|
@ -1271,6 +1271,8 @@ pub(crate) enum FieldKind<'db> {
|
||||||
TypedDict {
|
TypedDict {
|
||||||
/// Whether this field is required
|
/// Whether this field is required
|
||||||
is_required: bool,
|
is_required: bool,
|
||||||
|
/// Whether this field is marked read-only
|
||||||
|
is_read_only: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1292,7 +1294,14 @@ impl Field<'_> {
|
||||||
FieldKind::Dataclass {
|
FieldKind::Dataclass {
|
||||||
init, default_ty, ..
|
init, default_ty, ..
|
||||||
} => default_ty.is_none() && *init,
|
} => 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__") => {
|
(CodeGeneratorKind::TypedDict, "__setitem__") => {
|
||||||
let fields = self.fields(db, specialization, field_policy);
|
let fields = self.fields(db, specialization, field_policy);
|
||||||
|
|
||||||
// Add (key type, value type) overloads for all TypedDict items ("fields"):
|
// Add (key type, value type) overloads for all TypedDict items ("fields") that are not read-only:
|
||||||
let overloads = fields.iter().map(|(name, field)| {
|
|
||||||
|
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()));
|
let key_type = Type::StringLiteral(StringLiteralType::new(db, name.as_str()));
|
||||||
|
|
||||||
Signature::new(
|
Signature::new(
|
||||||
|
@ -2680,7 +2717,11 @@ impl<'db> ClassLiteral<'db> {
|
||||||
.expect("TypedDictParams should be available for CodeGeneratorKind::TypedDict")
|
.expect("TypedDictParams should be available for CodeGeneratorKind::TypedDict")
|
||||||
.contains(TypedDictParams::TOTAL)
|
.contains(TypedDictParams::TOTAL)
|
||||||
};
|
};
|
||||||
FieldKind::TypedDict { is_required }
|
|
||||||
|
FieldKind::TypedDict {
|
||||||
|
is_required,
|
||||||
|
is_read_only: attr.is_read_only(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -122,6 +122,10 @@ impl TypedDictAssignmentKind {
|
||||||
Self::Constructor => &INVALID_ARGUMENT_TYPE,
|
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`.
|
/// 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;
|
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
|
// Key exists, check if value type is assignable to declared type
|
||||||
if value_ty.is_assignable_to(db, item.declared_ty) {
|
if value_ty.is_assignable_to(db, item.declared_ty) {
|
||||||
return true;
|
return true;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue