mirror of
				https://github.com/astral-sh/ruff.git
				synced 2025-10-31 03:55:09 +00:00 
			
		
		
		
	[ty] TypedDict: Add support for typing.ReadOnly (#20241)
	
		
			
	
		
	
	
		
	
		
			Some checks are pending
		
		
	
	
		
			
				
	
				CI / mkdocs (push) Waiting to run
				
			
		
			
				
	
				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 / formatter instabilities and black similarity (push) Blocked by required conditions
				
			
		
			
				
	
				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 / 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 / mkdocs (push) Waiting to run
				
			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 / formatter instabilities and black similarity (push) Blocked by required conditions
				
			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 / 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
	
	 David Peter
						David Peter