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
48 changes: 13 additions & 35 deletions conformance/third_party/conformance.exp
Original file line number Diff line number Diff line change
Expand Up @@ -5412,6 +5412,17 @@
"stop_column": 16,
"stop_line": 41
},
{
"code": -2,
"column": 15,
"concise_description": "Argument `bytes` is not assignable to parameter `y` with type `str` in function `concat`",
"description": "Argument `bytes` is not assignable to parameter `y` with type `str` in function `concat`",
"line": 44,
"name": "bad-argument-type",
"severity": "error",
"stop_column": 16,
"stop_line": 44
},
{
"code": -2,
"column": 18,
Expand All @@ -5434,44 +5445,11 @@
"stop_column": 60,
"stop_line": 55
},
{
"code": -2,
"column": 16,
"concise_description": "assert_type(MyStr, str) failed",
"description": "assert_type(MyStr, str) failed",
"line": 67,
"name": "assert-type",
"severity": "error",
"stop_column": 35,
"stop_line": 67
},
{
"code": -2,
"column": 16,
"concise_description": "assert_type(MyStr, str) failed",
"description": "assert_type(MyStr, str) failed",
"line": 68,
"name": "assert-type",
"severity": "error",
"stop_column": 35,
"stop_line": 68
},
{
"code": -2,
"column": 27,
"concise_description": "Argument `str` is not assignable to parameter `y` with type `MyStr` in function `concat`",
"description": "Argument `str` is not assignable to parameter `y` with type `MyStr` in function `concat`",
"line": 68,
"name": "bad-argument-type",
"severity": "error",
"stop_column": 28,
"stop_line": 68
},
{
"code": -2,
"column": 15,
"concise_description": "Argument `bytes` is not assignable to parameter `y` with type `MyStr` in function `concat`",
"description": "Argument `bytes` is not assignable to parameter `y` with type `MyStr` in function `concat`",
"concise_description": "Argument `bytes` is not assignable to parameter `y` with type `str` in function `concat`",
"description": "Argument `bytes` is not assignable to parameter `y` with type `str` in function `concat`",
"line": 69,
"name": "bad-argument-type",
"severity": "error",
Expand Down
3 changes: 1 addition & 2 deletions conformance/third_party/conformance.result
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@
"generics_base_class.py": [],
"generics_basic.py": [
"Line 34: Unexpected errors ['`+` is not supported between `AnyStr` and `AnyStr`\\n Argument `AnyStr` is not assignable to parameter `value` with type `Buffer` in function `bytes.__add__`\\n Protocol `Buffer` requires attribute `__buffer__`', '`+` is not supported between `AnyStr` and `AnyStr`\\n No matching overload found for function `str.__add__` called with arguments: (AnyStr)\\n Possible overloads:\\n (value: LiteralString, /) -> LiteralString\\n (value: str, /) -> str [closest match]']",
"Line 67: Unexpected errors ['assert_type(MyStr, str) failed']",
"Line 68: Unexpected errors ['assert_type(MyStr, str) failed', 'Argument `str` is not assignable to parameter `y` with type `MyStr` in function `concat`']"
"Line 44: Unexpected errors ['Argument `bytes` is not assignable to parameter `y` with type `str` in function `concat`']"
],
"generics_defaults.py": [],
"generics_defaults_referential.py": [],
Expand Down
4 changes: 2 additions & 2 deletions conformance/third_party/results.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"pass": 122,
"fail": 16,
"pass_rate": 0.88,
"differences": 43,
"differences": 42,
"passing": [
"aliases_explicit.py",
"aliases_newtype.py",
Expand Down Expand Up @@ -137,7 +137,7 @@
"dataclasses_descriptors.py": 6,
"dataclasses_slots.py": 2,
"exceptions_context_managers.py": 2,
"generics_basic.py": 3,
"generics_basic.py": 2,
"generics_paramspec_components.py": 1,
"generics_scoping.py": 4,
"generics_self_basic.py": 2,
Expand Down
150 changes: 119 additions & 31 deletions pyrefly/lib/solver/solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use pyrefly_types::simplify::intersect;
use pyrefly_types::special_form::SpecialForm;
use pyrefly_types::tensor::TensorShape;
use pyrefly_types::tuple::Tuple;
use pyrefly_types::type_var::Restriction;
use pyrefly_types::types::TArgs;
use pyrefly_types::types::Union;
use pyrefly_util::gas::Gas;
Expand Down Expand Up @@ -1500,6 +1501,34 @@ impl<'a, Ans: LookupAnswer> Subset<'a, Ans> {
res
}

/// For a constrained TypeVar, find the narrowest constraint that `ty` is assignable to.
///
/// Per the typing spec, a constrained TypeVar (`T = TypeVar("T", int, str)`) must resolve
/// to exactly one of its constraint types — never a subtype like `bool` or `Literal[42]`.
/// This method finds the best (narrowest) matching constraint by checking assignability
/// and preferring the most specific constraint when multiple match.
fn find_matching_constraint<'c>(
&mut self,
ty: &Type,
constraints: &'c [Type],
) -> Option<&'c Type> {
let matching: Vec<&Type> = constraints
.iter()
.filter(|c| self.is_subset_eq(ty, c).is_ok())
.collect();
if matching.is_empty() {
return None;
}
// Pick the narrowest matching constraint: the one that is a subtype of all others.
let mut best = matching[0];
for &candidate in &matching[1..] {
if self.is_subset_eq(candidate, best).is_ok() {
best = candidate;
}
}
Some(best)
}

/// Implementation of Var subset cases, calling onward to solve non-Var cases.
///
/// This function does two things: it checks that got <: want, and it solves free variables assuming that
Expand Down Expand Up @@ -1665,23 +1694,48 @@ impl<'a, Ans: LookupAnswer> Subset<'a, Ans> {
}
Variable::Quantified(q) | Variable::PartialQuantified(q) => {
let name = q.name.clone();
let bound = q
.restriction()
.as_type(self.type_order.stdlib(), &self.solver.heap);
let restriction = q.restriction().clone();
let bound =
restriction.as_type(self.type_order.stdlib(), &self.solver.heap);
drop(v1_ref);
variables.update(*v1, Variable::Answer(t2.clone()));
drop(variables);

if let Err(e) = self.is_subset_eq(t2, &bound) {
self.solver.instantiation_errors.write().insert(
*v1,
TypeVarSpecializationError {
name,
got: t2.clone(),
want: bound,
error: e,
},
);
// For constrained TypeVars, promote to the matching constraint type
// rather than pinning to the raw argument type.
if let Restriction::Constraints(ref constraints) = restriction {
variables.update(*v1, Variable::Answer(t2.clone()));
drop(variables);
if let Some(constraint) = self.find_matching_constraint(t2, constraints)
{
let constraint = constraint.clone();
self.solver
.variables
.lock()
.update(*v1, Variable::Answer(constraint));
} else {
self.solver.instantiation_errors.write().insert(
*v1,
TypeVarSpecializationError {
name,
got: t2.clone(),
want: bound,
error: SubsetError::Other,
},
);
}
} else {
variables.update(*v1, Variable::Answer(t2.clone()));
drop(variables);
if let Err(e) = self.is_subset_eq(t2, &bound) {
self.solver.instantiation_errors.write().insert(
*v1,
TypeVarSpecializationError {
name,
got: t2.clone(),
want: bound,
error: e,
},
);
}
}
Ok(())
}
Expand Down Expand Up @@ -1761,36 +1815,70 @@ impl<'a, Ans: LookupAnswer> Subset<'a, Ans> {
.clone()
.promote_implicit_literals(self.type_order.stdlib());
let name = q.name.clone();
let bound = q
.restriction()
.as_type(self.type_order.stdlib(), &self.solver.heap);
let restriction = q.restriction().clone();
let bound =
restriction.as_type(self.type_order.stdlib(), &self.solver.heap);
drop(v2_ref);
variables.update(*v2, Variable::Answer(t1_p.clone()));
drop(variables);

if let Err(err_p) = self.is_subset_eq(&t1_p, &bound) {
// If the promoted type fails, try again with the original type, in case the bound itself is literal.
// This could be more optimized, but errors are rare, so this code path should not be hot.
self.solver
.variables
.lock()
.update(*v2, Variable::Answer(t1.clone()));
if self.is_subset_eq(t1, &bound).is_err() {
// If the original type is also an error, use the promoted type.
// For constrained TypeVars, promote to the matching constraint type.
if let Restriction::Constraints(ref constraints) = restriction {
variables.update(*v2, Variable::Answer(t1_p.clone()));
drop(variables);
// Try promoted type first, then fall back to original (for literal bounds).
if let Some(constraint) =
self.find_matching_constraint(&t1_p, constraints)
{
let constraint = constraint.clone();
self.solver
.variables
.lock()
.update(*v2, Variable::Answer(t1_p.clone()));
.update(*v2, Variable::Answer(constraint));
} else if let Some(constraint) =
self.find_matching_constraint(t1, constraints)
{
let constraint = constraint.clone();
self.solver
.variables
.lock()
.update(*v2, Variable::Answer(constraint));
} else {
self.solver.instantiation_errors.write().insert(
*v2,
TypeVarSpecializationError {
name,
got: t1_p.clone(),
want: bound,
error: err_p,
error: SubsetError::Other,
},
);
}
} else {
variables.update(*v2, Variable::Answer(t1_p.clone()));
drop(variables);
if let Err(err_p) = self.is_subset_eq(&t1_p, &bound) {
// If the promoted type fails, try again with the original type, in case the bound itself is literal.
// This could be more optimized, but errors are rare, so this code path should not be hot.
self.solver
.variables
.lock()
.update(*v2, Variable::Answer(t1.clone()));
if self.is_subset_eq(t1, &bound).is_err() {
// If the original type is also an error, use the promoted type.
self.solver
.variables
.lock()
.update(*v2, Variable::Answer(t1_p.clone()));
self.solver.instantiation_errors.write().insert(
*v2,
TypeVarSpecializationError {
name,
got: t1_p.clone(),
want: bound,
error: err_p,
},
);
}
}
}
Ok(())
}
Expand Down
6 changes: 3 additions & 3 deletions pyrefly/lib/test/generic_basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ class F(Generic[_b]):
);

testcase!(
bug = "conformance: Constrained TypeVar with subtype should resolve to constraint, not subtype",
bug = "Operator dispatch does not expand per constraint",
test_constrained_typevar_subtype_resolves_to_constraint,
r#"
from typing import TypeVar, assert_type
Expand All @@ -323,8 +323,8 @@ def concat(x: AnyStr, y: AnyStr) -> AnyStr:
class MyStr(str): ...

def test(m: MyStr, s: str):
assert_type(concat(m, m), str) # E: assert_type(MyStr, str) failed
assert_type(concat(m, s), str) # E: assert_type(MyStr, str) failed # E: Argument `str` is not assignable to parameter `y` with type `MyStr`
assert_type(concat(m, m), str)
assert_type(concat(m, s), str)
"#,
);

Expand Down
67 changes: 67 additions & 0 deletions pyrefly/lib/test/generic_restrictions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,73 @@ assert_type(g("bar"), Literal["bar"])
"#,
);

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

def f[T: (int, str)](x: T) -> T: ...

# bool is a subtype of int, so T should resolve to int (the constraint), not bool.
assert_type(f(True), int)
assert_type(f(False), int)
"#,
);

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

def f[T: (int, str)](x: T) -> T: ...

# Literal[42] is a subtype of int, so T should resolve to int.
assert_type(f(42), int)
"#,
);

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

def f[T: (int, str)](x: T) -> T: ...

# Literal["hi"] is a subtype of str, so T should resolve to str.
assert_type(f("hi"), str)
"#,
);

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

class B: ...
class C(B): ...
class D(C): ...

def f[T: (B, C)](x: T) -> T: ...

# D is a subtype of C (and B), so T should resolve to C (the narrowest constraint).
assert_type(f(D()), C)
# B matches B exactly.
assert_type(f(B()), B)
"#,
);

testcase!(
test_constraint_promotion_no_match,
r#"
class X: ...

def f[T: (int, str)](x: T) -> T: ...

# X is not assignable to int or str, so this should error.
f(X()) # E: `X` is not assignable to upper bound `int | str` of type variable `T`
"#,
);

testcase!(
bug = "This should succeed with no errors",
test_add_with_constraints,
Expand Down