Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/pyrefly_types/src/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 {
Expand Down Expand Up @@ -196,6 +201,7 @@ impl DataclassFieldKeywords {
strict: None,
frozen: None,
attrs_setattr_frozen: None,
pydantic_has_custom_schema: false,
}
}

Expand Down
17 changes: 10 additions & 7 deletions pyrefly/lib/alt/class/class_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions pyrefly/lib/alt/class/dataclass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> {
frozen,
converter_param,
attrs_setattr_frozen,
pydantic_has_custom_schema: false,
}
}

Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions pyrefly/lib/alt/class/pydantic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyrefly/lib/binding/pydantic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
132 changes: 132 additions & 0 deletions pyrefly/lib/test/pydantic/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
Loading