Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[ty] Treat non-empty range calls as non-empty for reachability#25220

Merged
charliermarsh merged 10 commits into
mainfrom
charlie/range
Jun 25, 2026
Merged

[ty] Treat non-empty range calls as non-empty for reachability#25220
charliermarsh merged 10 commits into
mainfrom
charlie/range

Conversation

@charliermarsh

@charliermarsh charliermarsh commented May 18, 2026

Copy link
Copy Markdown
Member

Summary

This PR productionizes one of @AlexWaygood's suggestions whereby we special-case range to detect that certain loops must trigger at least one iteration, which in turn allows us to avoid false positives around "possibly unbound" variables defined within that loop.

For example:

for x in range(42):
    pass

reveal_type(x)  # revealed: int

This PR handles two- and three-argument ranges like range(1, 3) and range(3, 0, -1), while leaving other
iterables and dynamic range(count) loops on the existing possibly-empty path.

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label May 18, 2026
@astral-sh-bot

astral-sh-bot Bot commented May 18, 2026

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 94.47%. The percentage of expected errors that received a diagnostic held steady at 89.19%. The number of fully passing files held steady at 95/134.

@astral-sh-bot

astral-sh-bot Bot commented May 18, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
prefect 449.54MB 449.66MB +0.03% (119.29kB)
trio 70.39MB 70.43MB +0.07% (48.96kB)
sphinx 166.91MB 166.94MB +0.02% (32.96kB)
flake8 29.03MB 29.03MB +0.01% (2.03kB)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
semantic_index 114.39MB 114.47MB +0.07% (85.71kB)
infer_expression_types_impl 37.70MB 37.72MB +0.05% (18.51kB)
infer_definition_types 50.56MB 50.58MB +0.03% (14.05kB)
analyze_non_terminal_call::interned_arguments 1.80MB 1.80MB +0.16% (2.95kB)
analyze_non_terminal_call 1.77MB 1.78MB +0.15% (2.76kB)
infer_scope_types_impl 30.43MB 30.43MB +0.00% (1.34kB)
CallableType 2.87MB 2.87MB -0.02% (736.00B)
Specialization 2.66MB 2.66MB -0.02% (672.00B)
BoundMethodType<'db>::bound_signatures_ 383.70kB 384.34kB +0.17% (664.00B)
when_constraint_set_assignable_to_owned_impl 2.05MB 2.05MB -0.03% (656.00B)
FunctionType 4.59MB 4.59MB -0.01% (640.00B)
Type<'db>::apply_specialization_inner_::interned_arguments 2.95MB 2.95MB -0.02% (560.00B)
is_redundant_with_impl::interned_arguments 2.34MB 2.34MB -0.02% (528.00B)
UnionType 1.06MB 1.06MB -0.04% (448.00B)
Type<'db>::apply_specialization_inner_ 2.03MB 2.03MB -0.02% (408.00B)
... 30 more

trio

Name Old New Diff Outcome
semantic_index 17.78MB 17.81MB +0.19% (34.00kB)
infer_expression_types_impl 4.35MB 4.36MB +0.12% (5.45kB)
infer_definition_types 4.02MB 4.02MB +0.07% (3.05kB)
analyze_non_terminal_call::interned_arguments 300.94kB 302.20kB +0.42% (1.27kB)
analyze_non_terminal_call 374.47kB 375.66kB +0.32% (1.19kB)
infer_scope_types_impl 2.43MB 2.43MB +0.04% (992.00B)
BoundMethodType<'db>::bound_signatures_ 75.45kB 76.06kB +0.81% (624.00B)
CallableType 665.33kB 665.85kB +0.08% (528.00B)
member_lookup_with_policy_inner::interned_arguments 766.05kB 766.41kB +0.05% (360.00B)
Type<'db>::class_member_with_policy_ 888.85kB 889.20kB +0.04% (360.00B)
Type<'db>::class_member_with_policy_::interned_arguments 626.84kB 627.15kB +0.05% (312.00B)
member_lookup_with_policy_inner 1.03MB 1.03MB +0.03% (272.00B)
BoundMethodType 183.05kB 183.20kB +0.09% (160.00B)
loop_header_reachability 70.99kB 71.14kB +0.22% (160.00B)
BoundMethodType<'db>::into_callable_type_ 30.33kB 30.47kB +0.46% (144.00B)
... 10 more

sphinx

Name Old New Diff Outcome
semantic_index 37.49MB 37.51MB +0.06% (22.79kB)
infer_expression_types_impl 14.74MB 14.75MB +0.02% (3.61kB)
infer_definition_types 13.84MB 13.84MB +0.02% (3.52kB)
infer_scope_types_impl 8.18MB 8.18MB +0.01% (656.00B)
loop_header_reachability 229.00kB 229.48kB +0.21% (496.00B)
analyze_non_terminal_call 662.78kB 663.08kB +0.04% (304.00B)
analyze_non_terminal_call::interned_arguments 610.95kB 611.23kB +0.05% (288.00B)
CallableType 1.44MB 1.44MB +0.01% (224.00B)
member_lookup_with_policy_inner::interned_arguments 2.69MB 2.69MB +0.00% (120.00B)
Type<'db>::class_member_with_policy_ 3.58MB 3.58MB +0.00% (120.00B)
all_narrowing_constraints_for_expression 1.90MB 1.90MB +0.01% (112.00B)
Type<'db>::class_member_with_policy_::interned_arguments 2.35MB 2.35MB +0.00% (104.00B)
known_class_to_class_literal 4.20kB 4.29kB +2.04% (88.00B)
member_lookup_with_policy_inner 4.27MB 4.27MB +0.00% (88.00B)
infer_statement_types_impl 483.27kB 483.36kB +0.02% (88.00B)
... 8 more

flake8

Name Old New Diff Outcome
semantic_index 7.91MB 7.92MB +0.01% (1016.00B)
infer_definition_types 1016.68kB 1016.90kB +0.02% (224.00B)
infer_expression_types_impl 640.11kB 640.24kB +0.02% (136.00B)
member_lookup_with_policy_inner::interned_arguments 205.08kB 205.20kB +0.06% (120.00B)
Type<'db>::class_member_with_policy_ 234.31kB 234.43kB +0.05% (120.00B)
Type<'db>::class_member_with_policy_::interned_arguments 168.70kB 168.80kB +0.06% (104.00B)
known_class_to_class_literal 3.16kB 3.24kB +2.72% (88.00B)
member_lookup_with_policy_inner 291.55kB 291.64kB +0.03% (88.00B)
BoundMethodType 55.00kB 55.08kB +0.14% (80.00B)
KnownClassArgument 1.91kB 1.97kB +2.86% (56.00B)
infer_scope_types_impl 517.61kB 517.64kB +0.01% (32.00B)
loop_header_reachability 9.33kB 9.35kB +0.17% (16.00B)

@astral-sh-bot

astral-sh-bot Bot commented May 18, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

Lint rule Added Removed Changed
possibly-unresolved-reference 0 86 0
unresolved-attribute 0 8 0
division-by-zero 0 1 0
not-iterable 0 1 0
Total 0 96 0
Raw diff (96 changes)
cloud-init (https://github.com/canonical/cloud-init)
- cloudinit/distros/networking.py:163:65 warning[possibly-unresolved-reference] Name `missing` used when possibly not defined
- cloudinit/net/netplan.py:456:12 warning[possibly-unresolved-reference] Name `last_exception` used when possibly not defined
- cloudinit/net/netplan.py:460:20 warning[possibly-unresolved-reference] Name `last_exception` used when possibly not defined
- cloudinit/sources/DataSourceLXD.py:317:37 warning[possibly-unresolved-reference] Name `response` used when possibly not defined
- cloudinit/sources/DataSourceLXD.py:318:25 warning[possibly-unresolved-reference] Name `response` used when possibly not defined
- cloudinit/sources/DataSourceLXD.py:321:22 warning[possibly-unresolved-reference] Name `response` used when possibly not defined
- cloudinit/sources/DataSourceLXD.py:323:22 warning[possibly-unresolved-reference] Name `response` used when possibly not defined
- cloudinit/sources/DataSourceLXD.py:326:12 warning[possibly-unresolved-reference] Name `response` used when possibly not defined

colour (https://github.com/colour-science/colour)
- colour/models/osa_ucs.py:341:38 warning[possibly-unresolved-reference] Name `XYZ` used when possibly not defined

core (https://github.com/home-assistant/core)
- homeassistant/components/ws66i/__init__.py:52:41 warning[possibly-unresolved-reference] Name `amp_num` used when possibly not defined
- homeassistant/components/zha/radio_manager.py:468:59 warning[possibly-unresolved-reference] Name `retry` used when possibly not defined

cryptography (https://github.com/pyca/cryptography)
- tests/hazmat/primitives/test_x25519.py:82:16 warning[possibly-unresolved-reference] Name `computed_shared_key` used when possibly not defined
- tests/hazmat/primitives/test_x448.py:83:16 warning[possibly-unresolved-reference] Name `computed_shared_key` used when possibly not defined

dd-trace-py (https://github.com/DataDog/dd-trace-py)
- ddtrace/vendor/psutil/_pswindows.py:720:32 warning[possibly-unresolved-reference] Name `err` used when possibly not defined
- tests/integration/test_sampling.py:336:46 warning[possibly-unresolved-reference] Name `start_time` used when possibly not defined
- tests/tracer/test_writer.py:255:43 warning[possibly-unresolved-reference] Name `i` used when possibly not defined
- tests/tracer/test_writer.py:255:55 warning[possibly-unresolved-reference] Name `i` used when possibly not defined
- tests/tracer/test_writer.py:280:51 warning[possibly-unresolved-reference] Name `i` used when possibly not defined

ibis (https://github.com/ibis-project/ibis)
- ibis/expr/tests/test_api.py:190:12 error[unresolved-attribute] Attribute `name` is not defined on `Node` in union `Node | Unknown`

ignite (https://github.com/pytorch/ignite)
- tests/ignite/engine/test_memory_leaks.py:43:27 warning[possibly-unresolved-reference] Name `i` used when possibly not defined
- tests/ignite/handlers/test_param_scheduler.py:768:67 warning[possibly-unresolved-reference] Name `data` used when possibly not defined
- tests/ignite/handlers/test_param_scheduler.py:768:75 warning[possibly-unresolved-reference] Name `max_epochs` used when possibly not defined
- tests/ignite/handlers/test_param_scheduler.py:769:12 warning[possibly-unresolved-reference] Name `lrs1` used when possibly not defined
- tests/ignite/handlers/test_param_scheduler.py:770:12 warning[possibly-unresolved-reference] Name `lrs2` used when possibly not defined

materialize (https://github.com/MaterializeInc/materialize)
- test/kafka-low-watermark/mzcompose.py:80:61 warning[possibly-unresolved-reference] Name `low` used when possibly not defined
- test/kafka-low-watermark/mzcompose.py:80:73 warning[possibly-unresolved-reference] Name `high` used when possibly not defined
- test/0dt/mzcompose.py:2173:21 warning[possibly-unresolved-reference] Name `settled_anon` used when possibly not defined
- test/0dt/mzcompose.py:2177:12 warning[possibly-unresolved-reference] Name `settled_anon` used when possibly not defined
- test/canary-load/mzcompose.py:554:21 warning[possibly-unresolved-reference] Name `received_timestamps` used when possibly not defined

optuna (https://github.com/optuna/optuna)
- docs/visualization_matplotlib_examples/optuna.visualization.matplotlib.intermediate_values.py:37:12 warning[possibly-unresolved-reference] Name `y` used when possibly not defined
- docs/visualization_examples/optuna.visualization.plot_intermediate_values.py:38:12 warning[possibly-unresolved-reference] Name `y` used when possibly not defined
- tests/samplers_tests/tpe_tests/test_sampler.py:421:59 warning[possibly-unresolved-reference] Name `trial` used when possibly not defined
- tutorial/10_key_features/005_visualization.py:159:12 warning[possibly-unresolved-reference] Name `val_accuracy` used when possibly not defined

pandas (https://github.com/pandas-dev/pandas)
- pandas/tests/tseries/offsets/test_week.py:113:37 warning[possibly-unresolved-reference] Name `date` used when possibly not defined
- pandas/tests/tseries/offsets/test_week.py:113:43 warning[possibly-unresolved-reference] Name `expected` used when possibly not defined

pandera (https://github.com/pandera-dev/pandera)
- tests/fastapi/test_app.py:60:12 warning[possibly-unresolved-reference] Name `response` used when possibly not defined

prefect (https://github.com/PrefectHQ/prefect)
- src/integrations/prefect-ray/tests/test_task_runners.py:91:40 warning[possibly-unresolved-reference] Name `result` used when possibly not defined
- src/integrations/prefect-ray/tests/test_task_runners.py:91:56 warning[possibly-unresolved-reference] Name `result` used when possibly not defined
- src/integrations/prefect-shell/tests/test_commands.py:38:12 warning[possibly-unresolved-reference] Name `logs` used when possibly not defined

psycopg (https://github.com/psycopg/psycopg)
- tests/test_waiting.py:142:78 warning[possibly-unresolved-reference] Name `the_ex` used when possibly not defined
- tests/test_waiting_async.py:150:78 warning[possibly-unresolved-reference] Name `the_ex` used when possibly not defined
- tests/types/test_string.py:265:76 warning[possibly-unresolved-reference] Name `obj` used when possibly not defined
- tests/types/test_string.py:265:82 warning[possibly-unresolved-reference] Name `i` used when possibly not defined
- tests/types/test_string.py:296:23 warning[possibly-unresolved-reference] Name `val` used when possibly not defined

pycryptodome (https://github.com/Legrandin/pycryptodome)
- lib/Crypto/SelfTest/Cipher/common.py:141:27 warning[possibly-unresolved-reference] Name `cipher` used when possibly not defined
- lib/Crypto/SelfTest/Cipher/common.py:143:13 warning[possibly-unresolved-reference] Name `decipher` used when possibly not defined
- lib/Crypto/SelfTest/Random/test_random.py:75:70 warning[possibly-unresolved-reference] Name `step` used when possibly not defined
- lib/Crypto/SelfTest/Random/test_random.py:76:69 warning[possibly-unresolved-reference] Name `step` used when possibly not defined
- lib/Crypto/SelfTest/Random/test_random.py:76:75 warning[possibly-unresolved-reference] Name `step` used when possibly not defined
- lib/Crypto/SelfTest/Random/test_random.py:78:67 warning[possibly-unresolved-reference] Name `step` used when possibly not defined
- lib/Crypto/SelfTest/Random/test_random.py:79:64 warning[possibly-unresolved-reference] Name `step` used when possibly not defined
- lib/Crypto/SelfTest/Random/test_random.py:90:67 warning[possibly-unresolved-reference] Name `step` used when possibly not defined
- lib/Crypto/PublicKey/ECC.py:1341:18 warning[possibly-unresolved-reference] Name `pointX` used when possibly not defined
- lib/Crypto/SelfTest/Hash/test_BLAKE2.py:390:30 warning[possibly-unresolved-reference] Name `h` used when possibly not defined
- lib/Crypto/SelfTest/Hash/test_BLAKE2.py:450:30 warning[possibly-unresolved-reference] Name `h` used when possibly not defined
- lib/Crypto/SelfTest/Hash/test_Poly1305.py:527:62 warning[possibly-unresolved-reference] Name `auth` used when possibly not defined

pytest (https://github.com/pytest-dev/pytest)
- testing/test_tmpdir.py:449:41 warning[possibly-unresolved-reference] Name `d` used when possibly not defined

pywin32 (https://github.com/mhammond/pywin32)
- com/win32com/test/testPyComTest.py:523:26 warning[possibly-unresolved-reference] Name `i` used when possibly not defined

scipy (https://github.com/scipy/scipy)
- scipy/special/tests/test_ellip_harm.py:42:22 warning[possibly-unresolved-reference] Name `xsum` used when possibly not defined
- scipy/integrate/_bvp.py:482:13 warning[possibly-unresolved-reference] Name `y_new` used when possibly not defined
- scipy/integrate/_bvp.py:483:13 warning[possibly-unresolved-reference] Name `p_new` used when possibly not defined
- scipy/integrate/_bvp.py:495:20 warning[possibly-unresolved-reference] Name `step_new` used when possibly not defined
- scipy/integrate/_bvp.py:496:20 warning[possibly-unresolved-reference] Name `cost_new` used when possibly not defined
- scipy/integrate/_ivp/bdf.py:69:23 warning[possibly-unresolved-reference] Name `k` used when possibly not defined
- scipy/integrate/_ivp/radau.py:136:23 warning[possibly-unresolved-reference] Name `k` used when possibly not defined
- scipy/interpolate/_interpnd_info.py:23:11 error[unresolved-attribute] Attribute `diff` is not defined on `Literal[0]` in union `Literal[0] | Unknown`
- scipy/optimize/tests/test_chandrupatla.py:127:20 warning[possibly-unresolved-reference] Name `x3` used when possibly not defined
- scipy/optimize/tests/test_chandrupatla.py:127:32 warning[possibly-unresolved-reference] Name `f3` used when possibly not defined
- scipy/optimize/tests/test_optimize.py:726:25 warning[possibly-unresolved-reference] Name `i` used when possibly not defined
- scipy/signal/_signaltools.py:1243:9 warning[possibly-unresolved-reference] Name `number` used when possibly not defined
- scipy/signal/_signaltools.py:1247:18 warning[possibly-unresolved-reference] Name `number` used when possibly not defined
- scipy/special/tests/test_basic.py:4269:47 warning[possibly-unresolved-reference] Name `n` used when possibly not defined
- scipy/special/tests/test_basic.py:4279:47 warning[possibly-unresolved-reference] Name `n` used when possibly not defined
- scipy/stats/_distn_infrastructure.py:877:24 error[unresolved-attribute] Attribute `replace` is not defined on `None` in union `str | None`
- scipy/stats/tests/test_multivariate.py:2232:24 error[unresolved-attribute] Attribute `T` is not defined on `None` in union `None | ndarray[tuple[Any, ...], dtype[Unknown]] | ndarray[tuple[Any, ...], dtype[float64]]`
- scipy/stats/tests/test_multivariate.py:2234:23 error[not-iterable] Object of type `None | ndarray[tuple[Any, ...], dtype[Unknown]] | ndarray[tuple[Any, ...], dtype[float64]]` may not be iterable

sympy (https://github.com/sympy/sympy)
- sympy/core/tests/test_expand.py:248:9 error[unresolved-attribute] Attribute `expand` is not defined on `Literal[1]` in union `Literal[1] | Expr`
- sympy/stats/sampling/tests/test_sample_continuous_rv.py:121:20 warning[possibly-unresolved-reference] Name `X` used when possibly not defined
- sympy/stats/sampling/tests/test_sample_continuous_rv.py:123:24 warning[possibly-unresolved-reference] Name `X` used when possibly not defined
- sympy/core/evalf.py:1364:33 warning[possibly-unresolved-reference] Name `err` used when possibly not defined
- sympy/core/evalf.py:1365:40 warning[possibly-unresolved-reference] Name `s` used when possibly not defined
- sympy/core/numbers.py:172:16 warning[possibly-unresolved-reference] Name `ca` used when possibly not defined
- sympy/core/numbers.py:172:31 warning[possibly-unresolved-reference] Name `ca` used when possibly not defined
- sympy/core/numbers.py:173:55 warning[possibly-unresolved-reference] Name `ca` used when possibly not defined
- sympy/core/tests/test_relational.py:694:14 warning[possibly-unresolved-reference] Name `f` used when possibly not defined
- sympy/core/tests/test_relational.py:694:36 warning[possibly-unresolved-reference] Name `f` used when possibly not defined
- sympy/physics/quantum/cg.py:147:25 error[unresolved-attribute] Attribute `parens` is not defined on `None` in union `None | prettyForm`
- sympy/physics/quantum/cg.py:322:25 error[unresolved-attribute] Attribute `parens` is not defined on `None` in union `None | prettyForm`
- sympy/physics/quantum/cg.py:426:25 error[unresolved-attribute] Attribute `parens` is not defined on `None` in union `None | prettyForm`

urllib3 (https://github.com/urllib3/urllib3)
- test/test_collections.py:23:11 warning[possibly-unresolved-reference] Name `i` used when possibly not defined
- test/test_collections.py:23:24 warning[possibly-unresolved-reference] Name `i` used when possibly not defined
- test/test_collections.py:27:17 warning[possibly-unresolved-reference] Name `i` used when possibly not defined

vision (https://github.com/pytorch/vision)
- test/test_ops.py:2449:24 warning[division-by-zero] Cannot divide object of type `Literal[0]` by zero

zulip (https://github.com/zulip/zulip)
- zerver/tests/test_external.py:130:21 warning[possibly-unresolved-reference] Name `result` used when possibly not defined
- zerver/tests/test_home.py:1012:23 warning[possibly-unresolved-reference] Name `defunct_user` used when possibly not defined
- zerver/tests/test_home.py:1020:26 warning[possibly-unresolved-reference] Name `defunct_user` used when possibly not defined

Full report with detailed diff (timing results)

@charliermarsh charliermarsh force-pushed the charlie/range branch 2 times, most recently from 0595810 to da2e341 Compare May 18, 2026 18:55
Comment thread crates/ty_python_core/src/builder.rs Outdated

/// Returns `true` if the literal arguments to `range(...)` guarantee at least one iteration.
///
/// The check is intentionally syntactic and conservative: keywords, starred arguments,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh, I wasn't thinking we'd do a syntactic check — I was thinking we'd have a Type::KnownInstance(KnownInstanceType::Range{ is_positive: book }) type, and special-case the range constructor in type inference to produce that type if a literal integer was passed in. Then we could use the inner bool to inform how we resolve the reachability constraints later on during type-checking. I think that would be much more robust than a syntactic check

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I wasn't sure that'd be necessary if we weren't going to use it beyond reachability. I'll give it a shot though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The downside of this is that we're now creating a TDD node for every iterable, even if it's not a range 😭

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I ended up with a bit of a hybrid: use a KnownInstanceType, but still use a syntactic check to keep the reachability graph manageable.

@charliermarsh charliermarsh force-pushed the charlie/range branch 5 times, most recently from fa08429 to 3105102 Compare May 19, 2026 09:31
@codspeed-hq

codspeed-hq Bot commented May 19, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 5.25%

⚡ 2 improved benchmarks
✅ 71 untouched benchmarks
⏩ 64 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime tanjun 3 s 2.9 s +5.6%
WallTime pandas 80.5 s 76.8 s +4.89%

Tip

Curious why this is faster? Use the CodSpeed MCP and ask your agent.


Comparing charlie/range (ec6058d) with main (e61a540)

Open in CodSpeed

Footnotes

  1. 64 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@charliermarsh charliermarsh force-pushed the charlie/range branch 4 times, most recently from 7938511 to 321551d Compare May 19, 2026 11:28
@charliermarsh charliermarsh changed the title [ty] View ecosystem report for non-empty range [ty] Treat non-empty range calls as non-empty for reachability May 19, 2026
@charliermarsh charliermarsh force-pushed the charlie/range branch 3 times, most recently from 9d3860e to 667a3fb Compare May 19, 2026 12:20
@charliermarsh charliermarsh marked this pull request as ready for review May 19, 2026 12:32
Comment on lines +4260 to +4265
///
/// This avoids adding reachability predicates for every `for` loop target to the TDD graph. We only
/// emit the predicate for syntactically direct `range(...)` calls; type checking later verifies that
/// the callee resolves to the built-in `range` and determines whether the range is statically
/// non-empty.
fn is_direct_range_call(expr: &ast::Expr) -> bool {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We would obviously prefer to avoid this, but I'm having trouble doing so without causing a significant performance regression, since without this guard, we end up creating TDD nodes for every iterable (even if they end up not being range calls later on).

@carljm carljm 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.

Looks generally OK but there is a correctness issue we need to fix. These kinds of special cases tend to feel like they'll be easy, but introduce more edge cases than you'd think. But I think the ecosystem diagnostics are good enough to make it worth doing.

It also feels like if we're doing this for range, we should at least also do it for empty/non-empty builtin collection literals, too? Not sure what the ecosystem impact of that would look like, but it would be significantly easier since it's detectable purely syntactically.

Comment thread crates/ty_python_core/src/predicate.rs Outdated
/// call is `Unknown`/`Any`, because that would result in too many false
/// positives.
IsNonTerminalCall(CallableAndCallExpr<'db>),
/// A direct `range(...)` call used as a `for` iterable.

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.

Nit: the name of this predicate is broader, and I could easily see us adding more cases to it in future, so I'd probably word this more generally and mention range as the only current actual case we handle.

Comment on lines +1284 to +1288
// A `range(0)` initializer can become the inferred target type for later assignments,
// but the emptiness refinement only applies to the original value expression.
(_, Type::KnownInstance(known_instance @ KnownInstanceType::Range { .. })) => {
self.check_type_pair(db, source, known_instance.instance_fallback(db))
}

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 makes empty range and non-empty range mutually subtypes of each other -- but the problem is that this means which one survives in a union order-dependent. In this example, we'll wrongly think the final block is unreachable, because the empty-range is the one that will survive in the union:

def f(flag: bool) -> None:
    value = range(0)
    if flag:
        value = range(1)

    if value:
        reveal_type(value)  # PR reveals Never; reachable when flag is True

I think the most correct fix here is to make it so that empty and non-empty ranges are actually disjoint types, so not subtypes in either direction. I think this is correct and would work fine and solve the problem -- we just need to make sure these known-instance types get literal-promoted to the instance fallback type if you e.g. make a list like list[range(0)] you get list[range] and not list[EmptyRange]. We probably also would want special logic in UnionBuilder to unify EmptyRange | NonEmptyRange in a union to just range?

}
KnownInstanceType::Range { .. } => f
.with_type(KnownClass::Range.to_class_literal(self.db))
.write_str("range"),

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.

I think this may prove to be confusing (having distinct non-equivalent types that we display identically), but we can try it and see how it works in practice.

@charliermarsh

Copy link
Copy Markdown
Member Author

Yeah I had a follow-up PR for non-empty builtins :)

@charliermarsh charliermarsh requested a review from a team as a code owner June 25, 2026 01:11
@charliermarsh charliermarsh merged commit bb9e94e into main Jun 25, 2026
63 checks passed
@charliermarsh charliermarsh deleted the charlie/range branch June 25, 2026 01:56
@carljm

carljm commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

One more issue here: it looks like we are wrongly treating two different empty ranges as equality-equivalent, so this wrongly treats the else branch as non-reachable:

left = range(1)
right = range(2)

if left == right:
    reveal_type(left)  # range
else:
    reveal_type(left)  # PR: Never; base: range

Note that we are also treating empty ranges that way, but in this case it is correct! All empty ranges are the same object and compare equal.

We should add tests for both empty and non-empty ranges, and fix the non-empty case.

@charliermarsh

Copy link
Copy Markdown
Member Author

Oops, thank you.

@charliermarsh

Copy link
Copy Markdown
Member Author

Fixed in #26351.

carljm pushed a commit that referenced this pull request Jun 25, 2026
## Summary

This fixes an equality-narrowing regression exposed by #25220.
`KnownInstanceType::Range` distinguishes statically empty and non-empty
ranges, but `Type::is_single_valued` previously treated both refinements
as single-valued. As a result, two different non-empty ranges such as
`range(1)` and `range(2)` were considered equality-equivalent and the
`else` branch below was incorrectly unreachable:

```python
left = range(1)
right = range(2)

if left == right:
    reveal_type(left)  # range
else:
    reveal_type(left)  # range
```

All empty ranges compare equal, so this keeps the empty-range refinement
single-valued. Non-empty ranges can contain different values, so this
treats that refinement as multiple-valued and preserves both equality
branches. Regression coverage checks both behaviors. The focused mdtest,
full ty semantic suite, workspace Clippy, and repository hooks pass.

Follow-up to
#25220 (comment).
pull Bot pushed a commit to TheDegenerateDev5150/ruff that referenced this pull request Jun 25, 2026
## Summary

This extends the static reachability analysis for synchronous `for`
loops from astral-sh#25220 to tuple, list, set, dictionary, string, and bytes
literals whose emptiness is known syntactically. We record their static
truthiness: a non-empty literal proves that the body executes at least
once, while an empty literal marks the body unreachable and prevents its
definitions from flowing past the loop.

```python
for item in [1]:
    pass

reveal_type(item)  # Literal[1]

value = 1
for _ in []:
    value = "unreachable"

reveal_type(value)  # Literal[1]
```

Starred elements and dictionary unpacking remain ambiguous when they are
the only elements, while an explicit element still proves non-emptiness.
Other iterables continue on the existing ambiguous path. Synchronous
literals used in `async for` do not use this shortcut because they do
not satisfy async iteration.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants