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
2 changes: 1 addition & 1 deletion pyrefly/lib/alt/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
82 changes: 72 additions & 10 deletions pyrefly/lib/alt/class/pydantic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ impl PydanticRangeConstraints {
#[derive(Clone)]
struct PydanticParamConstraint {
field_name: Name,
constraints: PydanticRangeConstraints,
range: Option<PydanticRangeConstraints>,
lax_bool_field: Option<Type>,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Expand All @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions pyrefly/lib/alt/class/pydantic_lax.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down
33 changes: 33 additions & 0 deletions pyrefly/lib/test/pydantic/strictness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
5 changes: 3 additions & 2 deletions website/docs/pydantic-lax-conversions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand All @@ -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
Expand All @@ -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]`

Loading