Skip to content

Narrow tuple elements after any()/all() None checks - #5484

Closed
1-DARK wants to merge 2 commits into
meta:mainfrom
1-DARK:fix-5434-any-none-tuple-narrowing
Closed

1-DARK wants to merge 2 commits into
meta:mainfrom
1-DARK:fix-5434-any-none-tuple-narrowing

Conversation

@1-DARK

@1-DARK 1-DARK commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #5434

Problem

Pyrefly did not narrow a tuple after a guard like:

values = (first, second)
if any(v is None for v in values):
    raise ValueError("missing value")
return values  # tuple[int | None, str | None] -> bad-return

Change

Recognize any(x is None for x in S) and all(x is not None for x in S), where the generator is single, unconditional and synchronous. Two new narrow ops (AnyElementIsNone / NoElementIsNone, each other's negation) are produced in the binding step. When no element can be None, the solver strips None from each element of concrete tuples (including within unions). The "some element is None" branch does not narrow.

any and all are added as SpecialExports so shadowed names are not matched.

Review feedback addressed

  • Unsound builtin detection: a name merged from branches (e.g. check = any vs. a local def check) was treated as the builtin because Binding::Phi ignored non-special branches. A merged special export is now recognized only when every branch resolves to the same export. Added tests for mixed any/all aliases and direct shadowing.
  • Impossible tuple members: a concrete tuple with an element that is always None now narrows to Never instead of tuple[Never, ...]. Added tests for any's false branch and all's true branch with differing element types and lengths.
  • Structure: the call shape (single unconditional generator) is checked before resolving the special export, and the matcher returns the iterable together with the op, removing the unreachable!.
  • Variables: the new ops are accepted by op_is_still_valid, so ok = all(v is not None for v in values); if ok: ... narrows.

Limitations

  • Only fixed-length concrete tuples are narrowed (not lists or tuple[T, ...]).
  • Only is None / is not None checks are handled.

Testing

  • New tests in pyrefly/lib/test/narrow.rs: the original guard, impossible-member pruning, alias/shadowing negatives, and narrowing through a variable.
  • All 518 narrow tests pass; the format/lint step of test.py is clean.
  • A full debug-build cargo test -p pyrefly --lib aborts with a stack overflow after test::cycles; this also happens on unmodified main. The wider run excluding cycles was not confirmed locally; CI is authoritative.
  • mypy_primer (run on the first commit) reported no change in type check results; it should be re-run on the new commit.

Checklist

  • I am a human, and I fully understand the code I am submitting.

Pyrefly did not narrow a tuple after a guard such as
`if any(v is None for v in values): raise`, so each element stayed
optional.

Recognize `any(x is None for x in S)` and `all(x is not None for x in S)`
and, when no element can be None, strip None from each element of
concrete tuples.

Fixes meta#5434
@meta-cla meta-cla Bot added the cla signed label Nov 2, 2026
@github-actions github-actions Bot added the size/m label Nov 2, 2026
@github-actions

This comment has been minimized.

@meta-codesync

meta-codesync Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

This pull request has been imported. If you are a Meta employee, you can view this in D129043234. (Because this pull request was imported automatically, there will not be any future comments.)

@stroxler stroxler left a comment

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.

Thanks!

AI review flagged that this looks like it is narrowing a little too aggressively, there are some soundness issues we need to address before we merge. And there's also a suggestion in here to change the structure of the code a bit for maintainability:

Conditional builtin/custom aliases narrow unsoundly

Location: binding/narrow.rs:1260–1267.

The new matcher treats as_special_export as proof that the callee is builtin any or all. Its shared Binding::Phi handling in binding/bindings.rs:1865–1882 ignores branches that return no special export. A callable that is builtin on one reachable branch and an ordinary function on another is therefore incorrectly recognized as definitely builtin.

from typing import Iterable, assert_type

def f(values: tuple[int | None, str | None], use_builtin: bool) -> tuple[int, str]:
    if use_builtin:
        check = any
    else:
        def check(it: Iterable[bool]) -> bool:
            return False
    if check(v is None for v in values):
        raise ValueError()
    assert_type(values, tuple[int, str])
    return values

Observed: the PR binary, with --preset default, reports 0 errors for this reproducer, including the assert_type. CPython nevertheless returns (None, None) for f((None, None), False). The exact base binary reports the necessary bad-return for the same mixed-callee pattern. A conditional reassignment of an initially builtin alias exhibits the same regression.

The shared alias-resolution defect predates this PR, but registering Any/All exposes it to the new narrowing and suppresses a correct diagnostic.

Fix: in a small prerequisite change, recognize a merged special export only when every reachable branch resolves to the same special export. A non-special/unresolved branch must reject recognition. Add negative tests for mixed builtin/custom aliases for both any and all, as well as direct shadowing.

Impossible tuple union members remain as tuple[Never, …]

Location: alt/narrow.rs:1291–1295.

The helper removes None elementwise, but always reconstructs the tuple. If an element is definitely None, the result is an impossible tuple[Never, …] rather than Never for the entire union member.

def f(values: tuple[None, str] | tuple[int, int]) -> tuple[int, int]:
    if any(v is None for v in values):
        raise ValueError()
    return values

Observed: the PR binary reports:

Returned type `tuple[int, int] | tuple[Never, str]` is not assignable
  to declared return type `tuple[int, int]` [bad-return]

Only tuple[int, int] can reach the return, so the diagnostic is false. The impossible member is not removed downstream; its other element can still fail assignability. Different-length alternatives likewise fail the tuple arity check.

This is incomplete narrowing within the stated concrete-tuple/union scope, not a new diagnostic regression: the base also rejects the example because it does not perform this narrowing at all.

Fix: if any required element narrows to Never, return Never for that concrete tuple member. Preserve the empty tuple. Add tests for both any's false branch and all's true branch, with differing element types and lengths.

Structural improvements we could make

  • Check the cheap single-generator argument shape before resolving the special export. The new guard currently adds another scope/alias lookup to ordinary conditions such as if ordinary(x):; the extra work is visible in the code, but its latency impact was not benchmarked.
  • Return the iterable together with the operation from the matcher. This avoids re-destructuring the arguments under unreachable! and keeps ownership of the structural invariant in one place.
  • The new operations are not accepted by op_is_still_valid, so ok = all(v is not None for v in values); if ok: ... does not narrow. This is a nonblocking integration gap, not a regression; concrete tuple elements are immutable, so the existing reassignment check can support it.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

Resolve a special export through a merged name only when every branch
resolves to the same export, so a conditionally rebound `any`/`all`
is not treated as the builtin.

Turn a concrete tuple with an always-None element into Never instead of
tuple[Never, ...]. Match the any/all generator shape before resolving
the callee, return the iterable with the op, and let the ops survive
through a variable (`ok = all(...)`).

@mangeshraut712 mangeshraut712 left a comment

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.

The handling of Phi branches in special_export_from_binding_idx using ? ensures that any branch not resolving to the expected special export safely fails early rather than being ignored.

Also verified that dropping impossible concrete tuple members in narrow_tuple_elements_not_none (returning mk_never() when any element narrows to Never) correctly eliminates incompatible union variants instead of leaving uninstantiable tuple[Never, ...] types.

A couple of minor edge case observations:

  1. element_none_check matches &operands[0] as Expr::Name and &operands[1] as Expr::NoneLiteral(_). This covers idiomatic v is None / v is not None, though inverted comparisons like None is v or None is not v will safely fall through un-narrowed.
  2. Invocations using a list comprehension like any([v is None for v in values]) are excluded since only Expr::Generator is matched. Scoping to generator expressions first makes sense to keep the generator AST traversal simple, but list comprehensions are fairly common in Python codebases and could be considered for a follow-up.
  3. Adding a check for arguments.keywords.is_empty() in element_none_check would ensure any malformed call with unexpected kwargs fails to match.

@rchen152

rchen152 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

@mangeshraut712 I appreciate the enthusiasm, but I'd ask you to please hold off on unsolicited reviews to avoid causing confusion.

@stroxler stroxler left a comment

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.

Review automatically exported from Phabricator review in Meta.

@meta-codesync

meta-codesync Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

This pull request has been merged in 4ec2a46.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tuple elements remain optional after an any() None check

4 participants