From 1af9a20930146705370ee3e71c87414a05d104d2 Mon Sep 17 00:00:00 2001 From: soaring0616 <30642533+soaring0616@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:42:24 +0800 Subject: [PATCH 1/2] Widen `__init__` for fields with a custom schema Pyrefly incorrectly reported an error when constructing a model with a custom schema field like `CustomModel(dtype=">i4")`, although the call is valid at runtime. The earlier attempt (#3798) widened the stored `converter_param`, which is also used for attribute assignment, and its check also matched metadata inferred as `Any`, so errors like `Model(y="1")` were missed. Add an `init_param_any` flag to `DataclassFieldKeywords`. It is set when an `Annotated` metadata item defines `__get_pydantic_core_schema__`, and only `get_dataclass_init` reads it to widen the synthesized `__init__` parameter. Metadata inferred as `Any` is not treated as a custom schema, so such fields are still checked. The widening applies in both lax and strict mode. Fixes #2586 --- crates/pyrefly_types/src/keywords.rs | 8 ++ pyrefly/lib/alt/class/class_field.rs | 17 ++-- pyrefly/lib/alt/class/dataclass.rs | 5 +- pyrefly/lib/alt/class/pydantic.rs | 19 +++++ pyrefly/lib/binding/pydantic.rs | 1 + pyrefly/lib/test/pydantic/field.rs | 123 +++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 8 deletions(-) diff --git a/crates/pyrefly_types/src/keywords.rs b/crates/pyrefly_types/src/keywords.rs index e954589f84..39ca8c8a46 100644 --- a/crates/pyrefly_types/src/keywords.rs +++ b/crates/pyrefly_types/src/keywords.rs @@ -165,6 +165,13 @@ pub struct DataclassFieldKeywords { /// Per-field attrs `on_setattr`, which overrides the class-level default. `None` = not set, /// `Some(true)` = `setters.frozen` (read-only), `Some(false)` = some other hook (writable). pub attrs_setattr_frozen: Option, + /// Pydantic only: when true, the synthesized `__init__` parameter for this + /// field is `Any`. Set for `Annotated` fields whose metadata defines + /// `__get_pydantic_core_schema__`, because that hook replaces the field's + /// validation. This is deliberately separate from `converter_param`, which + /// attribute assignment also reads: the declared type must still be + /// enforced when assigning to the attribute. + pub init_param_any: bool, } impl DataclassFieldKeywords { @@ -196,6 +203,7 @@ impl DataclassFieldKeywords { strict: None, frozen: None, attrs_setattr_frozen: None, + init_param_any: false, } } diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 80b5d173f3..36899872ad 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1784,11 +1784,12 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { .and_then(|annot| { self.extract_pydantic_field_from_annotation(annot, name, metadata) }) - .and_then(|flags| flags.strict) - .map(|strict| { - let mut flags = DataclassFieldKeywords::new(); - flags.strict = Some(strict); - flags + .filter(|flags| flags.strict.is_some() || flags.init_param_any) + .map(|flags| { + let mut kept = DataclassFieldKeywords::new(); + kept.strict = flags.strict; + kept.init_param_any = flags.init_param_any; + kept }); if metadata.is_protocol() && direct_annotation.is_none() @@ -1846,9 +1847,11 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { ); if let Some(f) = &mut flags && let Some(annotation_flags) = annotation_flags.as_ref() - && f.strict.is_none() { - f.strict = annotation_flags.strict; + if f.strict.is_none() { + f.strict = annotation_flags.strict; + } + f.init_param_any |= annotation_flags.init_param_any; } if flags.is_some() { // A field specifier with no type annotation is a definition-time error, diff --git a/pyrefly/lib/alt/class/dataclass.rs b/pyrefly/lib/alt/class/dataclass.rs index ad3dc27aba..27d4bf61db 100644 --- a/pyrefly/lib/alt/class/dataclass.rs +++ b/pyrefly/lib/alt/class/dataclass.rs @@ -926,6 +926,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { frozen, converter_param, attrs_setattr_frozen, + init_param_any: false, } } @@ -1346,9 +1347,11 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } } - // If this field has a `@field_validator(..., mode='before'|'plain')`, the init + // If this field has a `@field_validator(..., mode='before'|'plain')`, or its + // `Annotated` metadata defines `__get_pydantic_core_schema__`, the init // parameter accepts `Any` because the validator transforms arbitrary input. let converter_param = if dataclass.pydantic_before_validator_fields.contains(&name) + || field_flags.init_param_any { Some(self.heap.mk_any_explicit()) } else { diff --git a/pyrefly/lib/alt/class/pydantic.rs b/pyrefly/lib/alt/class/pydantic.rs index 47991a1627..543be542b4 100644 --- a/pyrefly/lib/alt/class/pydantic.rs +++ b/pyrefly/lib/alt/class/pydantic.rs @@ -49,6 +49,7 @@ use crate::binding::binding::KeyAnnotation; use crate::binding::pydantic::EXTRA; use crate::binding::pydantic::FROZEN; use crate::binding::pydantic::FROZEN_DEFAULT; +use crate::binding::pydantic::GET_PYDANTIC_CORE_SCHEMA; use crate::binding::pydantic::POPULATE_BY_NAME; use crate::binding::pydantic::PydanticConfigDict; use crate::binding::pydantic::ROOT; @@ -714,6 +715,17 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { TypeFormContext::ClassVarAnnotation, &self.error_swallower(), ); + // A metadata object that defines `__get_pydantic_core_schema__` replaces the + // field's validation, so the synthesized `__init__` must not constrain the + // argument to the declared type. Metadata inferred as `Any` (e.g. `Field(gt=0)`) + // is excluded: `has_static_attr` is true for `Any`. + let ignore_errors = self.error_swallower(); + let has_custom_schema = metadata_items.iter().any(|metadata_item| { + let metadata_ty = self.expr_infer(metadata_item, &ignore_errors); + !metadata_ty.is_any() + && self.has_static_attr(&metadata_ty, &GET_PYDANTIC_CORE_SCHEMA) + }); + // Look through metadata items and find a Field(...) call, then extract its keywords for metadata_item in &metadata_items { if let Expr::Call(call) = metadata_item @@ -735,6 +747,13 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { .get_or_insert_with(DataclassFieldKeywords::new) .strict = Some(strict); } + + if has_custom_schema { + keywords + .get_or_insert_with(DataclassFieldKeywords::new) + .init_param_any = true; + } + return keywords; } None diff --git a/pyrefly/lib/binding/pydantic.rs b/pyrefly/lib/binding/pydantic.rs index 4ab1e0362f..f434c481c7 100644 --- a/pyrefly/lib/binding/pydantic.rs +++ b/pyrefly/lib/binding/pydantic.rs @@ -43,6 +43,7 @@ pub const STRICT_DEFAULT: bool = false; pub const FROZEN: Name = Name::new_static("frozen"); pub const FROZEN_DEFAULT: bool = false; pub const EXTRA: Name = Name::new_static("extra"); +pub const GET_PYDANTIC_CORE_SCHEMA: Name = Name::new_static("__get_pydantic_core_schema__"); pub const FIELD_VALIDATOR: Name = Name::new_static("field_validator"); pub const ALIAS_GENERATOR: Name = Name::new_static("alias_generator"); diff --git a/pyrefly/lib/test/pydantic/field.rs b/pyrefly/lib/test/pydantic/field.rs index 4bd4787856..c57caee801 100644 --- a/pyrefly/lib/test/pydantic/field.rs +++ b/pyrefly/lib/test/pydantic/field.rs @@ -432,6 +432,129 @@ a.print_model_fields() "#, ); +// `Annotated` metadata with a `__get_pydantic_core_schema__` hook replaces the field validation, +// so the `__init__` parameter accepts `Any`. +// Attribute assignment still checks against the declared type. + +pydantic_testcase!( + test_annotated_custom_schema_widens_init_param, + r#" +from typing import Annotated, Any, reveal_type +from pydantic import BaseModel, ConfigDict + +class DType: ... + +class DTypeAdapter: + def __get_pydantic_core_schema__( + self, + _source_type: Any, + _handler: Any, + )-> Any: ... + +class Model(BaseModel): + model_config = ConfigDict(strict=True) + dtype: Annotated[DType, DTypeAdapter()] + other: DType + +reveal_type(Model.__init__) # E: revealed type: (self: Model, *, dtype: Any, other: DType, **Unknown) -> None +m = Model(dtype=">i4", other=DType()) +Model(dtype=DType(), other=">i4") # E: Argument `Literal['>i4']` is not assignable to parameter `other` +m.dtype = ">i4" # E: `Literal['>i4']` is not assignable to attribute `dtype` with type `DType` + + "#, +); + +pydantic_testcase!( + test_annotated_custom_schema_with_default, + r#" +from typing import Annotated, Any, reveal_type +from pydantic import BaseModel, ConfigDict, Field + +class DType: ... + +class DTypeAdapter: + def __get_pydantic_core_schema__( + self, + _source_type: Any, + _handler: Any, + )-> Any: ... + +adapter = DTypeAdapter() + +class Model(BaseModel): + model_config = ConfigDict(strict=True) + x: Annotated[DType, adapter] = DType() + y: Annotated[DType, DTypeAdapter()] = Field(default=DType()) + +reveal_type(Model.__init__) # E: revealed type: (self: Model, *, x: Any = ..., y: Any = ..., **Unknown) -> None +Model(x=">i4", y=">i4") + "#, +); + +pydantic_testcase!( + test_annotated_custom_schema_inherited, + r#" +from typing import Annotated, Any +from pydantic import BaseModel, ConfigDict + +class DType: ... + +class DTypeAdapter: + def __get_pydantic_core_schema__(self, _source_type: Any, _handler: Any) -> Any: ... + +class Parent(BaseModel): + model_config = ConfigDict(strict=True) + dtype: Annotated[DType, DTypeAdapter()] + +class Child(Parent): + pass + +Child(dtype=">i4") + "#, +); + +// Metadata of type `Any` has every attribute, so it must not count as a schema hook. +// Metadata without the hook, such as `Field(...)`, leaves the parameter type alone. +pydantic_testcase!( + test_annotated_metadata_without_custom_schema, + r#" +from typing import Annotated, Any +from pydantic import BaseModel, ConfigDict, Field + +mystery: Any = ... + +class Model(BaseModel): + model_config = ConfigDict(strict=True) + x: Annotated[int, mystery] + y: Annotated[int, Field(gt=0)] + +Model(x=0, y=1) +Model(x="0", y=1) # E: `Literal['0']` is not assignable to parameter `x` +Model(x=0, y="1") # E: `Literal['1']` is not assignable to parameter `y` + + "#, +); + +pydantic_testcase!( + test_annotated_custom_schema_widens_lax_conversion_table_type, + r#" +from typing import Annotated, Any, reveal_type +from pydantic import BaseModel + +class Adapter: + @classmethod + def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> Any: ... + +class Model(BaseModel): + n: Annotated[int, Adapter()] + m: int + +reveal_type(Model.__init__) # E: revealed type: (self: Model, *, n: Any, m: LaxInt, **Unknown) -> None +Model(n=None, m=1) +Model(n=1, m=None) # E: Argument `None` is not assignable to parameter `m` + "#, +); + pydantic_testcase!( test_field_validator, r#" From 1f07f2105646e1df02f6dc9f5b80087cebcbddf2 Mon Sep 17 00:00:00 2001 From: soaring0616 <30642533+soaring0616@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:39:15 +0800 Subject: [PATCH 2/2] Rename custom schema flag and skip Pydantic-defined hooks - Rename `init_param_any` to `pydantic_has_custom_schema`, so that the name describes a fact about the field rather than how the flag is used. - Only a `__get_pydantic_core_schema__` hook defined outside Pydantic counts as a custom schema. Hooks defined by Pydantic are skipped because they generally build on the declared type's own validation. `BeforeValidator`, `PlainValidator` and `WrapValidator` are the exceptions, since they receive the raw input. - Add a test showing that `UuidVersion` does not widen the `__init__` parameter. --- crates/pyrefly_types/src/keywords.rs | 14 +++++------- pyrefly/lib/alt/class/class_field.rs | 6 ++--- pyrefly/lib/alt/class/dataclass.rs | 12 ++++++---- pyrefly/lib/alt/class/pydantic.rs | 34 +++++++++++++++++++++------- pyrefly/lib/test/pydantic/field.rs | 9 ++++++++ 5 files changed, 52 insertions(+), 23 deletions(-) diff --git a/crates/pyrefly_types/src/keywords.rs b/crates/pyrefly_types/src/keywords.rs index 39ca8c8a46..256eea3faa 100644 --- a/crates/pyrefly_types/src/keywords.rs +++ b/crates/pyrefly_types/src/keywords.rs @@ -165,13 +165,11 @@ pub struct DataclassFieldKeywords { /// Per-field attrs `on_setattr`, which overrides the class-level default. `None` = not set, /// `Some(true)` = `setters.frozen` (read-only), `Some(false)` = some other hook (writable). pub attrs_setattr_frozen: Option, - /// Pydantic only: when true, the synthesized `__init__` parameter for this - /// field is `Any`. Set for `Annotated` fields whose metadata defines - /// `__get_pydantic_core_schema__`, because that hook replaces the field's - /// validation. This is deliberately separate from `converter_param`, which - /// attribute assignment also reads: the declared type must still be - /// enforced when assigning to the attribute. - pub init_param_any: bool, + /// Pydantic only: whether the field's `Annotated` metadata has a custom schema, + /// i.e. a `__get_pydantic_core_schema__` hook that is defined outside Pydantic + /// (or by Pydantic's `BeforeValidator`, `PlainValidator` or `WrapValidator`). Such a field + /// may accept input that is not of its declared type. + pub pydantic_has_custom_schema: bool, } impl DataclassFieldKeywords { @@ -203,7 +201,7 @@ impl DataclassFieldKeywords { strict: None, frozen: None, attrs_setattr_frozen: None, - init_param_any: false, + pydantic_has_custom_schema: false, } } diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 36899872ad..34b503acf1 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1784,11 +1784,11 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { .and_then(|annot| { self.extract_pydantic_field_from_annotation(annot, name, metadata) }) - .filter(|flags| flags.strict.is_some() || flags.init_param_any) + .filter(|flags| flags.strict.is_some() || flags.pydantic_has_custom_schema) .map(|flags| { let mut kept = DataclassFieldKeywords::new(); kept.strict = flags.strict; - kept.init_param_any = flags.init_param_any; + kept.pydantic_has_custom_schema = flags.pydantic_has_custom_schema; kept }); if metadata.is_protocol() @@ -1851,7 +1851,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { if f.strict.is_none() { f.strict = annotation_flags.strict; } - f.init_param_any |= annotation_flags.init_param_any; + f.pydantic_has_custom_schema |= annotation_flags.pydantic_has_custom_schema; } if flags.is_some() { // A field specifier with no type annotation is a definition-time error, diff --git a/pyrefly/lib/alt/class/dataclass.rs b/pyrefly/lib/alt/class/dataclass.rs index 27d4bf61db..a0c5e9632e 100644 --- a/pyrefly/lib/alt/class/dataclass.rs +++ b/pyrefly/lib/alt/class/dataclass.rs @@ -926,7 +926,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { frozen, converter_param, attrs_setattr_frozen, - init_param_any: false, + pydantic_has_custom_schema: false, } } @@ -1347,11 +1347,15 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } } - // If this field has a `@field_validator(..., mode='before'|'plain')`, or its - // `Annotated` metadata defines `__get_pydantic_core_schema__`, the init + // If this field has a `@field_validator(..., mode='before'|'plain')`, the init // parameter accepts `Any` because the validator transforms arbitrary input. let converter_param = if dataclass.pydantic_before_validator_fields.contains(&name) - || field_flags.init_param_any + // A custom schema may accept input that is not of the declared + // type, so only the `__init__` parameter is widened to `Any`. The + // stored `converter_param` is left alone: attribute assignment and + // `__replace__` also read it, and assignment must still enforce + // the declared type. + || field_flags.pydantic_has_custom_schema { Some(self.heap.mk_any_explicit()) } else { diff --git a/pyrefly/lib/alt/class/pydantic.rs b/pyrefly/lib/alt/class/pydantic.rs index 543be542b4..72969d7217 100644 --- a/pyrefly/lib/alt/class/pydantic.rs +++ b/pyrefly/lib/alt/class/pydantic.rs @@ -715,15 +715,33 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { TypeFormContext::ClassVarAnnotation, &self.error_swallower(), ); - // A metadata object that defines `__get_pydantic_core_schema__` replaces the - // field's validation, so the synthesized `__init__` must not constrain the - // argument to the declared type. Metadata inferred as `Any` (e.g. `Field(gt=0)`) - // is excluded: `has_static_attr` is true for `Any`. + // A metadata item whose `__get_pydantic_core_schema__` hook is defined outside + // Pydantic counts as a custom schema. + // We cannot know what such a hook does, so the field may accept values of any type. + // + // Hooks defined by Pydantic are skipped since they generally build on the declared + // type's own validation (for example, `UuidVersion` adds a constraint to it), + // so the accepted input type does not change. + // The exceptions are `BeforeValidator`, `PlainValidator` and `WrapValidator`, + // which receive the raw input and can therefore accept any value. + // + // Metadata that is not a class (such as `Any`) is excluded. let ignore_errors = self.error_swallower(); let has_custom_schema = metadata_items.iter().any(|metadata_item| { - let metadata_ty = self.expr_infer(metadata_item, &ignore_errors); - !metadata_ty.is_any() - && self.has_static_attr(&metadata_ty, &GET_PYDANTIC_CORE_SCHEMA) + let cls = match self.expr_infer(metadata_item, &ignore_errors) { + Type::ClassType(cls) => cls.class_object().clone(), + Type::ClassDef(cls) => cls, + _ => return false, + }; + self.get_class_member_with_defining_class(&cls, &GET_PYDANTIC_CORE_SCHEMA) + .is_some_and(|member| { + let owner = &member.defining_class; + owner.module_name().first_component() != "pydantic" + || matches!( + owner.name().as_str(), + "BeforeValidator" | "PlainValidator" | "WrapValidator" + ) + }) }); // Look through metadata items and find a Field(...) call, then extract its keywords @@ -751,7 +769,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { if has_custom_schema { keywords .get_or_insert_with(DataclassFieldKeywords::new) - .init_param_any = true; + .pydantic_has_custom_schema = true; } return keywords; diff --git a/pyrefly/lib/test/pydantic/field.rs b/pyrefly/lib/test/pydantic/field.rs index c57caee801..1c7705dd57 100644 --- a/pyrefly/lib/test/pydantic/field.rs +++ b/pyrefly/lib/test/pydantic/field.rs @@ -442,6 +442,9 @@ pydantic_testcase!( from typing import Annotated, Any, reveal_type from pydantic import BaseModel, ConfigDict +from uuid import UUID +from pydantic.types import UuidVersion + class DType: ... class DTypeAdapter: @@ -456,11 +459,17 @@ class Model(BaseModel): dtype: Annotated[DType, DTypeAdapter()] other: DType +class Model2(BaseModel): + model_config = ConfigDict(strict=True) + x: Annotated[UUID, UuidVersion(4)] + reveal_type(Model.__init__) # E: revealed type: (self: Model, *, dtype: Any, other: DType, **Unknown) -> None m = Model(dtype=">i4", other=DType()) Model(dtype=DType(), other=">i4") # E: Argument `Literal['>i4']` is not assignable to parameter `other` m.dtype = ">i4" # E: `Literal['>i4']` is not assignable to attribute `dtype` with type `DType` +Model2(x="not-a-uuid") # E: Argument `Literal['not-a-uuid']` is not assignable to parameter `x` + "#, );