diff --git a/crates/pyrefly_types/src/keywords.rs b/crates/pyrefly_types/src/keywords.rs index e954589f84..256eea3faa 100644 --- a/crates/pyrefly_types/src/keywords.rs +++ b/crates/pyrefly_types/src/keywords.rs @@ -165,6 +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: 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 { @@ -196,6 +201,7 @@ impl DataclassFieldKeywords { strict: None, frozen: None, attrs_setattr_frozen: None, + pydantic_has_custom_schema: false, } } diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 80b5d173f3..34b503acf1 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.pydantic_has_custom_schema) + .map(|flags| { + let mut kept = DataclassFieldKeywords::new(); + kept.strict = flags.strict; + kept.pydantic_has_custom_schema = flags.pydantic_has_custom_schema; + 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.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 ad3dc27aba..a0c5e9632e 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, + pydantic_has_custom_schema: false, } } @@ -1349,6 +1350,12 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { // 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) + // 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 47991a1627..72969d7217 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,35 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { TypeFormContext::ClassVarAnnotation, &self.error_swallower(), ); + // 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 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 for metadata_item in &metadata_items { if let Expr::Call(call) = metadata_item @@ -735,6 +765,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) + .pydantic_has_custom_schema = 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..1c7705dd57 100644 --- a/pyrefly/lib/test/pydantic/field.rs +++ b/pyrefly/lib/test/pydantic/field.rs @@ -432,6 +432,138 @@ 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 + +from uuid import UUID +from pydantic.types import UuidVersion + +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 + +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` + + "#, +); + +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#"