From 1d1930af175a2ec83392da1fab7f27f53f352e6e Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sat, 31 Oct 2026 22:40:59 +0900 Subject: [PATCH 1/2] test --- pyrefly/lib/test/pydantic/field.rs | 313 +++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) diff --git a/pyrefly/lib/test/pydantic/field.rs b/pyrefly/lib/test/pydantic/field.rs index 4c0cbe6a51..3c6b7e4c3c 100644 --- a/pyrefly/lib/test/pydantic/field.rs +++ b/pyrefly/lib/test/pydantic/field.rs @@ -644,6 +644,319 @@ class Bar(Foo): "#, ); +pydantic_testcase!( + test_model_validate_nested_field_range, + r#" +from pydantic import BaseModel, Field + +class Item(BaseModel): + sku: str + quantity: int = Field(ge=1) + +class Inventory(BaseModel): + items: list[Item] + +Inventory.model_validate({"items": [{"sku": "A-100", "quantity": 5}]}) +Inventory.model_validate({"items": [{"sku": "B-200", "quantity": -3}]}) # E: Argument value `Literal[-3]` violates Pydantic `ge` constraint `Literal[1]` for field `quantity` + +bad = {"items": [{"sku": "B-200", "quantity": -3}]} # E: Argument value `Literal[-3]` violates Pydantic `ge` constraint `Literal[1]` for field `quantity` +Inventory.model_validate(bad) +"#, +); + +pydantic_testcase!( + test_model_validate_union_alternatives, + r#" +from pydantic import BaseModel, Field + +class Positive(BaseModel): + x: int = Field(ge=0) + +class Negative(BaseModel): + x: int = Field(le=-1) + +class Either(BaseModel): + item: Positive | Negative + items: list[Positive | Negative] + +Either.model_validate({"item": {"x": -1}, "items": [{"x": -1}, {"x": 1}]}) +Either.model_validate({"item": {"x": 1}, "items": []}) +"#, +); + +pydantic_testcase!( + test_model_validate_custom_input, + r#" +from typing import Self, assert_type +from pydantic import BaseModel, Field + +class Parent(BaseModel): + x: int = Field(ge=0) + + @classmethod + def model_validate( + cls, obj: object = None, *, strict=None, from_attributes=None, + context=None, by_alias=None, by_name=None, + ) -> Self: + return cls(x=1) + +class Child(Parent): + pass + +class Builtin(BaseModel): + pass + +class Multiple(Builtin, Parent): + pass + +assert_type(Child.model_validate(), Child) +assert_type(Multiple.model_validate(), Multiple) +Parent.model_validate({"x": -1}) +Child.model_validate({"x": -1}) +"#, +); + +pydantic_testcase!( + test_model_validate_alias_selection, + r#" +from pydantic import BaseModel, ConfigDict, Field + +class Both(BaseModel): + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + x: int = Field(ge=0, alias="y") + +Both.model_validate({"x": -1, "y": 1}) +Both.model_validate({"x": 1, "y": -1}) # E: violates Pydantic `ge` constraint +Both.model_validate({"x": 1, "y": -1}, by_name=True, by_alias=False) +Both.model_validate({"x": -1, "y": 1}, by_name=True, by_alias=False) # E: violates Pydantic `ge` constraint + +class AliasOnly(BaseModel): + x: int = Field(ge=0, validation_alias="y") + +AliasOnly.model_validate({"x": 1, "y": -1}, by_name=True, by_alias=False) +AliasOnly.model_validate({"x": -1, "y": 1}, by_name=True, by_alias=False) # E: violates Pydantic `ge` constraint + +class NameOnly(BaseModel): + model_config = ConfigDict(validate_by_name=True, validate_by_alias=False) + x: int = Field(ge=0, alias="y") + +NameOnly.model_validate({"x": -1, "y": 1}, by_alias=True, by_name=False) +NameOnly.model_validate({"x": 1, "y": -1}) + +def dynamic(flag: bool) -> None: + Both.model_validate({"x": -1, "y": 1}, by_alias=flag, by_name=True) + Both.model_validate({"x": -1, "y": 1}, **{"by_alias": flag, "by_name": True}) + +class Outer(BaseModel): + child: Both + +Outer.model_validate({"child": {"x": 1, "y": -1}}, by_name=True, by_alias=False) +"#, +); + +pydantic_testcase!( + test_model_validate_dictionary_spreads, + r#" +from pydantic import BaseModel, Field + +class Model(BaseModel): + x: int = Field(ge=0) + +patch: dict[str, int] = {"x": 1} +Model.model_validate({"x": -1, **patch}) +Model.model_validate({"x": -1, **{"x": 1}}) +Model.model_validate({**patch, "x": -1}) # E: violates Pydantic `ge` constraint +Model.model_validate({"x": 1, **{"x": -1}}) # E: violates Pydantic `ge` constraint + +def dynamic_key(key: str) -> None: + Model.model_validate({"x": -1, key: 1}) + +class Aliased(BaseModel): + model_config = {"validate_by_name": True} + x: int = Field(ge=0, alias="y") + +Aliased.model_validate({**patch, "x": -1}) +Aliased.model_validate({**patch, "x": -1, "y": 1}) +Aliased.model_validate({**patch, "y": -1}) # E: violates Pydantic `ge` constraint +"#, +); + +/// Supply a library signature with a version-specific keyword. +fn model_validate_library_env() -> TestEnv { + TestEnv::one( + "pydantic.main", + r#" +from typing import Any, Literal, Self, dataclass_transform + +@dataclass_transform() +class BaseModel: + @classmethod + def model_validate( + cls, obj: object, *, extra: Literal["allow", "ignore", "forbid"] | None = None, + context: Any = None, + ) -> Self: ... +"#, + ) +} + +testcase!( + test_model_validate_library_signature, + model_validate_library_env(), + r#" +from typing import assert_type +from pydantic.main import BaseModel + +class Model(BaseModel): + x: int + +assert_type(Model.model_validate({"x": 1}, extra="forbid"), Model) +Model.model_validate({"x": 1}, extra="invalid") # E: is not assignable to parameter `extra` +"#, +); + +pydantic_testcase!( + test_model_validate_shared_descendants, + r#" +from pydantic import BaseModel + +class M0(BaseModel): + x: int + +class M1(BaseModel): + left: M0 + right: M0 + +class M2(BaseModel): + left: M1 + right: M1 + +class M3(BaseModel): + left: M2 + right: M2 + +class M4(BaseModel): + left: M3 + right: M3 + +class M5(BaseModel): + left: M4 + right: M4 + +class M6(BaseModel): + left: M5 + right: M5 + +class M7(BaseModel): + left: M6 + right: M6 + +class M8(BaseModel): + left: M7 + right: M7 + +class M9(BaseModel): + left: M8 + right: M8 + +class M10(BaseModel): + left: M9 + right: M9 + +class M11(BaseModel): + left: M10 + right: M10 + +class M12(BaseModel): + left: M11 + right: M11 + +class M13(BaseModel): + left: M12 + right: M12 + +class M14(BaseModel): + left: M13 + right: M13 +"#, +); + +pydantic_testcase!( + test_model_validate_from_attributes_accepts_object, + r#" +from pydantic import BaseModel + +class Model(BaseModel): + x: int + +def validate(obj: object) -> Model: + return Model.model_validate(obj, from_attributes=True) +"#, +); + +pydantic_testcase!( + test_custom_model_validate_is_not_overwritten, + r#" +from typing import Self +from pydantic import BaseModel + +class Model(BaseModel): + x: int + + @classmethod + def model_validate( + cls, + obj: object, + *, + strict: bool | None = None, + from_attributes: bool | None = None, + context: object | None = None, + by_alias: bool | None = None, + by_name: bool | None = None, + ) -> Self: + return cls(x=0) + +def validate(obj: object) -> int: + return Model.model_validate(obj).x +"#, +); + +pydantic_testcase!( + test_super_model_validate_returns_self, + r#" +from typing import Self +from pydantic import BaseModel + +class Parent(BaseModel): + x: int + +class Child(Parent): + y: int + + @classmethod + def model_validate( + cls, + obj: object, + *, + strict: bool | None = None, + from_attributes: bool | None = None, + context: object | None = None, + by_alias: bool | None = None, + by_name: bool | None = None, + ) -> Self: + return super().model_validate( + obj, + strict=strict, + from_attributes=from_attributes, + context=context, + by_alias=by_alias, + by_name=by_name, + ) + +def validate(obj: object) -> int: + return Child.model_validate(obj, from_attributes=True).y +"#, +); + pydantic_testcase!( bug = "consider erroring on invalid5 and invalid6", test_discriminated_unions, From 5d2af2d5f279e052a4be216dbab9be09e4a2ec3a Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 14 May 2026 15:55:31 +0900 Subject: [PATCH 2/2] fix use Gas fix some regression . fix api change . --- pyrefly/lib/alt/call.rs | 6 + pyrefly/lib/alt/class/pydantic.rs | 204 ++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index df5857c26b..43dd115dc7 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -3112,6 +3112,12 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { "`NotImplemented` is not callable. Did you mean `NotImplementedError`?".to_owned(), ); } + self.check_pydantic_model_validate_constraints( + ty, + &x.arguments.args, + &x.arguments.keywords, + errors, + ); match ty.callee_kind() { Some(CalleeKind::Function(FunctionKind::AssertType)) => self .call_assert_type( diff --git a/pyrefly/lib/alt/class/pydantic.rs b/pyrefly/lib/alt/class/pydantic.rs index 65cb70bb37..5c4ae53029 100644 --- a/pyrefly/lib/alt/class/pydantic.rs +++ b/pyrefly/lib/alt/class/pydantic.rs @@ -22,7 +22,9 @@ use pyrefly_types::function::FunctionKind; use pyrefly_types::keywords::DataclassFieldKeywords; use pyrefly_types::lit_int::LitInt; use pyrefly_types::literal::Lit; +use pyrefly_util::gas::Gas; use ruff_python_ast::Expr; +use ruff_python_ast::Keyword; use ruff_python_ast::name::Name; use ruff_text_size::Ranged; use ruff_text_size::TextRange; @@ -882,6 +884,202 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { } } + /// Check literal input only when the call uses Pydantic's built-in implementation. + pub fn check_pydantic_model_validate_constraints( + &self, + callee: &Type, + args: &[Expr], + keywords: &[Keyword], + errors: &ErrorCollector, + ) { + let Some(meta) = callee.toplevel_func_metadata() else { + return; + }; + if meta.kind.function_name().as_str() != "model_validate" + || !meta.kind.class().is_some_and(|cls| { + cls.has_toplevel_qname(ModuleName::pydantic().as_str(), "BaseModel") + }) + { + return; + } + let Type::BoundMethod(method) = callee else { + return; + }; + let expected = match &method.obj { + Type::ClassDef(cls) => self.instantiate(cls), + Type::Type(ty) => (**ty).clone(), + _ => return, + }; + let Some(obj) = args.first().or_else(|| { + keywords + .iter() + .find(|kw| kw.arg.as_ref().is_some_and(|arg| arg.id.as_str() == "obj")) + .map(|kw| &kw.value) + }) else { + return; + }; + let mut validation = PydanticValidationFlags::default(); + for keyword in keywords { + let Some(name) = &keyword.arg else { + // An unpacked keyword mapping can change either alias control. + return; + }; + let option = match name.id.as_str() { + "by_alias" => &mut validation.validate_by_alias, + "by_name" => &mut validation.validate_by_name, + _ => continue, + }; + *option = match &keyword.value { + Expr::BooleanLiteral(value) => Some(value.value), + Expr::NoneLiteral(_) => None, + _ => return, + }; + } + self.check_pydantic_model_validate_input( + &expected, + obj, + &validation, + &mut Gas::new(100), + errors, + ); + } + + 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, + } + } + + /// Walk only concrete dictionary and list input, with a fixed work limit. + fn check_pydantic_model_validate_input( + &self, + expected: &Type, + actual: &Expr, + validation: &PydanticValidationFlags, + gas: &mut Gas, + errors: &ErrorCollector, + ) { + if gas.stop() { + return; + } + let actual = self.pydantic_name_initializer(actual).unwrap_or(actual); + match expected { + Type::Annotated(expected, _) => { + self.check_pydantic_model_validate_input(expected, actual, validation, gas, errors); + } + Type::ClassType(expected) + if expected.class_object() == self.stdlib.list_object() + && let [expected] = expected.targs().as_slice() + && let Expr::List(actual) = actual => + { + for actual in &actual.elts { + self.check_pydantic_model_validate_input( + expected, actual, validation, gas, errors, + ); + } + } + Type::ClassType(expected) | Type::SelfType(expected) + if let Expr::Dict(actual) = actual => + { + let cls = expected.class_object(); + let metadata = self.get_metadata_for_class(cls); + let Some(dataclass) = metadata + .dataclass_metadata() + .filter(|_| metadata.is_pydantic_model()) + else { + return; + }; + if validation.validate_by_alias == Some(true) + && !dataclass + .init_defaults + .pydantic_validation_flags + .validate_by_alias() + { + // Constructor metadata omits aliases disabled by model configuration. + return; + } + let mut fields = SmallMap::new(); + let mut unknown_keys = false; + for item in Ast::flatten_dict_items(&actual.items) { + if let Some(key) = item.key.as_ref().and_then(Expr::as_string_literal_expr) { + fields.insert(Name::new(key.value.to_str()), &item.value); + } else { + // An unknown key or spread can replace any earlier value. + fields.clear(); + unknown_keys = true; + } + } + let kw_only_by_class = self.compute_kw_only_fields_by_class(cls); + for (name, field, flags) in + self.iter_fields(cls, dataclass, true, &kw_only_by_class) + { + let alias = flags + .init_by_alias + .as_ref() + .filter(|_| validation.validate_by_alias.unwrap_or(true)); + let value = if let Some(value) = alias.and_then(|alias| fields.get(alias)) { + Some(value) + } else if alias.is_some() && unknown_keys { + // A spread can supply an alias that takes precedence over the field name. + None + } else if validation.validate_by_name.unwrap_or(flags.init_by_name) { + fields.get(&name) + } else { + None + }; + let Some(value) = value else { + continue; + }; + if let Some(constraints) = PydanticRangeConstraints::from_keywords(&flags) { + let value_ty = self.expr_infer(value, &self.error_swallower()); + if int_literal_from_type(&value_ty).is_some() { + self.emit_pydantic_argument_constraint( + &value_ty, + &PydanticParamConstraint { + field_name: name, + constraints, + }, + value.range(), + errors, + ); + } + } + self.check_pydantic_model_validate_input( + &field.ty(), + value, + validation, + gas, + errors, + ); + } + } + // Union alternatives need schema selection before a violation is certain. + _ => {} + } + } + fn collect_pydantic_constraint_params( &self, cls: &Class, @@ -928,6 +1126,12 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { range: TextRange, errors: &ErrorCollector, ) { + if let Type::Union(union) = value_ty { + for value_ty in union.members.iter() { + self.emit_pydantic_argument_constraint(value_ty, info, range, errors); + } + return; + } let Some(value_lit) = int_literal_from_type(value_ty) else { return; };