From 315f203d30f500fc086afc421efece56012a9d39 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 00:27:30 +0900 Subject: [PATCH] fix --- pyrefly/lib/alt/call.rs | 2 +- pyrefly/lib/alt/class/pydantic.rs | 82 ++++++++++++++++++++--- pyrefly/lib/alt/class/pydantic_lax.rs | 8 ++- pyrefly/lib/test/pydantic/strictness.rs | 33 +++++++++ website/docs/pydantic-lax-conversions.mdx | 5 +- 5 files changed, 114 insertions(+), 16 deletions(-) diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index 018bcdb3bc..2b50ca7a53 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -1439,7 +1439,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { if class_metadata.is_pydantic_model() && let Some(dataclass) = class_metadata.dataclass_metadata() { - self.check_pydantic_argument_range_constraints( + self.check_pydantic_argument_constraints( cls.class_object(), dataclass, args, diff --git a/pyrefly/lib/alt/class/pydantic.rs b/pyrefly/lib/alt/class/pydantic.rs index 1bf21e7fe4..10ffd7ca96 100644 --- a/pyrefly/lib/alt/class/pydantic.rs +++ b/pyrefly/lib/alt/class/pydantic.rs @@ -90,7 +90,8 @@ impl PydanticRangeConstraints { #[derive(Clone)] struct PydanticParamConstraint { field_name: Name, - constraints: PydanticRangeConstraints, + range: Option, + lax_bool_field: Option, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -654,7 +655,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { None } - pub fn check_pydantic_argument_range_constraints( + pub fn check_pydantic_argument_constraints( &self, cls: &Class, dataclass: &DataclassMetadata, @@ -708,18 +709,35 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { let mut constraints = SmallMap::new(); let mut position = 0; let kw_only_by_class = self.compute_kw_only_fields_by_class(cls); - for (field_name, _field, keywords) in + for (field_name, field, keywords) in self.iter_fields(cls, dataclass, true, &kw_only_by_class) { if !keywords.init { continue; } - let Some(constraint) = PydanticRangeConstraints::from_keywords(&keywords) else { - continue; + let range = PydanticRangeConstraints::from_keywords(&keywords); + let field_ty = field.ty(); + let lax_bool_field = if !keywords.strict.unwrap_or(dataclass.kws.strict) + && match &field_ty { + Type::ClassType(cls) => cls == self.stdlib.bool(), + Type::Union(union) => union + .members + .iter() + .any(|member| matches!(member, Type::ClassType(cls) if cls == self.stdlib.bool())), + _ => false, + } + { + Some(field_ty) + } else { + None }; + if range.is_none() && lax_bool_field.is_none() { + continue; + } let info = PydanticParamConstraint { field_name: field_name.clone(), - constraints: constraint, + range, + lax_bool_field, }; if keywords.init_by_name { constraints.insert(PydanticParamKey::Name(field_name), info.clone()); @@ -746,14 +764,58 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { range: TextRange, errors: &ErrorCollector, ) { + // A literal rejected by `bool` may still be valid for another member of the field union. + if let Some(field_ty) = &info.lax_bool_field + && !self.is_subset_eq(value_ty, field_ty) + { + let is_bool_string = |value: &str| { + matches!( + value.to_ascii_lowercase().as_str(), + "0" | "1" + | "f" + | "false" + | "n" + | "no" + | "off" + | "on" + | "t" + | "true" + | "y" + | "yes" + ) + }; + let invalid_bool = match value_ty { + Type::Literal(lit) => match &lit.value { + Lit::Int(value) => !matches!(value.as_i64(), Some(0 | 1)), + Lit::Str(value) => !is_bool_string(value), + _ => false, + }, + _ => false, + }; + if invalid_bool { + self.error( + errors, + range, + ErrorKind::BadArgumentType, + format!( + "Argument value `{}` is not valid for Pydantic `bool` field `{}`", + self.for_display(value_ty.clone()), + info.field_name + ), + ); + } + } + let Some(constraints) = &info.range else { + return; + }; 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()), + ("gt", constraints.gt.as_ref()), + ("ge", constraints.ge.as_ref()), + ("lt", constraints.lt.as_ref()), + ("le", constraints.le.as_ref()), ]; for (label, constraint_ty) in checks { let Some(constraint_ty) = constraint_ty else { diff --git a/pyrefly/lib/alt/class/pydantic_lax.rs b/pyrefly/lib/alt/class/pydantic_lax.rs index e4fc29e84e..734bac6fe7 100644 --- a/pyrefly/lib/alt/class/pydantic_lax.rs +++ b/pyrefly/lib/alt/class/pydantic_lax.rs @@ -244,10 +244,12 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { let expanded_members = self.expand_types(&f.members); self.unions(expanded_members) } - // Known atomic types with conversion tables, or Any for everything else - _ => self + // Pydantic validates class instances without coercing them. + Type::ClassType(_) => self .get_atomic_lax_conversion(ty) - .unwrap_or_else(|| self.heap.mk_any_explicit()), + .unwrap_or_else(|| ty.clone()), + // Runtime validation determines whether unsupported forms such as callables are valid. + _ => self.heap.mk_any_explicit(), } } diff --git a/pyrefly/lib/test/pydantic/strictness.rs b/pyrefly/lib/test/pydantic/strictness.rs index e2d954af58..6c48f36370 100644 --- a/pyrefly/lib/test/pydantic/strictness.rs +++ b/pyrefly/lib/test/pydantic/strictness.rs @@ -261,6 +261,39 @@ reveal_type(Model.__init__) # E: revealed type: (self: Model, *, y: Decimal | b "#, ); +pydantic_testcase!( + test_lax_mode_coercion_union_rejects_invalid_values, + r#" +from pydantic import BaseModel + +class Something(BaseModel): + pass + +class OtherThing(BaseModel): + pass + +class Model(BaseModel): + scalar: str | bool + model: Something | OtherThing + +Model(scalar=5, model=Something()) # E: Argument value `Literal[5]` is not valid for Pydantic `bool` field `scalar` +Model(scalar="value", model=5) # E: Argument `Literal[5]` is not assignable to parameter `model` + +class BoolModel(BaseModel): + value: bool + +BoolModel(value=0) +BoolModel(value="yes") +BoolModel(value=2) # E: Argument value `Literal[2]` is not valid for Pydantic `bool` field `value` +BoolModel(value="maybe") # E: Argument value `Literal['maybe']` is not valid for Pydantic `bool` field `value` + +class IntOrBoolModel(BaseModel): + value: int | bool + +IntOrBoolModel(value=5) + "#, +); + pydantic_testcase!( test_lax_mode_list_and_set_invariance, r#" diff --git a/website/docs/pydantic-lax-conversions.mdx b/website/docs/pydantic-lax-conversions.mdx index 0741284b43..e532e9a3e1 100644 --- a/website/docs/pydantic-lax-conversions.mdx +++ b/website/docs/pydantic-lax-conversions.mdx @@ -14,7 +14,7 @@ description: Complete reference of how Pyrefly converts types in Pydantic lax mo This page provides a complete reference for how Pyrefly converts types when working with Pydantic models in **lax mode** (the default). For background on how lax mode works, see the [main Pydantic documentation](../pydantic). -**Note:** Types without a specific conversion rule (e.g., `Callable`, `Any`, custom classes, and generic classes not listed below) are converted to `Any`. +**Note:** Custom class types without a specific conversion rule are left unchanged. Other unsupported forms, such as `Callable` and generic classes not listed below, are converted to `Any`. --- @@ -38,6 +38,8 @@ Named unions are used for atomic types to keep type signatures concise. | `UUID` | `LaxUuid` | `UUID \| str` | | `None` | (no conversion) | `None` | +For `bool` conversions, Pyrefly checks inferred integer and string literals against Pydantic's accepted values. Non-literal values use the broader input types shown above. + --- ## Compositional Type Conversions @@ -61,4 +63,3 @@ Named unions are used for atomic types to keep type signatures concise. - **Single-element containers and unbounded tuples:** Named unions are preserved. `list[int]` → `Iterable[LaxInt]` - **Concrete tuples:** Element types are expanded and flattened. `tuple[int, str]` → `Iterable[int | bool | float | str | bytes | bytearray | Decimal]` - **Dictionaries:** Only values are converted; keys remain unchanged. `dict[str, int]` → `Mapping[str, LaxInt]` -