Files
Carl Meyer 002d4c8f1d [ty] Normalize runtime unions on demand (#29727)
When ty checks a value assignment such as `Alias = A[int] | B[int] |
C[int]`, it currently normalizes `A[int] | B[int]` and then compares
those same members again while building the final union. Longer chains
repeat that work for every prefix, even when nothing ever uses the value
as a type.

Instead, this PR just stores the runtime operands of `|` in the
runtime-union object, and only builds and normalizes the actual type
union lazily, if or when the runtime union value needs to be understood
as a type. For example, checking a stored union against `TypeForm[Base]`
walks the operands once and caches the result. Checks such as
`isinstance` can still inspect the original operands.

This is an alternative to #29304, which keeps the type of every
intermediate union available. For `(A | B) | C`, it normalizes `A | B`,
then seeds a fresh `UnionBuilder` with those members. If the
construction policy and inference context match, the builder only needs
to compare `C` with the existing members. This PR stores the `A | B`
object as the left operand of the outer union. If only the final type is
requested, one union builder visits `A`, `B`, and `C`. A second request
for that same union uses the cached type. The two approaches take about
the same time when a 256-class union is consumed once.

- For a chain of assignments like `U2 = U1 | Literal[2]`, each
assignment provides a new type-variable binding context. In #29304 that
context is part of the cache key, so these repeated assignments defeat
the caching, and extending the union can revisit its prefixes even when
nothing ever asks for their types. This accounts for the slowness of
#29304 on the "Extend a literal union across 1024 assignments" case
below.
- If `Prefix` is a long union and `(Prefix | X, Prefix | Y, ...)` is
checked against `tuple[TypeForm[Base], ...]`, every extension needs its
type. #29304 can reuse the normalized `Prefix` across those extensions.
This PR walks all its members for each distinct result. With 32
extensions, #29304 is nearly twice as fast. If this case turns out to
occur commonly in practice, we could add intermediate prefix caching as
well.

This PR is larger than #29304, which leaves the runtime union
representation largely intact and adds a path in `UnionBuilder` for
reconstructing an already normalized prefix. This PR needs extra
complexity in `UnionTypeInstance`, where specialization, cycle recovery,
and runtime checks need to distinguish the original operands from the
denoted type. But I prefer keeping the complexity of these
runtime-unions in the runtime-union implementation, rather than allowing
it to leak into the core `UnionBuilder`, as it does in #29304.

This also fixes a bug (as seen in xarray in the ecosystem report) with a
runtime check list `isinstance(x, float | int)`. Previously we would
simplify this union, eliminating `int`, but `int` and `float` are
separate classes at runtime, so we would then miss that the `isinstance`
check catches integers as well. Now the `isinstance` narrowing can look
at the original runtime operands instead.

## Test plan

I timed optimized builds on Apple silicon using the same main revision
(`157caa55a5`) for all three. The #29304 build applies its reviewed
patch (`a0f998e2b4`) to that base. Each entry is the median elapsed time
in milliseconds for six fresh, single-worker CLI checks with warm
filesystem caches.

| Workload | Main | #29304 | This change |
| --- | ---: | ---: | ---: |
| Construct an unused union of 256 specialized classes | 326 | 56 | 34 |
| Check the stored union against `TypeForm[CommonBase]` | 328 | 57 | 60
|
| Extend a literal union across 1,024 assignments | 46 | 3,789 | 29 |
| Extend a union across 128 assignments and consume each prefix | 77 |
95 | 79 |
| Check 32 extensions of the same 128-class prefix as type forms | 112 |
57 | 105 |

## Ecosystem report

At `dcc4ce9890`, xarray loses two false positives because `isinstance`
now recognizes integer chunk sizes. Three existing xarray diagnostics
include the types that actually reach those branches. Two truthiness
warnings in aioredis and dd-trace-py display the same union members in a
different order. All seven changes are expected. [Full ecosystem
report](https://github.com/astral-sh/ruff/pull/29727#issuecomment-6422364992)
2026-10-26 11:52:50 -07:00
..
2026-07-01 23:23:21 +01:00

Ruff Benchmarks

The ruff_benchmark crate benchmarks the linter and the formatter on individual files:

# Run once on the "baseline".
cargo bench -p ruff_benchmark -- --save-baseline=main

# Compare against the "baseline".
cargo bench -p ruff_benchmark -- --baseline=main

# Run the lexer benchmarks.
cargo bench -p ruff_benchmark lexer -- --baseline=main

See CONTRIBUTING.md on how to use these benchmarks.