Skip to content

fix Implement static checking for model_validate in pydantic #1123#3390

Open
asukaminato0721 wants to merge 4 commits into
facebook:mainfrom
asukaminato0721:1123
Open

fix Implement static checking for model_validate in pydantic #1123#3390
asukaminato0721 wants to merge 4 commits into
facebook:mainfrom
asukaminato0721:1123

Conversation

@asukaminato0721
Copy link
Copy Markdown
Contributor

@asukaminato0721 asukaminato0721 commented May 14, 2026

Summary

Fixes #1123

Added synthesized model_validate support for Pydantic models including nested model/list dict input shapes.

Wired the synthesized method into Pydantic dataclass fields.

Added call-time constraint checking for model_validate, including inline literals and same-scope literal initializers.

Test Plan

add test

@meta-cla meta-cla Bot added the cla signed label May 14, 2026
@github-actions github-actions Bot added size/xl and removed size/xl labels May 14, 2026
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions github-actions Bot added size/xl and removed size/xl labels May 14, 2026
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions github-actions Bot added size/xl and removed size/xl labels May 14, 2026
@github-actions

This comment has been minimized.

@asukaminato0721 asukaminato0721 marked this pull request as ready for review May 14, 2026 16:06
Copilot AI review requested due to automatic review settings May 14, 2026 16:06
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a synthesized model_validate classmethod to Pydantic dataclass-style models in pyrefly's static checker, and adds call-time constraint checking that walks dict literals (and same-scope literal initializers) passed to Model.model_validate(...) to flag values that violate field constraints such as Field(ge=...). It also extends emit_pydantic_argument_constraint to recurse through union types so each member is checked.

Changes:

  • Synthesize a model_validate classmethod on every Pydantic model whose own class doesn't already define one, with a typed-dict-shaped obj parameter that recursively expands nested models, lists, and dicts.
  • Add check_pydantic_model_validate_constraints invoked from the generic call path; it inspects dict literals (and traces a name's same-scope initializer) and the inferred typed-dict shape for ge/le/etc. violations on nested fields.
  • Add four new pydantic tests covering nested-field range checking, from_attributes, custom model_validate preservation, and a super().model_validate(...) flow.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
pyrefly/lib/alt/class/pydantic.rs Implements synthesized model_validate builder, dict-shape type construction, and the recursive expr/type constraint checkers; adds union recursion to emit_pydantic_argument_constraint.
pyrefly/lib/alt/class/dataclass.rs Wires the synthesized model_validate into Pydantic dataclass synthesized fields, suppressed only when the current class itself defines model_validate.
pyrefly/lib/alt/call.rs Invokes the new constraint checker on every call expression.
pyrefly/lib/test/pydantic/field.rs Adds tests for nested constraint detection, from_attributes, custom-method preservation, and super().model_validate(...).
Comments suppressed due to low confidence (5)

pyrefly/lib/alt/class/pydantic.rs:995

  • When expected is a Type::Union, this iterates each member and recursively checks the same actual against every member, emitting a constraint violation for any union member whose nested-model shape disagrees. For a value typed as int | str this is harmless because non-pydantic members are no-ops, but for ItemA | ItemB (both pydantic models) the same actual will be validated against both models and may produce duplicate or false-positive errors when the value is only intended to satisfy one branch of the union. Consider only emitting an error if all members fail (i.e. a "best-match" or "any-passes" semantics) for nested model unions.
            Type::Union(union) => {
                for expected in union.members.iter() {
                    self.check_pydantic_field_expr_constraints(
                        expected, actual, range, errors, seen,
                    );
                }
            }

pyrefly/lib/alt/class/pydantic.rs:1084

  • Same union false-positive concern as in check_pydantic_field_expr_constraints: when the expected type is a union of multiple pydantic models, the actual value is validated against every member and any failing branch will report a constraint error, even if another branch in the union accepts the value.
        match (expected, actual) {
            (Type::Union(expected), _) => {
                for expected in expected.members.iter() {
                    self.check_pydantic_field_type_constraints(
                        expected, actual, range, errors, seen,
                    );
                }
            }

pyrefly/lib/alt/class/pydantic.rs:832

  • When the user writes bad = {"items": [..., {"quantity": -3}]} followed by Inventory.model_validate(bad), this code recurses into the initializer expression of bad and emits a constraint violation at the literal's range — but the assignment bad = {...} is, by itself, not a pydantic call. The error then surfaces on the assignment line even when the user could plausibly use bad for unrelated purposes, and the diagnostic gives no indication that the violation comes from a downstream model_validate call. Consider either reporting the error at the model_validate call's range (with a note pointing back to the literal) or qualifying the message so it isn't confusing on the assignment line.
        if let Some(initializer) = self.pydantic_name_initializer(obj) {
            self.check_pydantic_model_validate_expr_constraints(
                &cls,
                dataclass,
                initializer,
                range,
                errors,
                &mut seen,
            );
            seen.clear();
        }

pyrefly/lib/alt/class/pydantic.rs:288

  • Recursion into nested container types only handles list and dict; nested pydantic models inside tuple, set, frozenset, Sequence, Mapping, etc. will not have their dict-shape input form added to obj_ty. This makes the accepted argument type for model_validate inconsistent with what Pydantic actually accepts at runtime (it coerces dict shapes through any iterable container, not just list/dict).
                } else if cls.class_object() == self.stdlib.list_object()
                    && let [elem] = cls.targs().as_slice()
                {
                    self.heap.mk_class_type(
                        self.stdlib
                            .list(self.pydantic_model_validate_type(elem.clone(), seen)),
                    )
                } else if cls.class_object() == self.stdlib.dict_object()
                    && let [key, value] = cls.targs().as_slice()
                {
                    self.heap.mk_class_type(self.stdlib.dict(
                        key.clone(),
                        self.pydantic_model_validate_type(value.clone(), seen),
                    ))
                } else {
                    Type::ClassType(cls)
                }
            }

pyrefly/lib/alt/class/pydantic.rs:881

  • pydantic_name_initializer only resolves Binding::Expr/Binding::NameAssign/Binding::AnnotatedType chains and ignores re-assignments. If the user writes bad = {"quantity": 5}; bad = {"quantity": -3}; Inventory.model_validate(bad), only one of these initializers will be inspected (whichever the binding key resolves to), and the analysis may either miss real violations or report them on a stale initializer that doesn't actually flow into the call. Consider documenting this best-effort behavior in a comment, or restricting it to bindings that are provably the unique reaching definition.
    fn pydantic_name_initializer<'b>(&'b self, actual: &Expr) -> Option<&'b Expr> {
        let Expr::Name(name) = actual else {
            return None;
        };
        let key = Key::BoundName(ShortIdentifier::expr_name(name));
        let idx = self.bindings().key_to_idx_hashed_opt(Hashed::new(&key))?;
        let mut idx = idx;
        let mut gas = Gas::new(100);
        while !gas.stop() {
            match self.bindings().get(idx) {
                Binding::Forward(forward)
                | Binding::PromoteForward(forward)
                | Binding::ForwardToFirstUse(forward) => idx = *forward,
                binding => return Self::pydantic_initializer_from_binding(binding),
            }
        }
        None
    }

    fn pydantic_initializer_from_binding(binding: &Binding) -> Option<&Expr> {
        match binding {
            Binding::Expr(_, expr) => Some(expr),
            Binding::NameAssign(assign) => Some(&assign.expr),
            Binding::AnnotatedType(_, inner) => Self::pydantic_initializer_from_binding(inner),
            _ => None,
        }
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +150 to +159
if metadata.is_pydantic_model()
&& !self
.get_class_fields(cls)
.is_some_and(|fields| fields.contains(&Name::new_static("model_validate")))
{
let root_model_type = self
.get_pydantic_root_model_type_via_mro(cls, &metadata)
.map(|(ty, _)| ty);
fields.insert(
Name::new_static("model_validate"),
Comment thread pyrefly/lib/alt/call.rs
Comment on lines +1893 to +1899
self.check_pydantic_model_validate_constraints(
ty,
&x.arguments.args,
&x.arguments.keywords,
x.arguments.range,
errors,
);
Comment on lines +250 to +256
ClassSynthesizedField::new(self.heap.mk_function(Function {
signature: Callable::list(
ParamList::new(params),
self.heap.mk_self_type(self.as_class_type_unchecked(cls)),
),
metadata,
}))
@github-actions
Copy link
Copy Markdown

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement static checking for model_validate in pydantic

2 participants