[ty] Synthesize read-only properties for all declared members on NamedTuple classes (#19899)

This commit is contained in:
Alex Waygood 2025-08-14 22:25:45 +01:00 committed by GitHub
parent 82350a398e
commit f6093452ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 74 additions and 5 deletions

View file

@ -74,8 +74,16 @@ Person(3, "Eve", 99, "extra")
# error: [invalid-argument-type] # error: [invalid-argument-type]
Person(id="3", name="Eve") Person(id="3", name="Eve")
# TODO: over-writing NamedTuple fields should be an error reveal_type(Person.id) # revealed: property
reveal_type(Person.name) # revealed: property
reveal_type(Person.age) # revealed: property
# TODO... the error is correct, but this is not the friendliest error message
# for assigning to a read-only property :-)
#
# error: [invalid-assignment] "Invalid assignment to data descriptor attribute `id` on type `Person` with custom `__set__` method"
alice.id = 42 alice.id = 42
# error: [invalid-assignment]
bob.age = None bob.age = None
``` ```
@ -151,9 +159,42 @@ from typing import NamedTuple
class User(NamedTuple): class User(NamedTuple):
id: int id: int
name: str name: str
age: int | None
nickname: str
class SuperUser(User): class SuperUser(User):
id: int # this should be an error # TODO: this should be an error because it implies that the `id` attribute on
# `SuperUser` is mutable, but the read-only `id` property from the superclass
# has not been overridden in the class body
id: int
# this is fine; overriding a read-only attribute with a mutable one
# does not conflict with the Liskov Substitution Principle
name: str = "foo"
# this is also fine
@property
def age(self) -> int:
return super().age or 42
def now_called_robert(self):
self.name = "Robert" # fine because overridden with a mutable attribute
# TODO: this should cause us to emit an error as we're assigning to a read-only property
# inherited from the `NamedTuple` superclass (requires https://github.com/astral-sh/ty/issues/159)
self.nickname = "Bob"
james = SuperUser(0, "James", 42, "Jimmy")
# fine because the property on the superclass was overridden with a mutable attribute
# on the subclass
james.name = "Robert"
# TODO: the error is correct (can't assign to the read-only property inherited from the superclass)
# but the error message could be friendlier :-)
#
# error: [invalid-assignment] "Invalid assignment to data descriptor attribute `nickname` on type `SuperUser` with custom `__set__` method"
james.nickname = "Bob"
``` ```
### Generic named tuples ### Generic named tuples
@ -164,13 +205,29 @@ python-version = "3.12"
``` ```
```py ```py
from typing import NamedTuple from typing import NamedTuple, Generic, TypeVar
class Property[T](NamedTuple): class Property[T](NamedTuple):
name: str name: str
value: T value: T
reveal_type(Property("height", 3.4)) # revealed: Property[float] reveal_type(Property("height", 3.4)) # revealed: Property[float]
reveal_type(Property.value) # revealed: property
reveal_type(Property.value.fget) # revealed: (self, /) -> Unknown
reveal_type(Property[str].value.fget) # revealed: (self, /) -> str
reveal_type(Property("height", 3.4).value) # revealed: float
T = TypeVar("T")
class LegacyProperty(NamedTuple, Generic[T]):
name: str
value: T
reveal_type(LegacyProperty("height", 42)) # revealed: LegacyProperty[int]
reveal_type(LegacyProperty.value) # revealed: property
reveal_type(LegacyProperty.value.fget) # revealed: (self, /) -> Unknown
reveal_type(LegacyProperty[str].value.fget) # revealed: (self, /) -> str
reveal_type(LegacyProperty("height", 3.4).value) # revealed: float
``` ```
## Attributes on `NamedTuple` ## Attributes on `NamedTuple`

View file

@ -24,8 +24,8 @@ use crate::types::tuple::{TupleSpec, TupleType};
use crate::types::{ use crate::types::{
ApplyTypeMappingVisitor, BareTypeAliasType, Binding, BoundSuperError, BoundSuperType, ApplyTypeMappingVisitor, BareTypeAliasType, Binding, BoundSuperError, BoundSuperType,
CallableType, DataclassParams, DeprecatedInstance, HasRelationToVisitor, KnownInstanceType, CallableType, DataclassParams, DeprecatedInstance, HasRelationToVisitor, KnownInstanceType,
NormalizedVisitor, StringLiteralType, TypeAliasType, TypeMapping, TypeRelation, NormalizedVisitor, PropertyInstanceType, StringLiteralType, TypeAliasType, TypeMapping,
TypeVarBoundOrConstraints, TypeVarInstance, TypeVarKind, declaration_type, TypeRelation, TypeVarBoundOrConstraints, TypeVarInstance, TypeVarKind, declaration_type,
infer_definition_types, todo_type, infer_definition_types, todo_type,
}; };
use crate::{ use crate::{
@ -1862,6 +1862,18 @@ impl<'db> ClassLiteral<'db> {
.with_qualifiers(TypeQualifiers::CLASS_VAR); .with_qualifiers(TypeQualifiers::CLASS_VAR);
} }
if CodeGeneratorKind::NamedTuple.matches(db, self) {
if let Some(field) = self.own_fields(db, specialization).get(name) {
let property_getter_signature = Signature::new(
Parameters::new([Parameter::positional_only(Some(Name::new_static("self")))]),
Some(field.declared_ty),
);
let property_getter = CallableType::single(db, property_getter_signature);
let property = PropertyInstanceType::new(db, Some(property_getter), None);
return Place::bound(Type::PropertyInstance(property)).into();
}
}
let body_scope = self.body_scope(db); let body_scope = self.body_scope(db);
let symbol = class_symbol(db, body_scope, name).map_type(|ty| { let symbol = class_symbol(db, body_scope, name).map_type(|ty| {
// The `__new__` and `__init__` members of a non-specialized generic class are handled // The `__new__` and `__init__` members of a non-specialized generic class are handled