diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md index f8246c883b..ebdf5073cd 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md @@ -451,7 +451,7 @@ checkers do not seem to support this either. ```py from typing_extensions import dataclass_transform, Any -def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ... @dataclass_transform(field_specifiers=(fancy_field,)) def fancy_model[T](cls: type[T]) -> type[T]: ... @@ -460,7 +460,7 @@ def fancy_model[T](cls: type[T]) -> type[T]: @fancy_model class Person: id: int = fancy_field(init=False) - name: str = fancy_field() + internal_name: str = fancy_field(alias="name") age: int | None = fancy_field(kw_only=True) reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None @@ -468,7 +468,7 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int alice = Person("Alice", age=30) reveal_type(alice.id) # revealed: int -reveal_type(alice.name) # revealed: str +reveal_type(alice.internal_name) # revealed: str reveal_type(alice.age) # revealed: int | None ``` @@ -477,7 +477,7 @@ reveal_type(alice.age) # revealed: int | None ```py from typing_extensions import dataclass_transform, Any -def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ... @dataclass_transform(field_specifiers=(fancy_field,)) class FancyMeta(type): def __new__(cls, name, bases, namespace): @@ -488,7 +488,7 @@ class FancyBase(metaclass=FancyMeta): ... class Person(FancyBase): id: int = fancy_field(init=False) - name: str = fancy_field() + internal_name: str = fancy_field(alias="name") age: int | None = fancy_field(kw_only=True) reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None @@ -496,7 +496,7 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int alice = Person("Alice", age=30) reveal_type(alice.id) # revealed: int -reveal_type(alice.name) # revealed: str +reveal_type(alice.internal_name) # revealed: str reveal_type(alice.age) # revealed: int | None ``` @@ -505,7 +505,7 @@ reveal_type(alice.age) # revealed: int | None ```py from typing_extensions import dataclass_transform, Any -def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ... +def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ... @dataclass_transform(field_specifiers=(fancy_field,)) class FancyBase: def __init_subclass__(cls): @@ -514,7 +514,7 @@ class FancyBase: class Person(FancyBase): id: int = fancy_field(init=False) - name: str = fancy_field() + internal_name: str = fancy_field(alias="name") age: int | None = fancy_field(kw_only=True) reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None @@ -522,7 +522,7 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int alice = Person("Alice", age=30) reveal_type(alice.id) # revealed: int -reveal_type(alice.name) # revealed: str +reveal_type(alice.internal_name) # revealed: str reveal_type(alice.age) # revealed: int | None ``` @@ -549,6 +549,58 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str) -> None Person(name="Alice") ``` +### Support for `alias` + +The `alias` parameter in field specifiers allows providing an alternative name for the parameter in +the synthesized `__init__` method. + +```py +from typing_extensions import dataclass_transform, Any + +def field_with_alias(*, alias: str | None = None, kw_only: bool = False) -> Any: ... +@dataclass_transform(field_specifiers=(field_with_alias,)) +def model[T](cls: type[T]) -> type[T]: + return cls + +@model +class Person: + internal_name: str = field_with_alias(alias="name") + internal_age: int = field_with_alias(alias="age", kw_only=True) +``` + +The synthesized `__init__` method uses the alias names instead of the actual attribute names: + +```py +reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int) -> None +``` + +We can construct instances using the alias names: + +```py +p = Person(name="Alice", age=30) +``` + +Passing the `name` parameter positionally also works: + +```py +p = Person("Alice", age=30) +``` + +But the attributes are still accessed by their actual names: + +```py +reveal_type(p.internal_name) # revealed: str +reveal_type(p.internal_age) # revealed: int +``` + +Trying to use the actual attribute names in the constructor results in an error: + +```py +# error: [unknown-argument] "Argument `internal_age` does not match any known parameter" +# error: [missing-argument] "No argument provided for required parameter `age`" +p = Person(name="Alice", internal_age=30) +``` + ### With overloaded field specifiers ```py diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index be545c4e59..9327a8f0ec 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -8000,6 +8000,9 @@ pub struct FieldInstance<'db> { /// Whether or not this field can only be passed as a keyword argument to `__init__`. pub kw_only: Option, + + /// This name is used to provide an alternative parameter name in the synthesized `__init__` method. + pub alias: Option>, } // The Salsa heap is tracked separately. @@ -8013,6 +8016,7 @@ impl<'db> FieldInstance<'db> { .map(|ty| ty.normalized_impl(db, visitor)), self.init(db), self.kw_only(db), + self.alias(db), ) } } diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index b4c00af378..9c4df3c22c 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -618,6 +618,9 @@ impl<'db> Bindings<'db> { let kw_only = overload .parameter_type_by_name("kw_only", true) .unwrap_or(None); + let alias = overload + .parameter_type_by_name("alias", true) + .unwrap_or(None); // `dataclasses.field` and field-specifier functions of commonly used // libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return @@ -650,6 +653,10 @@ impl<'db> Bindings<'db> { None }; + let alias = alias + .and_then(Type::as_string_literal) + .map(|literal| Box::from(literal.value(db))); + // `typeshed` pretends that `dataclasses.field()` returns the type of the // default value directly. At runtime, however, this function returns an // instance of `dataclasses.Field`. We also model it this way and return @@ -658,7 +665,7 @@ impl<'db> Bindings<'db> { // are assignable to `T` if the default type of the field is assignable // to `T`. Otherwise, we would error on `name: str = field(default="")`. overload.set_return_type(Type::KnownInstance(KnownInstanceType::Field( - FieldInstance::new(db, default_ty, init, kw_only), + FieldInstance::new(db, default_ty, init, kw_only, alias), ))); } diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index c2a2ccd823..56df9329ed 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1341,6 +1341,8 @@ pub(crate) enum FieldKind<'db> { init: bool, /// Whether or not this field can only be passed as a keyword argument to `__init__`. kw_only: Option, + /// The name for this field in the `__init__` signature, if specified. + alias: Option>, }, /// `TypedDict` field metadata TypedDict { @@ -2314,14 +2316,15 @@ impl<'db> ClassLiteral<'db> { let signature_from_fields = |mut parameters: Vec<_>, return_ty: Option>| { for (field_name, field) in self.fields(db, specialization, field_policy) { - let (init, mut default_ty, kw_only) = match field.kind { - FieldKind::NamedTuple { default_ty } => (true, default_ty, None), + let (init, mut default_ty, kw_only, alias) = match &field.kind { + FieldKind::NamedTuple { default_ty } => (true, *default_ty, None, None), FieldKind::Dataclass { init, default_ty, kw_only, + alias, .. - } => (init, default_ty, kw_only), + } => (*init, *default_ty, *kw_only, alias.as_ref()), FieldKind::TypedDict { .. } => continue, }; let mut field_ty = field.declared_ty; @@ -2387,10 +2390,13 @@ impl<'db> ClassLiteral<'db> { let is_kw_only = name == "__replace__" || kw_only.unwrap_or(has_dataclass_param(DataclassFlags::KW_ONLY)); + // Use the alias name if provided, otherwise use the field name + let parameter_name = alias.map(Name::new).unwrap_or(field_name); + let mut parameter = if is_kw_only { - Parameter::keyword_only(field_name) + Parameter::keyword_only(parameter_name) } else { - Parameter::positional_or_keyword(field_name) + Parameter::positional_or_keyword(parameter_name) } .with_annotated_type(field_ty); @@ -2925,6 +2931,7 @@ impl<'db> ClassLiteral<'db> { let mut init = true; let mut kw_only = None; + let mut alias = None; if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = default_ty { default_ty = field.default_type(db); if self @@ -2938,6 +2945,7 @@ impl<'db> ClassLiteral<'db> { } else { init = field.init(db); kw_only = field.kw_only(db); + alias = field.alias(db); } } @@ -2948,6 +2956,7 @@ impl<'db> ClassLiteral<'db> { init_only: attr.is_init_var(), init, kw_only, + alias, }, CodeGeneratorKind::TypedDict => { let is_required = if attr.is_required() {