Skip to content

Conversation

dcreager
Copy link
Member

@dcreager dcreager commented Jun 25, 2025

This PR updates our unpacking assignment logic to use the new tuple machinery. As a result, we can now unpack variable-length tuples correctly.

As part of this, the TupleSpec classes have been renamed to Tuple, and can now contain any element (Rust) type, not just Type<'db>. The unpacker uses a tuple of UnionBuilders to maintain the types that will be assigned to each target, as we iterate through potentially many union elements on the rhs. We also add a new consuming iterator for tuples, and update the all_elements methods to wrap the result in an enum (similar to itertools::Position) letting you know which part of the tuple each element appears in. I also added a new UnionBuilder::try_build, which lets you specify a different fallback type if the union contains no elements.

@dcreager dcreager added the ty Multi-file analysis & type inference label Jun 25, 2025
Copy link
Contributor

github-actions bot commented Jun 25, 2025

mypy_primer results

Changes were detected when running on open source projects
dedupe (https://github.com/dedupeio/dedupe)
+ error[invalid-argument-type] dedupe/convenience.py:77:16: Argument to function `__new__` is incorrect: Expected `Iterable[Unknown]`, found `signedinteger[Unknown]`
+ error[invalid-argument-type] dedupe/convenience.py:77:19: Argument to function `__new__` is incorrect: Expected `Iterable[Unknown]`, found `signedinteger[Unknown]`
- Found 59 diagnostics
+ Found 61 diagnostics

jinja (https://github.com/pallets/jinja)
- error[invalid-argument-type] src/jinja2/compiler.py:1133:38: Argument to bound method `ref` is incorrect: Expected `str`, found `str | (tuple[str, str] & ~tuple[Unknown, ...])`
+ error[invalid-argument-type] src/jinja2/compiler.py:1133:38: Argument to bound method `ref` is incorrect: Expected `str`, found `Unknown | str | (tuple[str, str] & ~tuple[Unknown, ...])`
- error[invalid-argument-type] src/jinja2/compiler.py:1136:52: Argument to bound method `ref` is incorrect: Expected `str`, found `str | (tuple[str, str] & ~tuple[Unknown, ...])`
+ error[invalid-argument-type] src/jinja2/compiler.py:1136:52: Argument to bound method `ref` is incorrect: Expected `str`, found `Unknown | str | (tuple[str, str] & ~tuple[Unknown, ...])`
- error[invalid-argument-type] src/jinja2/compiler.py:1149:38: Argument to bound method `ref` is incorrect: Expected `str`, found `str | (tuple[str, str] & ~tuple[Unknown, ...])`
+ error[invalid-argument-type] src/jinja2/compiler.py:1149:38: Argument to bound method `ref` is incorrect: Expected `str`, found `Unknown | str | (tuple[str, str] & ~tuple[Unknown, ...])`

pywin32 (https://github.com/mhammond/pywin32)
- error[invalid-argument-type] win32/Demos/win32gui_menu.py:434:67: Argument to function `ExtTextOut` is incorrect: Expected `PyRECT`, found `tuple[@Todo(Unpack variable-length tuple), @Todo(Unpack variable-length tuple), @Todo(Unpack variable-length tuple), @Todo(Unpack variable-length tuple)]`
+ error[invalid-argument-type] win32/Demos/win32gui_menu.py:434:67: Argument to function `ExtTextOut` is incorrect: Expected `PyRECT`, found `tuple[Any, Any, Any, Any]`

sympy (https://github.com/sympy/sympy)
+ error[unsupported-operator] sympy/assumptions/tests/test_refine.py:53:26: Operator `-` is unsupported between objects of type `Unknown | int | float` and `Unknown | Half`
+ error[unsupported-operator] sympy/assumptions/tests/test_refine.py:54:26: Operator `+` is unsupported between objects of type `Unknown | int | float` and `Unknown | Half`
+ warning[possibly-unbound-attribute] sympy/core/tests/test_assumptions.py:714:14: Attribute `xreplace` on type `Unknown | int` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_evalf.py:371:12: Attribute `evalf` on type `Unknown | int` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_evalf.py:374:12: Attribute `evalf` on type `Unknown | int` is possibly unbound
+ error[invalid-argument-type] sympy/core/tests/test_evalf.py:435:39: Argument to bound method `evalf` is incorrect: Expected `dict[Basic, Basic | int | float] | None`, found `tuple[Unknown | Symbol, Literal[1]]`
+ warning[possibly-unbound-attribute] sympy/core/tests/test_evalf.py:651:12: Attribute `evalf` on type `Unknown | int` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_evalf.py:652:12: Attribute `evalf` on type `Unknown | int` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_expr.py:607:12: Attribute `is_polynomial` on type `Unknown | int` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_expr.py:608:12: Attribute `is_polynomial` on type `Unknown | int` is possibly unbound
- error[unsupported-operator] sympy/core/tests/test_expr.py:667:25: Operator `/` is unsupported between objects of type `Unknown | NaN | Infinity | NegativeInfinity | ComplexInfinity` and `Literal[1] | Unknown`
+ error[unsupported-operator] sympy/core/tests/test_expr.py:667:25: Operator `/` is unsupported between objects of type `Unknown | NaN | Infinity | NegativeInfinity | ComplexInfinity` and `Literal[1] | Unknown | Symbol`
+ warning[possibly-unbound-attribute] sympy/core/tests/test_expr.py:770:36: Attribute `cos` on type `Unknown | Symbol` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_expr.py:771:36: Attribute `sin` on type `Unknown | Symbol` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_expr.py:772:36: Attribute `exp` on type `Unknown | Symbol` is possibly unbound
+ error[invalid-argument-type] sympy/core/tests/test_expr.py:1669:43: Argument to bound method `has_xfree` is incorrect: Expected `set[Basic]`, found `Unknown | Symbol`
+ error[invalid-argument-type] sympy/core/tests/test_expr.py:1670:43: Argument to bound method `has_xfree` is incorrect: Expected `set[Basic]`, found `list[Unknown]`
+ warning[possibly-unbound-attribute] sympy/core/tests/test_expr.py:1888:12: Attribute `is_constant` on type `Unknown | int` is possibly unbound
- error[invalid-argument-type] sympy/core/tests/test_match.py:520:42: Argument to function `symbols` is incorrect: Expected `int`, found `tuple[Unknown]`
+ error[invalid-argument-type] sympy/core/tests/test_match.py:520:42: Argument to function `symbols` is incorrect: Expected `int`, found `tuple[Unknown | Symbol]`
+ error[unsupported-operator] sympy/core/tests/test_power.py:52:38: Operator `*` is unsupported between objects of type `Unknown | Half` and `Unknown | int`
+ warning[possibly-unbound-attribute] sympy/core/tests/test_power.py:282:12: Attribute `base` on type `Unknown | Expr` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_power.py:282:20: Attribute `exp` on type `Unknown | Expr` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_subs.py:750:12: Attribute `subs` on type `Unknown | int` is possibly unbound
+ warning[possibly-unbound-attribute] sympy/core/tests/test_subs.py:751:12: Attribute `subs` on type `Unknown | int` is possibly unbound
+ error[call-non-callable] sympy/core/tests/test_symbol.py:338:31: Object of type `Symbol` is not callable
+ error[call-non-callable] sympy/core/tests/test_symbol.py:339:31: Object of type `Symbol` is not callable
+ error[call-non-callable] sympy/core/tests/test_symbol.py:340:31: Object of type `Symbol` is not callable
+ error[call-non-callable] sympy/core/tests/test_symbol.py:341:31: Object of type `Symbol` is not callable
+ error[call-non-callable] sympy/core/tests/test_symbol.py:342:31: Object of type `Symbol` is not callable
+ error[unsupported-operator] sympy/geometry/polygon.py:784:16: Operator `>` is not supported for types `int` and `Basic`, in comparing `Literal[0]` with `Basic`
+ error[unsupported-operator] sympy/geometry/polygon.py:785:20: Operator `<=` is not supported for types `int` and `Basic`, in comparing `Literal[0]` with `Basic`
+ error[unsupported-operator] sympy/geometry/polygon.py:786:24: Operator `<=` is not supported for types `int` and `Basic`, in comparing `Literal[0]` with `Basic`
+ error[unsupported-operator] sympy/geometry/polygon.py:788:40: Unary operator `-` is unsupported for type `Basic`
+ error[unsupported-operator] sympy/geometry/polygon.py:788:47: Operator `-` is unsupported between objects of type `Basic` and `Basic`
+ error[unsupported-operator] sympy/geometry/polygon.py:788:59: Operator `-` is unsupported between objects of type `Basic` and `Basic`
+ warning[possibly-unbound-attribute] sympy/series/tests/test_series.py:421:32: Attribute `series` on type `Unknown | int` is possibly unbound
+ error[unresolved-attribute] sympy/sets/sets.py:2526:12: Type `Basic` has no attribute `variables`
+ error[unresolved-attribute] sympy/sets/sets.py:2526:30: Type `Basic` has no attribute `expr`
+ error[unresolved-attribute] sympy/sets/sets.py:2534:54: Type `Basic` has no attribute `variables`
+ error[unresolved-attribute] sympy/sets/sets.py:2536:21: Type `Basic` has no attribute `variables`
+ error[unresolved-attribute] sympy/sets/sets.py:2538:31: Type `Basic` has no attribute `expr`
+ error[unresolved-attribute] sympy/sets/tests/test_sets.py:844:12: Type `Basic` has no attribute `_contains`
+ error[unresolved-attribute] sympy/sets/tests/test_sets.py:845:12: Type `Basic` has no attribute `_contains`
+ error[unresolved-attribute] sympy/simplify/hyperexpand.py:1798:17: Type `Expr` has no attribute `exp`
+ error[unresolved-attribute] sympy/simplify/hyperexpand.py:1799:19: Type `Expr` has no attribute `base`
+ warning[possibly-unbound-attribute] sympy/simplify/tests/test_powsimp.py:133:48: Attribute `subs` on type `Unknown | int` is possibly unbound
+ error[unsupported-operator] sympy/simplify/tests/test_powsimp.py:330:20: Operator `*` is unsupported between objects of type `Unknown | int` and `Rational`
+ error[unsupported-operator] sympy/solvers/tests/test_solveset.py:2207:63: Operator `/` is unsupported between objects of type `Unknown | Integer` and `Unknown | int`
- Found 13236 diagnostics
+ Found 13281 diagnostics

@dcreager dcreager force-pushed the dcreager/unpack-tuple branch from 76757de to 05af0b3 Compare June 26, 2025 13:07
dcreager added 2 commits June 26, 2025 09:21
* main:
  [ty] Add regression-benchmark for attribute-assignment hang (#18957)
  [ty] Format conflicting types as an enumeration (#18956)
  [ty] Prevent union builder construction for just one declaration (#18954)
  [ty] Infer nonlocal types as unions of all reachable bindings (#18750)
  [`pyflakes`] Mark `F504`/`F522`/`F523` autofix as unsafe if there's a call with side effect (#18839)
  [`playground`] Add ruff logo docs link to Header.tsx (#18947)
  [ty] Reduce the overwhelming complexity of `TypeInferenceBuilder::infer_call_expression` (#18943)
  [ty] Add subdiagnostic about empty bodies in more cases (#18942)
  [ty] Move search path resolution to `Options::to_program_settings` (#18937)
  [`flake8-errmsg`] Extend `EM101` to support byte strings (#18867)
  Move big rule implementations (#18931)
  [`pylint`] Allow fix with comments and document performance implications (`PLW3301`) (#18936)
| Expected 3 or more
| Expected at least 3
Copy link
Member Author

Choose a reason for hiding this comment

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

These rewordings aren't because I had a strong opinion about the word choice; it's just that we're now using the same display machinery that we were using for e.g. the index-out-of-bounds diagnostics when indexing into a tuple.

Comment on lines +352 to +363
#[allow(unsafe_code)]
unsafe impl<T> salsa::Update for FixedLengthTuple<T>
where
T: salsa::Update,
{
unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool {
unsafe {
let old_value = &mut *old_pointer;
Vec::maybe_update(&mut old_value.0, new_value.0)
}
}
}
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't love this, but it was necessary to implement salsa::Update only if T also implements it. UnionBuilder does not, and it was looking very invasive to add it.

@dcreager dcreager changed the title [ty] WIP: Support variable-length tuples in unpacking assignments [ty] Support variable-length tuples in unpacking assignments Jun 26, 2025
@dcreager
Copy link
Member Author

dcreager commented Jun 26, 2025

I've spot-checked the new ecosystem results, and they all seem to be coming from unpacking variable-length tuples. For example, there are many new messages involving Symbol in sympy that come from these large unpackings:

a, b, c, d, e, f, g, h, i, j = symbols('a, b, c, d, e, f, g, h, i, j', seq=True)
k, l, m, n, o, p, q, r, s, t = symbols('k, l, m, n, o, p, q, r, s, t', seq=True)
u, v, w, x, y, z = symbols('u, v, w, x, y, z', seq=True)

(symbols is defined to return either tuple[Symbol, ...] or tuple[Any, ...])

@dcreager dcreager marked this pull request as ready for review June 26, 2025 17:34
Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

Nice!

It feels like a fair amount of complexity was introduced to our tuple representation in order to support it also representing a tuple of union-builders. I think this is probably still simpler / less duplicative than having a separate unpack-targets implementation, but it seems like some of this complexity currently leaks out to external users of tuple types. It seems like that can probably be cleaned up with some additional methods specific to tuples of Type?

Comment on lines 7218 to 7220
.all_elements()
.map(TupleElement::into_inner)
.copied()
Copy link
Contributor

Choose a reason for hiding this comment

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

We seem to do this a lot -- should it be a method?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done — made separate all_elements vs all_elements_with_kind. All of the existing callers still use all_elements, don't have to call into_inner (but do still have to add a .copied()) The new code that needs to distinguish between the different tuple element positions now calls all_elements_with_kind and has to match on the result.

@sharkdp sharkdp removed their request for review June 27, 2025 08:16
dcreager added 4 commits June 27, 2025 10:19
* main:
  [ty] Add builtins to completions derived from scope (#18982)
  [ty] Don't add incorrect subdiagnostic for unresolved reference (#18487)
  [ty] Simplify `KnownClass::check_call()` and `KnownFunction::check_call()` (#18981)
  [ty] Add micro-benchmark for #711 (#18979)
  [`flake8-annotations`] Make `ANN401` example error out-of-the-box (#18974)
  [`flake8-async`] Make `ASYNC110` example error out-of-the-box (#18975)
  [pandas]: Fix issue on `non pandas` dataframe `in-place` usage (PD002) (#18963)
  [`pylint`] Fix `PLC0415` example (#18970)
  [ty] Add environment variable to dump Salsa memory usage stats (#18928)
  [`pylint`] Fix `PLW0108` autofix introducing a syntax error when the lambda's body contains an assignment expression (#18678)
  Bump 0.12.1 (#18969)
  [`FastAPI`] Add fix safety section to `FAST002` (#18940)
  [ty] Add regression test for leading tab mis-alignment in diagnostic rendering (#18965)
  [ty] Resolve python environment in `Options::to_program_settings` (#18960)
  [`ruff`] Fix false positives and negatives in `RUF010` (#18690)
  [ty] Fix rendering of long lines that are indented with tabs
  [ty] Add regression test for diagnostic rendering panic
  [ty] Move venv and conda env discovery to `SearchPath::from_settings` (#18938)
Copy link
Member Author

@dcreager dcreager left a comment

Choose a reason for hiding this comment

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

It feels like a fair amount of complexity was introduced to our tuple representation in order to support it also representing a tuple of union-builders. I think this is probably still simpler / less duplicative than having a separate unpack-targets implementation, but it seems like some of this complexity currently leaks out to external users of tuple types. It seems like that can probably be cleaned up with some additional methods specific to tuples of Type?

I tweaked some things to make this less impactful on the existing Type-tuple callers. The main difference now is just that the iterators return &Type instead of Type, so there are some additional copies.

I also separated out the unpack_tuple logic into two steps: resizing the rhs tuple of types to have the same length as the lhs tuple of targets, and then pairwise adding each type to the target UnionBuilder. I think the new resize method will also be helpful in the forthcoming PR to splat arguments into parameters.

Comment on lines 7218 to 7220
.all_elements()
.map(TupleElement::into_inner)
.copied()
Copy link
Member Author

Choose a reason for hiding this comment

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

Done — made separate all_elements vs all_elements_with_kind. All of the existing callers still use all_elements, don't have to call into_inner (but do still have to add a .copied()) The new code that needs to distinguish between the different tuple element positions now calls all_elements_with_kind and has to match on the result.

Comment on lines +966 to +969
/// Resizes this tuple to a different length, if possible. If this tuple cannot satisfy the
/// desired minimum or maximum length, we return an error. If we return an `Ok` result, the
/// [`len`][Self::len] of the resulting tuple is guaranteed to be equal to `new_length`.
pub(crate) fn resize(
Copy link
Member Author

Choose a reason for hiding this comment

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

Eventually it would be nice to make this work for any Tuple<T>, but then you'd have to pass in additional parameters to specify how to combine elements. For now I've just defined it only for Tuple<Type<'db>> and hard-coded combining the types by unioning them.

Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

Nice!

dcreager added 2 commits June 27, 2025 15:05
* main:
  [ty] Make tuple instantiations sound (#18987)
  [`flake8-pyi`] Expand `Optional[A]` to `A | None` (`PYI016`) (#18572)
  Convert `OldDiagnostic::noqa_code` to an `Option<String>` (#18946)
  [ty] Fix playground (#18986)
Copy link

codspeed-hq bot commented Jun 27, 2025

CodSpeed WallTime Performance Report

Merging #18948 will improve performances by 4.49%

Comparing dcreager/unpack-tuple (ad3fad2) with main (a50a993)

Summary

⚡ 1 improvements
✅ 7 untouched benchmarks

Benchmarks breakdown

Benchmark BASE HEAD Change
multithreaded[pydantic] 8.7 s 8.3 s +4.49%

@dcreager dcreager merged commit c60e590 into main Jun 27, 2025
36 checks passed
@dcreager dcreager deleted the dcreager/unpack-tuple branch June 27, 2025 19:29
/// unpack the values from a rhs tuple into those targets. If the rhs is a union, call
/// `unpack_tuple` separately for each element of the union. We will automatically wrap the types
/// assigned to the starred target in `list`.
pub(crate) struct TupleUnpacker<'db> {
Copy link
Member

Choose a reason for hiding this comment

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

This is awesome!

Comment on lines +33 to +36
pub(crate) enum TupleLength {
Fixed(usize),
Variable(usize, usize),
}
Copy link
Member

Choose a reason for hiding this comment

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

nit: it might be useful to document what these usizes are, especially the ones in Variable variant or just a reference to VariableLengthTuple to indicate that these are the length of prefix and suffix respectively

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