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
25 changes: 22 additions & 3 deletions pyrefly/lib/alt/class/class_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2502,6 +2502,16 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
field_name: &Name,
field: &ClassField,
instance: &Instance,
) -> ClassAttribute {
self.as_instance_attribute_with_mode(field_name, field, instance, true)
}

fn as_instance_attribute_with_mode(
&self,
field_name: &Name,
field: &ClassField,
instance: &Instance,
expand_vars: bool,
) -> ClassAttribute {
// Special handling for `__new__`: because `__new__` is a static method, it can be called
// with a `cls` argument that differs from the class on which it is accessed, so we use a
Expand Down Expand Up @@ -2556,7 +2566,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
}
ClassFieldInner::Method { mut ty, .. } => {
// bind_instance matches on the type, so resolve it if we can
self.expand_vars_mut(&mut ty);
if expand_vars {
self.expand_vars_mut(&mut ty);
}
// If the field is a dunder or ClassVar[Callable] & the assigned value is a callable, we replace it with a named function
// so that it gets treated as a bound method.
//
Expand Down Expand Up @@ -2599,7 +2611,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
read_only_reason,
..
} => {
self.expand_vars_mut(&mut ty);
if expand_vars {
self.expand_vars_mut(&mut ty);
}
if is_classvar {
ClassAttribute::read_only(ty, ReadOnlyReason::ClassVar)
} else if let Some(reason) = read_only_reason {
Expand Down Expand Up @@ -3556,7 +3570,12 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
) -> Option<ClassAttribute> {
self.get_class_member(cls.class_object(), name)
.map(|field| {
self.as_instance_attribute(name, &field, &Instance::of_protocol(cls, self_type))
self.as_instance_attribute_with_mode(
name,
&field,
&Instance::of_protocol(cls, self_type),
false,
)
})
}

Expand Down
81 changes: 80 additions & 1 deletion pyrefly/lib/solver/solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,7 @@ impl Solver {
solver: self,
type_order,
gas: INITIAL_GAS,
union_widening: Vec::new(),
recursive_assumptions: SmallSet::new(),
class_protocol_assumptions: SmallSet::new(),
}
Expand Down Expand Up @@ -1432,6 +1433,7 @@ pub struct Subset<'a, Ans: LookupAnswer> {
pub(crate) solver: &'a Solver,
pub type_order: TypeOrder<'a, Ans>,
gas: Gas,
union_widening: Vec<SmallMap<Var, Quantified>>,
/// Recursive assumptions of pairs of types that is_subset_eq returns true for.
/// Used for structural typechecking of protocols and recursive type aliases.
pub recursive_assumptions: SmallSet<(Type, Type)>,
Expand All @@ -1444,6 +1446,53 @@ pub struct Subset<'a, Ans: LookupAnswer> {
}

impl<'a, Ans: LookupAnswer> Subset<'a, Ans> {
pub(super) fn collect_union_widening_vars(&self, ty: &Type) -> SmallMap<Var, Quantified> {
let mut vars = SmallMap::new();
if !ty.may_contain_quantified_var() {
return vars;
}
let candidates = ty.collect_maybe_quantified_vars();
if candidates.is_empty() {
return vars;
}
let variables = self.solver.variables.lock();
for var in candidates {
if vars.contains_key(&var) {
continue;
}
match &*variables.get(var) {
Variable::Quantified(q) | Variable::PartialQuantified(q) => {
if q.is_type_var() {
vars.insert(var, q.clone());
}
}
_ => {}
}
}
vars
}

pub(super) fn with_union_widening<R>(
&mut self,
vars: SmallMap<Var, Quantified>,
f: impl FnOnce(&mut Self) -> Result<R, SubsetError>,
) -> Result<R, SubsetError> {
if vars.is_empty() {
return f(self);
}
self.union_widening.push(vars);
let res = f(self);
self.union_widening.pop();
res
}

pub(super) fn union_widening_quantified(&self, var: Var) -> Option<Quantified> {
self.union_widening
.iter()
.rev()
.find_map(|vars| vars.get(&var).cloned())
}

pub fn is_equal(&mut self, got: &Type, want: &Type) -> Result<(), SubsetError> {
self.is_subset_eq(got, want)?;
self.is_subset_eq(want, got)
Expand Down Expand Up @@ -1724,7 +1773,37 @@ impl<'a, Ans: LookupAnswer> Subset<'a, Ans> {
let t2 = t2.clone();
drop(v2_ref);
drop(variables);
self.is_subset_eq(t1, &t2)
match self.is_subset_eq(t1, &t2) {
Ok(()) => Ok(()),
Err(err) => match self.union_widening_quantified(*v2) {
Some(q) => {
let t1_p = t1
.clone()
.promote_implicit_literals(self.type_order.stdlib());
let bound = q
.restriction()
.as_type(self.type_order.stdlib(), &self.solver.heap);
if let Err(err_p) = self.is_subset_eq(&t1_p, &bound) {
self.solver.instantiation_errors.write().insert(
*v2,
TypeVarSpecializationError {
name: q.name.clone(),
got: t1_p.clone(),
want: bound,
error: err_p,
},
);
}
let widened = unions(vec![t2, t1_p], &self.solver.heap);
self.solver
.variables
.lock()
.update(*v2, Variable::Answer(widened));
Ok(())
}
None => Err(err),
},
Comment on lines +1776 to +1805
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

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

In the union-widening path for Variable::Answer(t2), any Err(err) from is_subset_eq(t1, &t2) is swallowed and converted into a widening update + Ok(()). This can accidentally mask non-subtyping failures (e.g. SubsetError::InternalError, TensorShape, protocol/attribute errors, or gas exhaustion), making the solver report success when it should surface the error. Consider only widening for “ordinary mismatch” errors (likely SubsetError::Other, possibly a small allowlist) and propagating structured errors unchanged.

Copilot uses AI. Check for mistakes.
}
}
Variable::Quantified(q) | Variable::PartialQuantified(q) => {
let t1_p = t1
Expand Down
6 changes: 5 additions & 1 deletion pyrefly/lib/solver/subset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1209,7 +1209,11 @@ impl<'a, Ans: LookupAnswer> Subset<'a, Ans> {
}
(Type::Intersect(l), u) => any(l.0.iter(), |l| self.is_subset_eq(l, u)),
(Type::Union(box Union { members: ls, .. }), u) => {
all(ls.iter(), |l| self.is_subset_eq(l, u))
// Allow unbound type variables on the RHS to widen across union members.
let widen_vars = self.collect_union_widening_vars(u);
self.with_union_widening(widen_vars, |subset| {
all(ls.iter(), |l| subset.is_subset_eq(l, u))
})
}
// Size <: Size - expand bound Vars, canonicalize, and compare for structural equality
(Type::Size(s1), Type::Size(s2)) => {
Expand Down
10 changes: 10 additions & 0 deletions pyrefly/lib/test/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ force_error(f(1, None)) # E: Argument `tuple[int, @_]` is not assignable to par
"#,
);

testcase!(
test_abs_union_supports_abs,
r#"
from typing import assert_type

def f(x: bool | int | float | complex) -> None:
assert_type(abs(x), int | float)
"#,
);

testcase!(
test_self_type_subst,
r#"
Expand Down