From c8133104e87305c0a25f11383986e162d10cf55f Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 16 Oct 2025 13:13:45 +0200 Subject: [PATCH] [ty] Use field-specifier return type as the default type for the field (#20915) ## Summary `dataclasses.field` and field-specifier functions of commonly used libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return the default type for the field (or `Any`) instead of an actual `Field` instance, even if this is not what happens at runtime. Let's make use of this fact and assume that *all* field specifiers return the type of the default value of the field. For standard dataclasses, this leads to more or less the same outcome (see test diff for details), but this change is important for 3rd party dataclass-transformers. ## Test Plan Tested the consequences of this change on the field-specifiers branch as well. --- .../resources/mdtest/dataclasses/fields.md | 10 ++++++---- .../ty_python_semantic/src/types/call/bind.rs | 17 ++++++++++------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md index d1ac6dc0be..7b6a4369cc 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md @@ -11,7 +11,7 @@ class Member: role: str = field(default="user") tag: str | None = field(default=None, init=False) -# revealed: (self: Member, name: str, role: str = Literal["user"]) -> None +# revealed: (self: Member, name: str, role: str = str) -> None reveal_type(Member.__init__) alice = Member(name="Alice", role="admin") @@ -37,7 +37,7 @@ class Data: content: list[int] = field(default_factory=list) timestamp: datetime = field(default_factory=datetime.now, init=False) -# revealed: (self: Data, content: list[int] = list[Unknown]) -> None +# revealed: (self: Data, content: list[int] = list[int]) -> None reveal_type(Data.__init__) data = Data([1, 2, 3]) @@ -63,7 +63,8 @@ class Person: age: int | None = field(default=None, kw_only=True) role: str = field(default="user", kw_only=True) -# revealed: (self: Person, name: str, *, age: int | None = None, role: str = Literal["user"]) -> None +# TODO: this would ideally show a default value of `None` for `age` +# revealed: (self: Person, name: str, *, age: int | None = int | None, role: str = str) -> None reveal_type(Person.__init__) alice = Person(role="admin", name="Alice") @@ -82,7 +83,8 @@ def get_default() -> str: reveal_type(field(default=1)) # revealed: dataclasses.Field[Literal[1]] reveal_type(field(default=None)) # revealed: dataclasses.Field[None] -reveal_type(field(default_factory=get_default)) # revealed: dataclasses.Field[str] +# TODO: this could ideally be `dataclasses.Field[str]` with a better generics solver +reveal_type(field(default_factory=get_default)) # revealed: dataclasses.Field[Unknown] ``` ## dataclass_transform field_specifiers diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index abaa77cb49..bcac0c636c 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -977,13 +977,16 @@ impl<'db> Bindings<'db> { let kw_only = overload.parameter_type_by_name("kw_only").unwrap_or(None); - let default_ty = match (default, default_factory) { - (Some(default_ty), _) => Some(default_ty), - (_, Some(default_factory_ty)) => default_factory_ty - .try_call(db, &CallArguments::none()) - .ok() - .map(|binding| binding.return_type(db)), - _ => None, + // `dataclasses.field` and field-specifier functions of commonly used + // libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return + // the default type for the field (or `Any`) instead of an actual `Field` + // instance, even if this is not what happens at runtime (see also below). + // We still make use of this fact and pretend that all field specifiers + // return the type of the default value: + let default_ty = if default.is_some() || default_factory.is_some() { + Some(overload.return_ty) + } else { + None }; let init = init