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
27 changes: 17 additions & 10 deletions pyrefly/lib/alt/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1073,16 +1073,23 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
let mut t_acc = Type::never();
let last_index = values.len() - 1;
for (i, value) in values.iter().enumerate() {
// If there isn't a hint for the overall expression, use the preceding branches as a "soft" hint
// for the next one. Most useful for expressions like `optional_list or []`.
let hint = hint.or_else(|| {
if t_acc.is_never() {
None
} else {
Some(HintRef::soft(&t_acc))
}
});
let mut t = self.expr_infer_with_hint(value, hint, errors);
let operand_hint = if matches!(op, BoolOp::Or) && matches!(value, Expr::Call(_)) {
Copy link
Contributor

@stroxler stroxler Nov 21, 2025

Choose a reason for hiding this comment

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

I'm not convinced this is specific to or; for example if we had

x: None | bool | str

and then

x = (not x) and get_value("default")

then we probably should be able to narrow x to bool | str, but the existing logic would pass down the hint and we'd get None | bool | str.


More generally, I think what might really be going on is that return types shouldn't be used contextually in the way they are now, because (using your test case as an example)

config = get_value("default")

really ought to result in config being narrowed to str, and it's not even in a bool op - I think the real issue is that we should only be using context when it's necessary to make the assignment legal; in cases where we can get a narrower type we want the narrower type

cc @samwgoldman for thoughts, it's unclear to me how hard this would be to do. We might be able to just analyze the function twice when necessary, similar to how we handle overloads

Copy link
Member

Choose a reason for hiding this comment

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

Is this just #881? Agree that the way we contextually type calls to generic functions (and ctors) is wrong. My plan was to treat these hints differently, but I only have a sketch of an idea.

If this PR is working around a specific instance of 881 I think we should probably work on the underlying issue instead.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, yeah I forgot about #881, I think this probably is just another case of it.

After giving it more thought, it seems like if the constraint solver understood that a return type hint is only an upper bound (and should not affect the result unless necessary) then we'd get the right answer.

I'm guessing that's at least roughly what your idea is?

I do think it's likely possible to use two attempts at solving the call to get this behavior, similar to overloads. But assuming the solver can do it natively in one pass that seems better

// Drop the hint for function calls in 'OR' expressions.
// This prevents the expected type (e.g. Optional[str]) from improperly influencing
// a generic function's inference (forcing it to return Optional[str]
// instead of str), while still preserving hints for literals like [].
None
} else {
// Use external hint, or fall back to previous branches
hint.or_else(|| {
if t_acc.is_never() {
None
} else {
Some(HintRef::soft(&t_acc))
}
})
};
let mut t = self.expr_infer_with_hint(value, operand_hint, errors);
self.expand_vars_mut(&mut t);
// If this is not the last entry, we have to make a type-dependent decision and also narrow the
// result; both operations require us to force `Var` first or they become unpredictable.
Expand Down
30 changes: 30 additions & 0 deletions pyrefly/lib/test/operators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,3 +681,33 @@ def f[S, T](x: type[S], y: type[T]):
return x == y
"#,
);

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

T = TypeVar("T")

def get_value(default: T) -> T:
return default

def test(config: str | None):
# 1. Setup: config is explicitly set to None.
# The variable 'config' still carries the type hint 'str | None'.
config = None

# 2. The Test:
# We reassign to 'config' using an OR expression with a generic function call.
#
# Without the fix, the 'str | None' hint from the variable definition flows
# into 'get_value', causing it to infer T='str | None' instead of T='str'.
#
# With the fix, the hint is dropped for the function call, so it infers
# T='str' purely from the argument "default".
config = config or get_value("default")

# Since left side was None (discarded) and right side is str, result must be str.
assert_type(config, str)
"#,
);