Skip to content

Stop reporting an implicit Any the expectation already absorbed - #4409

Open
IBlackVoid wants to merge 1 commit into
facebook:mainfrom
IBlackVoid:fix-4301-absorbed-implicit-any
Open

Stop reporting an implicit Any the expectation already absorbed#4409
IBlackVoid wants to merge 1 commit into
facebook:mainfrom
IBlackVoid:fix-4301-absorbed-implicit-any

Conversation

@IBlackVoid

Copy link
Copy Markdown

Fixes #4301.

response["meta"] = {"time_to_live": None} on a dict[str, Any] reported
implicit-any-empty-container, warning that a placeholder would leak out as
Any when the annotation had already accounted for it.

Where the report comes from

Not from checking the assignment. implicit-any-empty-container is raised by
sanitize_answer_vars when an unpinned PartialContained var survives into a
binding's answer — which is why the ErrorStyle::Never collector on the
subscript path does not suppress it.

Two independent paths were leaving one behind:

  1. The subscript re-inference. __setitem__ is called and checks the value
    against its parameter contextually — that part is correct. The code then
    infers the same expression a second time, bare, purely to produce the type
    the subscript narrows to, minting a fresh placeholder nothing ever solves.

  2. Checking against Any never pins. expr_with_options skips the check
    when the expectation is Any, correctly, since it always succeeds — but
    succeeding trivially does not solve the placeholder either. This is what left
    the same spurious report on attribute assignment, return position, default
    arguments, global assignment and TypedDict fields.

The rule

Pin those placeholders instead of reporting them, when nothing can observe them:

let target_absorbs = !base.any(|t| match t {
    Type::Any(AnyStyle::Implicit) => true,
    Type::Var(v) => self.solver().var_is_partial(*v),
    _ => false,
});
  • The target's type must be known — no unpinned placeholder of its own, and
    no Any that pyrefly inferred rather than the user declaring. A container
    pinned from {} propagates the same uncertainty rather than absorbing it, so
    its contents keep the diagnostic.
  • Containment against the expression's range keeps this to placeholders the
    expression minted itself. One belonging to a name it merely mentions
    outlives the assignment and keeps its own diagnostic.
  • Evaluated on the whole target before distributing over unions, because
    pinning is global: a solved arm must not silence what an unsolved arm leaks.

The rule asks whether the placeholder can escape, never what the target is, so
dict, list, defaultdict, a MutableMapping protocol and a hand-written
__setitem__ are covered by the same code with no per-container branches.

This follows the direction @yangdanny97 set out on the issue — "be smarter about
when we emit these errors based on the contextual type" — and the counter-example
given there, x = {} followed by x["y"] = {"z": None}, is a passing control.

Relationship to #4391

@lyydsheep's #4391 (draft) fixes the reported case by matching the target against
builtins.dict[builtins.str, Any], and got there first. This generalises the
same idea. Measured, each patch applied to the same base and run on the same
inputs:

case main #4391 here
dict[str, Any] (the report) error fixed fixed
dict[int, Any] error error fixed
MutableMapping[str, Any] error error fixed
defaultdict[str, Any] error error fixed
list[Any] error error fixed
hand-written __setitem__ error error fixed
c.attr = {...}, return {...}, x: Any = {...} default, TypedDict field error error fixed
x = {} then x["y"] = {...} error error error

Happy to fold this into #4391 rather than land it separately if that is easier —
the analysis matters to me, not whose branch it lands on.

Real-world check

CPython 3.12 stdlib plus transitively-imported site-packages (numpy, scipy,
idlelib, importlib, unittest, configparser) — 137,627 diagnostics on main:

main this
total diagnostic lines 137,627 137,461
implicit-any-empty-container 3,205 3,122
diagnostics introduced 0

83 false positives removed, and every changed line is
implicit-any-empty-container
— no other error kind moved by a single line.
3,122 of 3,205 instances survive, so this is not a blanket disabling of the
check. Real examples it removes:

module.__path__ = []                 # importlib/_bootstrap.py:1193
self.__dict__['__kwdefaults__'] = {} # unittest/mock.py:2276
cf["A"] = {}                         # test_configparser.py:482 (a MutableMapping)

Two deliberate behaviours, both tested

  • A fully-known target that cannot hold the value at all now reports once:
    d["k"] = {"t": None} on dict[str, int] keeps Cannot set item and drops the
    second diagnostic, since nothing can observe a placeholder inside a value being
    rejected outright. No existing test covered this and [pyrefly] Contextualize dict subscript literals for Any #4391's test asserts both
    errors, so flagging it — it is isolated to target_absorbs and easy to revert.
  • Unpacked targets still report, and are marked with a bug test.
    bind_unpacking notes that "we never contextually type unpacks, we do the
    unpacking at type level for simplicity (for now)", so the target's Any never
    reaches the value and there is no origin range to key containment on. That
    belongs to contextually typing unpacks, not here.

Verification

  • cargo test -p pyrefly — 7441 passed, 0 failed.
  • Conformance — no change to any code, line, column or message.
  • A 12-case adversarial control suite diffed against main: names nested inside
    displays, aliased/chained/union targets with an unsolved arm, slice assignment,
    late-solved targets, plain assignment. All still report; no new diagnostics.
  • Each change verified load-bearing: dropping the solve.rs half brings the
    reported case back, and the expr.rs half alone leaves every subscript probe
    identical to main.
  • Determinism — three consecutive full-corpus runs byte-identical, with the
    pinning in place.
  • Cost — 20,000 subscript assignments, interleaved runs, median of 5, on a target
    where both sides emit identical diagnostics: 1087 ms before, 1079 ms after. No
    measurable overhead.

Assigning an empty container display where the target is declared `Any` reported
`implicit-any-empty-container`, warning that an un-inferred placeholder would
leak out as `Any` when the annotation had already accounted for it:

    def from_response(response: dict[str, Any]) -> None:
        response["meta"] = {"time_to_live": None}

The diagnostic is raised by `sanitize_answer_vars` when an unpinned
`PartialContained` var survives into a binding's answer, and two independent
paths were leaving one behind.

A subscript assignment calls `__setitem__`, which checks the value against its
parameter contextually, and then infers the same expression a second time, bare,
purely to produce the type the subscript narrows to. That second inference mints
a fresh placeholder that nothing ever solves. Separately, checking an expression
against `Any` is skipped because it always succeeds - but succeeding trivially
does not solve the placeholder either, which left the same spurious report on
attribute assignment, return position, default arguments, global assignment and
TypedDict fields.

Pin those placeholders rather than reporting them, in both places, when nothing
can observe them. The target's own type must be known: no unpinned placeholder,
and no `Any` that was inferred rather than declared, since a container pinned
from `{}` propagates the same uncertainty instead of absorbing it. Containment
against the expression's range keeps this to placeholders the expression minted
itself - one belonging to a name it merely mentions outlives the assignment and
keeps its own diagnostic. For subscripts the test runs on the whole target
before distributing over unions, because pinning is global and a solved arm must
not silence what an unsolved arm genuinely leaks.

Because the rule asks whether the placeholder can escape rather than what the
target is, `dict`, `list`, `defaultdict`, a `MutableMapping` protocol and a
hand-written `__setitem__` are covered by the same code with no per-container
branches.

Two behaviours are deliberate and covered by tests. A fully-known target that
cannot hold the value at all, such as `d["k"] = {}` on `dict[str, int]`, now
reports only `Cannot set item`, since nothing can observe a placeholder inside a
value being rejected outright. And unpacked targets still report: `bind_unpacking`
does not contextually type unpacks, so the target's `Any` never reaches the
value.

Fixes facebook#4301
@meta-cla meta-cla Bot added the cla signed label Aug 2, 2026
@github-actions github-actions Bot added the size/l label Aug 2, 2026
@meta-codesync

meta-codesync Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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

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.

implicit-any-empty-container false positive when assigning dict containing None to dict[str, Any]

2 participants