From 37de0d8599cefd5a8f3657e5dfff8dc24b169414 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 19 Dec 2025 22:51:26 +0900 Subject: [PATCH 1/3] able to check call site --- pyrefly/lib/alt/call.rs | 16 ++- pyrefly/lib/alt/class/dataclass.rs | 2 +- pyrefly/lib/alt/class/pydantic.rs | 200 +++++++++++++++++++++++++++++ pyrefly/lib/test/pydantic/field.rs | 4 +- 4 files changed, 216 insertions(+), 6 deletions(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index 5679721928..1039107dfa 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -571,15 +571,14 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { self.solver().generalize_class_targs(cls.targs_mut()); } let hint = None; // discard hint + let class_metadata = self.get_metadata_for_class(cls.class_object()); if let Some(ret) = self.call_metaclass(&cls, arguments_range, args, keywords, errors, context, hint) && !self.is_compatible_constructor_return(&ret, cls.class_object()) { if let Some(metaclass_dunder_call) = self.get_metaclass_dunder_call(&cls) { if let Some(callee_range) = callee_range - && let Some(metaclass) = self - .get_metadata_for_class(cls.class_object()) - .custom_metaclass() + && let Some(metaclass) = class_metadata.custom_metaclass() { self.record_external_attribute_definition_index( &metaclass.clone().to_type(), @@ -676,6 +675,17 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } self.record_resolved_trace(arguments_range, init_method); } + if class_metadata.is_pydantic_base_model() + && let Some(dataclass) = class_metadata.dataclass_metadata() + { + self.check_pydantic_argument_range_constraints( + cls.class_object(), + dataclass, + args, + keywords, + errors, + ); + } self.solver() .finish_class_targs(cls.targs_mut(), self.uniques); if let Some(mut ret) = dunder_new_ret { diff --git a/pyrefly/lib/alt/class/dataclass.rs b/pyrefly/lib/alt/class/dataclass.rs index 0542a371cd..925fccd9d8 100644 --- a/pyrefly/lib/alt/class/dataclass.rs +++ b/pyrefly/lib/alt/class/dataclass.rs @@ -604,7 +604,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { ) } - fn iter_fields( + pub(crate) fn iter_fields( &self, cls: &Class, dataclass: &DataclassMetadata, diff --git a/pyrefly/lib/alt/class/pydantic.rs b/pyrefly/lib/alt/class/pydantic.rs index 22335de640..944b743b43 100644 --- a/pyrefly/lib/alt/class/pydantic.rs +++ b/pyrefly/lib/alt/class/pydantic.rs @@ -25,10 +25,14 @@ use pyrefly_types::literal::Lit; use pyrefly_types::types::Union; use ruff_python_ast::Expr; use ruff_python_ast::name::Name; +use ruff_text_size::Ranged; use ruff_text_size::TextRange; +use starlark_map::small_map::SmallMap; use crate::alt::answers::LookupAnswer; use crate::alt::answers_solver::AnswersSolver; +use crate::alt::callable::CallArg; +use crate::alt::callable::CallKeyword; use crate::alt::solve::TypeFormContext; use crate::alt::types::class_metadata::ClassMetadata; use crate::alt::types::class_metadata::ClassSynthesizedField; @@ -53,6 +57,45 @@ use crate::error::context::ErrorInfo; use crate::types::class::Class; use crate::types::types::Type; +fn int_literal_from_type(ty: &Type) -> Option<&LitInt> { + match ty { + Type::Literal(Lit::Int(lit)) => Some(lit), + _ => None, + } +} + +#[derive(Clone)] +struct PydanticRangeConstraints { + gt: Option, + ge: Option, + lt: Option, + le: Option, +} + +impl PydanticRangeConstraints { + fn from_keywords(keywords: &DataclassFieldKeywords) -> Option { + if keywords.gt.is_none() + && keywords.ge.is_none() + && keywords.lt.is_none() + && keywords.le.is_none() + { + return None; + } + Some(Self { + gt: keywords.gt.clone(), + ge: keywords.ge.clone(), + lt: keywords.lt.clone(), + le: keywords.le.clone(), + }) + } +} + +#[derive(Clone)] +struct PydanticParamConstraint { + field_name: Name, + constraints: PydanticRangeConstraints, +} + impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { pub fn get_pydantic_root_model_type_via_mro( &self, @@ -499,4 +542,161 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } None } + + pub fn check_pydantic_argument_range_constraints( + &self, + cls: &Class, + dataclass: &DataclassMetadata, + args: &[CallArg], + keywords: &[CallKeyword], + errors: &ErrorCollector, + ) { + let Some((positional_slots, constraints)) = + self.collect_pydantic_constraint_params(cls, dataclass) + else { + return; + }; + if constraints.is_empty() { + return; + } + + let infer_errors = self.error_swallower(); + let mut positional_index = 0; + for arg in args { + if positional_index >= positional_slots.len() { + break; + } + let slot = positional_slots.get(positional_index); + positional_index += 1; + let Some(slot) = slot else { + break; + }; + let Some(param_name) = slot.clone() else { + continue; + }; + match arg { + CallArg::Arg(value) => { + let value_ty = value.infer(self, &infer_errors); + if let Some(info) = constraints.get(¶m_name) { + self.emit_pydantic_argument_constraint( + &value_ty, + info, + arg.range(), + errors, + ); + } + } + CallArg::Star(..) => { + // Can't reliably map starred arguments to parameters. + break; + } + } + } + + for kw in keywords { + let Some(identifier) = kw.arg.as_ref() else { + continue; + }; + if let Some(info) = constraints.get(&identifier.id) { + let value_ty = kw.value.infer(self, &infer_errors); + self.emit_pydantic_argument_constraint(&value_ty, info, kw.range, errors); + } + } + } + + fn collect_pydantic_constraint_params( + &self, + cls: &Class, + dataclass: &DataclassMetadata, + ) -> Option<(Vec>, SmallMap)> { + let mut positional_slots: Vec> = Vec::new(); + let mut constraints = SmallMap::new(); + + for (field_name, _field, keywords) in self.iter_fields(cls, dataclass, true) { + if !keywords.init { + continue; + } + + let constraint = PydanticRangeConstraints::from_keywords(&keywords); + let constraint_template = constraint.as_ref().map(|c| PydanticParamConstraint { + field_name: field_name.clone(), + constraints: c.clone(), + }); + + if keywords.init_by_name && !keywords.is_kw_only() { + positional_slots.push(constraint_template.as_ref().map(|_| field_name.clone())); + } else if keywords.init_by_name { + // kw-only parameter with no positional slot + } + + if let Some(alias) = &keywords.init_by_alias + && !keywords.is_kw_only() + { + positional_slots.push(constraint_template.as_ref().map(|_| alias.clone())); + } + + if let Some(info) = constraint_template { + if keywords.init_by_name { + constraints.insert(field_name.clone(), info.clone()); + } + if let Some(alias) = &keywords.init_by_alias { + constraints.insert(alias.clone(), info.clone()); + } + } + } + + if constraints.is_empty() { + None + } else { + Some((positional_slots, constraints)) + } + } + + fn emit_pydantic_argument_constraint( + &self, + value_ty: &Type, + info: &PydanticParamConstraint, + range: TextRange, + errors: &ErrorCollector, + ) { + let Some(value_lit) = int_literal_from_type(value_ty) else { + return; + }; + let checks = [ + ("gt", info.constraints.gt.as_ref()), + ("ge", info.constraints.ge.as_ref()), + ("lt", info.constraints.lt.as_ref()), + ("le", info.constraints.le.as_ref()), + ]; + for (label, constraint_ty) in checks { + let Some(constraint_ty) = constraint_ty else { + continue; + }; + let Some(constraint_lit) = int_literal_from_type(constraint_ty) else { + continue; + }; + let comparison = value_lit.cmp(constraint_lit); + let violates = match label { + "gt" => !matches!(comparison, std::cmp::Ordering::Greater), + "ge" => matches!(comparison, std::cmp::Ordering::Less), + "lt" => !matches!(comparison, std::cmp::Ordering::Less), + "le" => matches!(comparison, std::cmp::Ordering::Greater), + _ => false, + }; + if violates { + self.error( + errors, + range, + ErrorInfo::Kind(ErrorKind::BadArgumentType), + format!( + "Argument value `{}` violates Pydantic `{}` constraint `{}` for field `{}`", + self.for_display(value_ty.clone()), + label, + self.for_display(constraint_ty.clone()), + info.field_name + ), + ); + } + } + } } diff --git a/pyrefly/lib/test/pydantic/field.rs b/pyrefly/lib/test/pydantic/field.rs index a8f378e40b..dc78296a70 100644 --- a/pyrefly/lib/test/pydantic/field.rs +++ b/pyrefly/lib/test/pydantic/field.rs @@ -21,8 +21,8 @@ class Model(BaseModel): x: int = Field(gt=0, lt=10) Model(x=5) -Model(x=0) -Model(x=15) +Model(x=0) # E: Argument value `Literal[0]` violates Pydantic `gt` constraint `Literal[0]` for field `x` +Model(x=15) # E: Argument value `Literal[15]` violates Pydantic `lt` constraint `Literal[10]` for field `x` "#, ); From afa9111a0e1cef579d7598e8c1936674012caeaa Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 19 Dec 2025 23:06:22 +0900 Subject: [PATCH 2/3] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pyrefly/lib/alt/class/pydantic.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyrefly/lib/alt/class/pydantic.rs b/pyrefly/lib/alt/class/pydantic.rs index 944b743b43..6562d8a56c 100644 --- a/pyrefly/lib/alt/class/pydantic.rs +++ b/pyrefly/lib/alt/class/pydantic.rs @@ -556,9 +556,6 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { else { return; }; - if constraints.is_empty() { - return; - } let infer_errors = self.error_swallower(); let mut positional_index = 0; From 31f409bcaef66d25bebd9cd31d20147059164ba9 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 30 Dec 2025 12:40:32 +0900 Subject: [PATCH 3/3] fix --- pyrefly/lib/alt/class/pydantic.rs | 89 +++++++++++++++--------------- pyrefly/lib/test/pydantic/field.rs | 70 ++++++++++++++++++++++- 2 files changed, 114 insertions(+), 45 deletions(-) diff --git a/pyrefly/lib/alt/class/pydantic.rs b/pyrefly/lib/alt/class/pydantic.rs index 6562d8a56c..8c01416a92 100644 --- a/pyrefly/lib/alt/class/pydantic.rs +++ b/pyrefly/lib/alt/class/pydantic.rs @@ -58,6 +58,7 @@ use crate::types::class::Class; use crate::types::types::Type; fn int_literal_from_type(ty: &Type) -> Option<&LitInt> { + // We only currently enforce range constraints for literal ints. match ty { Type::Literal(Lit::Int(lit)) => Some(lit), _ => None, @@ -96,6 +97,12 @@ struct PydanticParamConstraint { constraints: PydanticRangeConstraints, } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum PydanticParamKey { + Position(usize), + Name(Name), +} + impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { pub fn get_pydantic_root_model_type_via_mro( &self, @@ -457,14 +464,6 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { range: TextRange, errors: &ErrorCollector, ) { - fn int_literal_from_type(ty: &Type) -> Option<&LitInt> { - // We only currently enforce range constraints for literal defaults, so carve out - // the `Literal[int]` case and ignore everything else. - match ty { - Type::Literal(Lit::Int(lit)) => Some(lit), - _ => None, - } - } let Some(default_ty) = &keywords.default else { return; }; @@ -551,30 +550,16 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { keywords: &[CallKeyword], errors: &ErrorCollector, ) { - let Some((positional_slots, constraints)) = - self.collect_pydantic_constraint_params(cls, dataclass) - else { + let Some(constraints) = self.collect_pydantic_constraint_params(cls, dataclass) else { return; }; let infer_errors = self.error_swallower(); - let mut positional_index = 0; - for arg in args { - if positional_index >= positional_slots.len() { - break; - } - let slot = positional_slots.get(positional_index); - positional_index += 1; - let Some(slot) = slot else { - break; - }; - let Some(param_name) = slot.clone() else { - continue; - }; + for (index, arg) in args.iter().enumerate() { match arg { CallArg::Arg(value) => { let value_ty = value.infer(self, &infer_errors); - if let Some(info) = constraints.get(¶m_name) { + if let Some(info) = constraints.get(&PydanticParamKey::Position(index)) { self.emit_pydantic_argument_constraint( &value_ty, info, @@ -594,7 +579,8 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { let Some(identifier) = kw.arg.as_ref() else { continue; }; - if let Some(info) = constraints.get(&identifier.id) { + let key = PydanticParamKey::Name(identifier.id.clone()); + if let Some(info) = constraints.get(&key) { let value_ty = kw.value.infer(self, &infer_errors); self.emit_pydantic_argument_constraint(&value_ty, info, kw.range, errors); } @@ -605,9 +591,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { &self, cls: &Class, dataclass: &DataclassMetadata, - ) -> Option<(Vec>, SmallMap)> { - let mut positional_slots: Vec> = Vec::new(); + ) -> Option> { let mut constraints = SmallMap::new(); + let mut position = 0; for (field_name, _field, keywords) in self.iter_fields(cls, dataclass, true) { if !keywords.init { @@ -615,37 +601,52 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } let constraint = PydanticRangeConstraints::from_keywords(&keywords); - let constraint_template = constraint.as_ref().map(|c| PydanticParamConstraint { + + if let Some(info) = constraint.as_ref().map(|c| PydanticParamConstraint { field_name: field_name.clone(), constraints: c.clone(), - }); + }) { + if keywords.init_by_name { + constraints.insert(PydanticParamKey::Name(field_name.clone()), info.clone()); + } + if let Some(alias) = &keywords.init_by_alias { + constraints.insert(PydanticParamKey::Name(alias.clone()), info.clone()); + } + } if keywords.init_by_name && !keywords.is_kw_only() { - positional_slots.push(constraint_template.as_ref().map(|_| field_name.clone())); - } else if keywords.init_by_name { - // kw-only parameter with no positional slot + if let Some(info) = constraint.as_ref() { + constraints.insert( + PydanticParamKey::Position(position), + PydanticParamConstraint { + field_name: field_name.clone(), + constraints: info.clone(), + }, + ); + } + position += 1; } - if let Some(alias) = &keywords.init_by_alias + if let Some(_alias) = &keywords.init_by_alias && !keywords.is_kw_only() { - positional_slots.push(constraint_template.as_ref().map(|_| alias.clone())); - } - - if let Some(info) = constraint_template { - if keywords.init_by_name { - constraints.insert(field_name.clone(), info.clone()); - } - if let Some(alias) = &keywords.init_by_alias { - constraints.insert(alias.clone(), info.clone()); + if let Some(info) = constraint.as_ref() { + constraints.insert( + PydanticParamKey::Position(position), + PydanticParamConstraint { + field_name: field_name.clone(), + constraints: info.clone(), + }, + ); } + position += 1; } } if constraints.is_empty() { None } else { - Some((positional_slots, constraints)) + Some(constraints) } } diff --git a/pyrefly/lib/test/pydantic/field.rs b/pyrefly/lib/test/pydantic/field.rs index dc78296a70..c624fb49a4 100644 --- a/pyrefly/lib/test/pydantic/field.rs +++ b/pyrefly/lib/test/pydantic/field.rs @@ -13,7 +13,6 @@ use crate::test::util::TestEnv; use crate::testcase; pydantic_testcase!( - bug = "we could support ranges, but this is not for v1", test_field_right_type, r#" from pydantic import BaseModel, Field @@ -26,6 +25,75 @@ Model(x=15) # E: Argument value `Literal[15]` violates Pydantic `lt` constraint "#, ); +pydantic_testcase!( + test_field_range_ge_le, + r#" +from pydantic import BaseModel, Field + +class Model(BaseModel): + x: int = Field(ge=0, le=10) + +Model(x=0) +Model(x=10) +Model(x=-1) # E: Argument value `Literal[-1]` violates Pydantic `ge` constraint `Literal[0]` for field `x` +Model(x=11) # E: Argument value `Literal[11]` violates Pydantic `le` constraint `Literal[10]` for field `x` +"#, +); + +pydantic_testcase!( + test_field_range_positional, + r#" +from pydantic import BaseModel, Field + +class Model(BaseModel): + x: int = Field(gt=0, kw_only=False) + y: int = Field(lt=3, kw_only=False) + +Model(1, 2) +Model(0, 2) # E: Argument value `Literal[0]` violates Pydantic `gt` constraint `Literal[0]` for field `x` +Model(1, 3) # E: Argument value `Literal[3]` violates Pydantic `lt` constraint `Literal[3]` for field `y` +"#, +); + +pydantic_testcase!( + test_field_range_kw_only, + r#" +from pydantic import BaseModel, Field + +class Model(BaseModel): + x: int = Field(ge=1, kw_only=True) + +Model(x=1) +Model(x=0) # E: Argument value `Literal[0]` violates Pydantic `ge` constraint `Literal[1]` for field `x` +"#, +); + +pydantic_testcase!( + test_field_range_alias, + r#" +from pydantic import BaseModel, Field + +class Model(BaseModel, validate_by_name=True, validate_by_alias=True): + x: int = Field(gt=0, validation_alias="y") + +Model(x=0) # E: Argument value `Literal[0]` violates Pydantic `gt` constraint `Literal[0]` for field `x` +Model(y=0) # E: Argument value `Literal[0]` violates Pydantic `gt` constraint `Literal[0]` for field `x` +"#, +); + +pydantic_testcase!( + test_field_range_alias_only, + r#" +from pydantic import BaseModel, Field + +class Model(BaseModel, validate_by_name=False, validate_by_alias=True): + x: int = Field(gt=0, validation_alias="y") + +Model(y=0) # E: Argument value `Literal[0]` violates Pydantic `gt` constraint `Literal[0]` for field `x` +Model(x=0) # E: Missing argument `y` +"#, +); + pydantic_testcase!( test_field_wrong_type, r#"