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
14 changes: 13 additions & 1 deletion crates/pyrefly_types/src/type_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,19 @@ fn join_types_impl(
base_ty
} else {
let joined_ty = union_types(types);
if is_subset_eq(&base_ty, &joined_ty) {
// If `base_ty` is a subset of a single member of the join, that member is at least

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This doesn't hold for types that contain Any. For example, the PR produces a false positive on this code:

from typing import Any
blocked: list[str] = []
def takes_int(x: int) -> None: ...
def f(x: int | Any | None) -> None:
   if x is None or x in blocked:
       return
   takes_int(x)  # PR: "Argument `int | str | Any` is not assignable to `int`"; base: no error

I think the easiest fix is to just exclude those types - i.e., make within_one_member require that !base_ty.any(Type::is_any) and same for joined_ty.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, good catch! Fixed in 4a3c0a4 as you suggested: within_one_member now requires !base_ty.any(Type::is_any) and !joined_ty.any(Type::is_any). With either one containing Any, we fall back to the old simplification.

I added your example to test_narrow_or_to_class_extending_any. On the previous commit it reports Argument int | str | Any is not assignable to parameter x with type int; now it's clean, and the rest of the test is unchanged.

pyrefly --lib gives 10204 passed and pyrefly_types gives 338 passed. Most of the earlier mypy_primer churn was in Any-related diagnostics, so I'll check the new primer run once it posts. GitHub doesn't let me re-request review, so I'm pinging you here: @rchen152

// as precise, so keep the join. This matters when the subset check is vacuous:
// `type[object]` is assignable to `type[C]` if `C` extends `Any`. It doesn't hold
// for types containing `Any`, which every type is a subset of.
let within_one_member = || {
!base_ty.any(Type::is_any)
&& !joined_ty.any(Type::is_any)
&& match &joined_ty {
Type::Union(u) => u.members.iter().any(|ty| is_subset_eq(&base_ty, ty)),
_ => true,
}
};
if is_subset_eq(&base_ty, &joined_ty) && !within_one_member() {
base_ty
} else {
joined_ty
Expand Down
71 changes: 71 additions & 0 deletions pyrefly/lib/test/narrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5272,6 +5272,77 @@ def preserve_facets_and_subtypes(x: A, flag: bool) -> None:
"#,
);

// https://github.com/facebook/pyrefly/issues/5546
testcase!(
test_narrow_or_to_class_extending_any,
r#"
from typing import Any, TypeGuard, reveal_type

class C(Any):
pass

def has(cls: type) -> TypeGuard[type[C]]:
return hasattr(cls, "tag")

def has2(cls: type) -> TypeGuard[type[C]]:
return hasattr(cls, "tag")

def is_c(x: object) -> TypeGuard[C]:
return True

def repeated_guard(t: type[object]) -> None:
if has(t) or has(t):
reveal_type(t) # E: revealed type: type[C]
t.tag

def different_guards(t: type[object]) -> None:
if has(t) or has2(t):
reveal_type(t) # E: revealed type: type[C]

def instance_guard(x: object) -> None:
if is_c(x) or is_c(x):
reveal_type(x) # E: revealed type: C

def one_branch_does_not_narrow(t: type[object]) -> None:
if has(t) or isinstance(t, type):
reveal_type(t) # E: revealed type: type[object]

def gradual_base(x) -> None:
if isinstance(x, int) or isinstance(x, str):
reveal_type(x) # E: revealed type: int | str

class Holder:
t: type[object]

def attribute(o: Holder) -> None:
if has(o.t) or has(o.t):
reveal_type(o.t) # E: revealed type: type[C]

def control_flow_merge(t: type[object], flag: bool) -> None:
if flag:
if not has(t):
return
else:
if not has2(t):
return
reveal_type(t) # E: revealed type: type[C]

def covering_branches(x: bool) -> None:
# The branches are `Literal[True]` and `Literal[False]`, which together cover `bool`.
if x or not x:
reveal_type(x) # E: revealed type: bool

blocked: list[str] = []

def takes_int(x: int) -> None: ...

def base_contains_any(x: int | Any | None) -> None:
if x is None or x in blocked:
return
takes_int(x)
"#,
);

// https://github.com/facebook/pyrefly/issues/5147
testcase!(
test_isinstance_union_with_typevar_classinfo,
Expand Down
Loading