Skip to content
Closed
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
9 changes: 9 additions & 0 deletions pyrefly/lib/alt/narrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1768,6 +1768,10 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> {
// too conservative and prone to false positives, see
// https://github.com/facebook/pyrefly/issues/911
self.heap.mk_callable_ellipsis(self.heap.mk_any_implicit())
} else if let Some(q) = self.solver().partial_quantified(&t) {
// An uninferred TypeVar provides its upper bound as positive evidence.
// Do not infer it from the subject during the intersection check.
q.upper_bound(self.stdlib, self.heap)
} else {
*t
};
Expand Down Expand Up @@ -1795,6 +1799,11 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> {
)
.ty;
if let Type::TypeIs(t) = ret {
if self.solver().partial_quantified(&t).is_some() {
// The unknown type may be narrower than its bound, so a failed
// check cannot exclude every value assignable to that bound.
return ty.clone();
}
return self.subtract(ty, &t);
}
}
Expand Down
11 changes: 11 additions & 0 deletions pyrefly/lib/solver/solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,17 @@ impl Solver {
}
}

/// Return the type parameter for a variable awaiting first-use inference.
pub fn partial_quantified(&self, ty: &Type) -> Option<Quantified> {
if let Type::Var(v) = ty {
let variables = self.variables.lock();
if let Variable::PartialQuantified(q) = &*variables.get(*v) {
return Some(q.clone());
}
}
None
}

/// Only an unsolved quantified var can hold what a branch implies, since finishing the call
/// is what turns those into answers.
pub(crate) fn var_is_quantified(&self, var: Var) -> bool {
Expand Down
74 changes: 74 additions & 0 deletions pyrefly/lib/test/narrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,80 @@ def f(x: Cat | Dog):
"#,
);

testcase!(
test_typeis_free_typevar_bound,
r#"
from typing import TypeIs, TypeVar, assert_type

T = TypeVar("T", bound=int)

def is_t(x: object) -> TypeIs[T]: ...

def caller(x: int | str | None) -> None:
if is_t(x):
assert_type(x, int)
else:
assert_type(x, int | str | None)

def caller_object(x: object) -> None:
if is_t(x):
assert_type(x, int)

def caller_subtype(x: bool | str) -> None:
if is_t(x):
assert_type(x, bool)
else:
assert_type(x, bool | str)
"#,
);

testcase!(
test_typeis_free_typevar_restrictions,
r#"
from typing import TypeIs, assert_type

def is_bounded[T: int](x: object) -> TypeIs[T]: ...
def is_constrained[T: (int, str)](x: object) -> TypeIs[T]: ...
def is_unbounded[T](x: object) -> TypeIs[T]: ...
def is_defaulted[T: int = bool](x: object) -> TypeIs[T]: ...

def caller(x: int | str | None) -> None:
if is_bounded(x):
assert_type(x, int)
if is_constrained(x):
assert_type(x, int | str)
else:
assert_type(x, int | str | None)
if is_unbounded(x):
assert_type(x, int | str | None)
else:
assert_type(x, int | str | None)
if is_defaulted(x):
assert_type(x, bool)
else:
assert_type(x, int | str | None)
"#,
);

testcase!(
test_typeis_inferred_typevar_bound,
r#"
from typing import TypeIs, assert_type

def is_t[T: int](x: object, target: type[T]) -> TypeIs[T]: ...

def caller(x: bool | str) -> None:
if is_t(x, bool):
assert_type(x, bool)
else:
assert_type(x, str)

def caller_generic[T: int](x: object, target: type[T]) -> None:
if is_t(x, target):
assert_type(x, T)
"#,
);

testcase!(
test_typeis_union,
r#"
Expand Down
Loading