Skip to content

[ty] Support Unpack[TypedDict] in **kwargs signatures#24653

Merged
charliermarsh merged 11 commits intomainfrom
charlie/unpack
Apr 27, 2026
Merged

[ty] Support Unpack[TypedDict] in **kwargs signatures#24653
charliermarsh merged 11 commits intomainfrom
charlie/unpack

Conversation

@charliermarsh
Copy link
Copy Markdown
Member

@charliermarsh charliermarsh commented Apr 15, 2026

Summary

We now support Unpack[TypedDict] as an annotation on **kwargs, as in the following example:

from typing_extensions import TypedDict, Unpack

class MovieKwargs(TypedDict):
    title: str
    year: int

def show_movie(**kwargs: Unpack[MovieKwargs]) -> None:
    ...

show_movie(title="Alien", year=1979)  # OK
show_movie(title="Alien")             # missing required key
show_movie(name="Alien", year=1979)   # unknown keyword

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label Apr 15, 2026
@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Apr 15, 2026

Typing conformance results improved 🎉

The percentage of diagnostics emitted that were expected errors increased from 88.13% to 88.59%. The percentage of expected errors that received a diagnostic increased from 83.58% to 84.44%. The number of fully passing files improved from 81/134 to 83/134.

Summary

How are test cases classified?

Each test case represents one expected error annotation or a group of annotations sharing a tag. Counts are per test case, not per diagnostic — multiple diagnostics on the same line count as one. Required annotations (E) are true positives when ty flags the expected location and false negatives when it does not. Optional annotations (E?) are true positives when flagged but true negatives (not false negatives) when not. Tagged annotations (E[tag]) require ty to flag exactly one of the tagged lines; tagged multi-annotations (E[tag+]) allow any number up to the tag count. Flagging unexpected locations counts as a false positive.

Metric Old New Diff Outcome
True Positives 891 901 +10 ⏫ (✅)
False Positives 120 116 -4 ⏬ (✅)
False Negatives 175 166 -9 ⏬ (✅)
Total Diagnostics 1060 1067 +7
Precision 88.13% 88.59% +0.46% ⏫ (✅)
Recall 83.58% 84.44% +0.86% ⏫ (✅)
Passing Files 81/134 83/134 +2 ⏫ (✅)

Test file breakdown

2 files altered
File True Positives False Positives False Negatives Status
callables_kwargs.py 13 (+9) ✅ 0 (-4) ✅ 0 (-8) ✅ ✅ Newly Passing 🎉
typeddicts_readonly_kwargs.py 1 (+1) ✅ 0 0 (-1) ✅ ✅ Newly Passing 🎉
Total (all files) 901 (+10) ✅ 116 (-4) ✅ 166 (-9) ✅ 83/134

True positives added (9)

9 diagnostics
Test case Diff

callables_kwargs.py:101

+error[invalid-assignment] Object of type `def func1(*, v1: int, v2: str = ..., v3: str, **kwargs: object) -> None` is not assignable to `TDProtocol3`

callables_kwargs.py:102

+error[invalid-assignment] Object of type `def func1(*, v1: int, v2: str = ..., v3: str, **kwargs: object) -> None` is not assignable to `TDProtocol4`

callables_kwargs.py:111

+error[invalid-type-form] Parameter `v1` overlaps with unpacked TypedDict key in `**kwargs` annotation

callables_kwargs.py:122

+error[invalid-type-form] Unpacked value for `**kwargs` must be a TypedDict, not `T@func6`

callables_kwargs.py:46

+error[missing-argument] No arguments provided for required parameters `v1`, `v3` of function `func1`

callables_kwargs.py:58

+error[invalid-argument-type] Argument to function `func1` is incorrect: Expected `int`, found `str`

callables_kwargs.py:63

+error[parameter-already-assigned] Multiple values provided for parameter `v1` of function `func1`

callables_kwargs.py:65

+error[parameter-already-assigned] Multiple values provided for parameter `v1` of function `func2`

typeddicts_readonly_kwargs.py:33

+error[invalid-assignment] Cannot assign to key "key1" on TypedDict `ReadOnlyArgs`: key is marked read-only

False positives removed (4)

4 diagnostics
Test case Diff

callables_kwargs.py:24

-error[type-assertion-failure] Type `@Todo(`Unpack[]` special form)` does not match asserted type `int`

callables_kwargs.py:32

-error[type-assertion-failure] Type `@Todo(`Unpack[]` special form)` does not match asserted type `str`

callables_kwargs.py:35

-error[type-assertion-failure] Type `@Todo(`Unpack[]` special form)` does not match asserted type `str`

callables_kwargs.py:41

-error[type-assertion-failure] Type `dict[str, @Todo(`Unpack[]` special form)]` does not match asserted type `TD1`

True positives changed (2)

2 diagnostics
Test case Diff

callables_kwargs.py:103

-error[invalid-assignment] Object of type `def func1(**kwargs: @Todo(`Unpack[]` special form)) -> None` is not assignable to `TDProtocol5`
+error[invalid-assignment] Object of type `def func1(*, v1: int, v2: str = ..., v3: str, **kwargs: object) -> None` is not assignable to `TDProtocol5`

callables_kwargs.py:52

-error[too-many-positional-arguments] Too many positional arguments to function `func1`: expected 0, got 3
+error[missing-argument] No arguments provided for required parameters `v1`, `v3` of function `func1`
+error[too-many-positional-arguments] Too many positional arguments to function `func1`: expected 0, got 3

Optional Diagnostics Added (1)

1 diagnostic
Test case Diff

callables_kwargs.py:61

+error[invalid-argument-type] Argument to function `func1` is incorrect: Expected `str`, found `int | str`

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Apr 15, 2026

Memory usage report

Summary

Project Old New Diff Outcome
prefect 700.60MB 701.62MB +0.15% (1.02MB)
sphinx 258.32MB 258.65MB +0.13% (343.28kB)
trio 116.37MB 116.56MB +0.16% (192.35kB)
flake8 47.59MB 47.63MB +0.08% (37.09kB)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
infer_definition_types 89.13MB 89.82MB +0.77% (705.26kB)
infer_scope_types_impl 55.41MB 55.52MB +0.21% (118.00kB)
CallableType 2.16MB 2.22MB +2.94% (65.04kB)
infer_deferred_types 11.09MB 11.14MB +0.41% (46.10kB)
infer_expression_types_impl 63.64MB 63.66MB +0.03% (19.07kB)
Type<'db>::apply_specialization_ 3.70MB 3.71MB +0.39% (14.75kB)
UnionType 939.02kB 950.14kB +1.18% (11.12kB)
FunctionType<'db>::signature_ 4.12MB 4.13MB +0.19% (8.00kB)
FunctionType<'db>::last_definition_signature_ 822.85kB 830.42kB +0.92% (7.57kB)
UnionType<'db>::from_two_elements_ 971.39kB 978.57kB +0.74% (7.18kB)
FunctionType 8.97MB 8.97MB +0.06% (5.75kB)
Type<'db>::apply_specialization_::interned_arguments 2.96MB 2.97MB +0.18% (5.55kB)
Type<'db>::try_call_dunder_get_ 10.62MB 10.62MB +0.05% (4.95kB)
FunctionType<'db>::last_definition_raw_signature_ 3.47MB 3.47MB +0.14% (4.92kB)
TupleType 727.66kB 732.34kB +0.64% (4.69kB)
... 42 more

sphinx

Name Old New Diff Outcome
infer_definition_types 23.49MB 23.79MB +1.30% (311.94kB)
infer_scope_types_impl 15.46MB 15.48MB +0.14% (22.84kB)
infer_expression_types_impl 20.82MB 20.82MB +0.04% (8.03kB)
infer_deferred_types 5.27MB 5.28MB +0.03% (1.70kB)
CallableType 1.12MB 1.11MB -0.11% (1.27kB)
infer_statement_types_impl 19.14kB 19.17kB +0.16% (32.00B)

trio

Name Old New Diff Outcome
infer_definition_types 7.53MB 7.69MB +2.11% (162.38kB)
infer_scope_types_impl 4.71MB 4.72MB +0.26% (12.38kB)
infer_deferred_types 2.33MB 2.34MB +0.27% (6.56kB)
infer_expression_types_impl 7.00MB 7.01MB +0.09% (6.44kB)
StaticClassLiteral<'db>::own_fields_ 7.35kB 8.46kB +15.15% (1.11kB)
StaticClassLiteral<'db>::fields_inner_ 12.88kB 13.92kB +8.10% (1.04kB)
class_based_items 3.62kB 3.91kB +8.21% (304.00B)
FunctionType<'db>::signature_ 1.06MB 1.06MB +0.03% (300.00B)
code_generator_of_static_class 308.70kB 308.98kB +0.09% (284.00B)
place_by_id 549.41kB 549.65kB +0.04% (248.00B)
Type<'db>::apply_specialization_::interned_arguments 626.33kB 626.56kB +0.04% (240.00B)
StaticClassLiteral<'db>::try_metaclass_ 139.05kB 139.28kB +0.16% (232.00B)
StaticClassLiteral<'db>::try_mro_ 794.85kB 795.06kB +0.03% (212.00B)
Type<'db>::apply_specialization_ 708.82kB 708.98kB +0.02% (168.00B)
place_by_id::interned_arguments 408.94kB 409.08kB +0.03% (144.00B)
... 9 more

flake8

Name Old New Diff Outcome
infer_definition_types 1.81MB 1.84MB +1.89% (35.03kB)
infer_scope_types_impl 982.63kB 984.60kB +0.20% (1.97kB)
infer_deferred_types 563.80kB 564.03kB +0.04% (240.00B)
CallableType 168.82kB 168.68kB -0.08% (144.00B)

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot Bot commented Apr 15, 2026

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-argument-type 71 1 5
unused-type-ignore-comment 0 32 0
no-matching-overload 4 0 0
not-subscriptable 0 3 0
invalid-return-type 1 1 0
unresolved-attribute 0 2 0
invalid-assignment 0 1 0
invalid-method-override 1 0 0
Total 77 40 5

Flaky changes detected. This PR summary excludes flaky changes; see the HTML report for details.

Raw diff (122 changes)
aiohttp-devtools (https://github.com/aio-libs/aiohttp-devtools)
+ aiohttp_devtools/runserver/watch.py:120:55 error[invalid-argument-type] Argument to bound method `ClientSession.get` is incorrect: Expected `SSLContext | bool | Fingerprint`, found `None | SSLContext`
+ aiohttp_devtools/runserver/watch.py:155:57 error[invalid-argument-type] Argument to bound method `ClientSession.get` is incorrect: Expected `SSLContext | bool | Fingerprint`, found `None | SSLContext`

altair (https://github.com/vega/altair)
- altair/datasets/_constraints.py:54:24 error[invalid-return-type] Return type does not match returned value: expected `Metadata`, found `dict[str, @Todo]`
- altair/datasets/_constraints.py:108:33 error[invalid-argument-type] Argument to bound method `MetaIs.from_metadata` is incorrect: Expected `Metadata`, found `dict[str, @Todo]`
- altair/utils/core.py:680:18 error[unresolved-attribute] Attribute `schema` is not defined on `NativeDataFrame`, `DataFrameLike`, `None` in union `NativeDataFrame | DataFrameLike | Unknown | None`
- altair/utils/core.py:682:22 error[not-subscriptable] Cannot subscript object of type `DataFrameLike` with no `__getitem__` method
- altair/utils/core.py:682:22 error[not-subscriptable] Cannot subscript object of type `NativeDataFrame` with no `__getitem__` method
- altair/utils/core.py:682:22 error[not-subscriptable] Cannot subscript object of type `None` with no `__getitem__` method
- altair/utils/core.py:686:39 error[unresolved-attribute] Attribute `to_native` is not defined on `NativeDataFrame`, `DataFrameLike`, `None` in union `NativeDataFrame | DataFrameLike | Unknown | None`

bokeh (https://github.com/bokeh/bokeh)
+ src/bokeh/plotting/_graph.py:131:9 error[invalid-argument-type] Argument to `GlyphRenderer.__init__` is incorrect: Argument type `Glyph | None` does not satisfy upper bound `Glyph` of type variable `GlyphType`
+ src/bokeh/plotting/_graph.py:131:9 error[invalid-argument-type] Argument to `GlyphRenderer.__init__` is incorrect: Expected `Glyph`, found `Glyph | None`
+ src/bokeh/plotting/_graph.py:149:9 error[invalid-argument-type] Argument to `GlyphRenderer.__init__` is incorrect: Argument type `Glyph | None` does not satisfy upper bound `Glyph` of type variable `GlyphType`
+ src/bokeh/plotting/_graph.py:149:9 error[invalid-argument-type] Argument to `GlyphRenderer.__init__` is incorrect: Expected `Glyph`, found `Glyph | None`
+ src/bokeh/transform.py:155:13 error[invalid-argument-type] Argument to `EqHistColorMapper.__init__` is incorrect: Expected `Sequence[str | tuple[int, int, int] | tuple[int, int, int, int | float]]`, found `Sequence[str | Color | tuple[int, int, int] | tuple[int, int, int, int | float]]`
+ src/bokeh/transform.py:158:13 error[invalid-argument-type] Argument to `EqHistColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float]`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float]`
+ src/bokeh/transform.py:159:13 error[invalid-argument-type] Argument to `EqHistColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float] | None`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float] | None`
+ src/bokeh/transform.py:160:13 error[invalid-argument-type] Argument to `EqHistColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float] | None`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float] | None`
+ src/bokeh/transform.py:200:13 error[invalid-argument-type] Argument to `CategoricalColorMapper.__init__` is incorrect: Expected `Sequence[str | tuple[int, int, int] | tuple[int, int, int, int | float]]`, found `Sequence[str | Color | tuple[int, int, int] | tuple[int, int, int, int | float]]`
+ src/bokeh/transform.py:202:13 error[invalid-argument-type] Argument to `CategoricalColorMapper.__init__` is incorrect: Expected `int`, found `int | float`
+ src/bokeh/transform.py:203:13 error[invalid-argument-type] Argument to `CategoricalColorMapper.__init__` is incorrect: Expected `int | None`, found `int | float | None`
+ src/bokeh/transform.py:204:13 error[invalid-argument-type] Argument to `CategoricalColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float]`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float]`
+ src/bokeh/transform.py:242:13 error[invalid-argument-type] Argument to `CategoricalPatternMapper.__init__` is incorrect: Expected `Sequence[Property[Literal["blank", "dot", "ring", "horizontal_line", "vertical_line", ... omitted 29 literals]]]`, found `Sequence[str]`
+ src/bokeh/transform.py:244:13 error[invalid-argument-type] Argument to `CategoricalPatternMapper.__init__` is incorrect: Expected `int`, found `int | float`
+ src/bokeh/transform.py:245:13 error[invalid-argument-type] Argument to `CategoricalPatternMapper.__init__` is incorrect: Expected `int | None`, found `int | float | None`
+ src/bokeh/transform.py:285:13 error[invalid-argument-type] Argument to `CategoricalMarkerMapper.__init__` is incorrect: Expected `Sequence[Literal["asterisk", "circle", "circle_cross", "circle_dot", "circle_x", ... omitted 23 literals]]`, found `Sequence[str]`
+ src/bokeh/transform.py:287:13 error[invalid-argument-type] Argument to `CategoricalMarkerMapper.__init__` is incorrect: Expected `int`, found `int | float`
+ src/bokeh/transform.py:288:13 error[invalid-argument-type] Argument to `CategoricalMarkerMapper.__init__` is incorrect: Expected `int | None`, found `int | float | None`
+ src/bokeh/transform.py:368:13 error[invalid-argument-type] Argument to `LinearColorMapper.__init__` is incorrect: Expected `Sequence[str | tuple[int, int, int] | tuple[int, int, int, int | float]]`, found `Sequence[str | Color | tuple[int, int, int] | tuple[int, int, int, int | float]]`
+ src/bokeh/transform.py:371:13 error[invalid-argument-type] Argument to `LinearColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float]`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float]`
+ src/bokeh/transform.py:372:13 error[invalid-argument-type] Argument to `LinearColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float] | None`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float] | None`
+ src/bokeh/transform.py:373:13 error[invalid-argument-type] Argument to `LinearColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float] | None`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float] | None`
+ src/bokeh/transform.py:415:13 error[invalid-argument-type] Argument to `LogColorMapper.__init__` is incorrect: Expected `Sequence[str | tuple[int, int, int] | tuple[int, int, int, int | float]]`, found `Sequence[str | Color | tuple[int, int, int] | tuple[int, int, int, int | float]]`
+ src/bokeh/transform.py:418:13 error[invalid-argument-type] Argument to `LogColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float]`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float]`
+ src/bokeh/transform.py:419:13 error[invalid-argument-type] Argument to `LogColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float] | None`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float] | None`
+ src/bokeh/transform.py:420:13 error[invalid-argument-type] Argument to `LogColorMapper.__init__` is incorrect: Expected `str | tuple[int, int, int] | tuple[int, int, int, int | float] | None`, found `str | Color | tuple[int, int, int] | tuple[int, int, int, int | float] | None`
+ src/bokeh/layouts.py:384:9 error[invalid-argument-type] Argument to `Toolbar.__init__` is incorrect: Expected `Literal["normal", "grey"] | None`, found `Literal["normal", "grey"] | None | UndefinedType`
+ src/bokeh/layouts.py:385:9 error[invalid-argument-type] Argument to `Toolbar.__init__` is incorrect: Expected `bool`, found `bool | UndefinedType`
+ src/bokeh/layouts.py:386:9 error[invalid-argument-type] Argument to `Toolbar.__init__` is incorrect: Expected `Literal["auto"] | Drag | ToolProxy | None`, found `ToolProxy | Tool | UndefinedType`
+ src/bokeh/layouts.py:387:9 error[invalid-argument-type] Argument to `Toolbar.__init__` is incorrect: Expected `Literal["auto"] | InspectTool | ToolProxy | Sequence[InspectTool] | None`, found `ToolProxy | Tool | UndefinedType`
+ src/bokeh/layouts.py:388:9 error[invalid-argument-type] Argument to `Toolbar.__init__` is incorrect: Expected `Literal["auto"] | Scroll | ToolProxy | None`, found `ToolProxy | Tool | UndefinedType`
+ src/bokeh/layouts.py:389:9 error[invalid-argument-type] Argument to `Toolbar.__init__` is incorrect: Expected `Literal["auto"] | Tap | ToolProxy | None`, found `ToolProxy | Tool | UndefinedType`
+ src/bokeh/layouts.py:390:9 error[invalid-argument-type] Argument to `Toolbar.__init__` is incorrect: Expected `Literal["auto"] | GestureTool | ToolProxy | None`, found `ToolProxy | Tool | UndefinedType`
+ src/bokeh/models/renderers/contour_renderer.py:112:13 error[invalid-argument-type] Argument to `ContourColorBar.__init__` is incorrect: Expected `GlyphRenderer[Glyph]`, found `Instance[GlyphRenderer[Unknown]]`
+ src/bokeh/models/renderers/contour_renderer.py:113:13 error[invalid-argument-type] Argument to `ContourColorBar.__init__` is incorrect: Expected `GlyphRenderer[Glyph]`, found `Instance[GlyphRenderer[Unknown]]`
+ src/bokeh/models/renderers/contour_renderer.py:114:13 error[invalid-argument-type] Argument to `ContourColorBar.__init__` is incorrect: Expected `Sequence[int | float]`, found `Seq[T@Seq]`
+ src/bokeh/models/renderers/contour_renderer.py:115:32 error[invalid-argument-type] Argument to `FixedTicker.__init__` is incorrect: Expected `Sequence[int | float]`, found `Seq[T@Seq]`
- src/bokeh/models/renderers/graph_renderer.py:84:45 error[invalid-argument-type] Argument to `Object.__init__` is incorrect: Argument type `() -> GlyphRenderer[Unknown]` does not satisfy upper bound `Serializable` of type variable `S`
+ src/bokeh/models/renderers/graph_renderer.py:84:45 error[invalid-argument-type] Argument to `Object.__init__` is incorrect: Argument type `() -> GlyphRenderer[Scatter]` does not satisfy upper bound `Serializable` of type variable `S`
- src/bokeh/models/renderers/graph_renderer.py:84:45 error[invalid-argument-type] Argument to `Object.__init__` is incorrect: Expected `GlyphRenderer[Unknown] | UndefinedType | IntrinsicType`, found `() -> GlyphRenderer[Unknown]`
+ src/bokeh/models/renderers/graph_renderer.py:84:45 error[invalid-argument-type] Argument to `Object.__init__` is incorrect: Expected `GlyphRenderer[Unknown] | UndefinedType | IntrinsicType`, found `() -> GlyphRenderer[Scatter]`
- src/bokeh/models/renderers/graph_renderer.py:89:45 error[invalid-argument-type] Argument to `Object.__init__` is incorrect: Argument type `() -> GlyphRenderer[Unknown]` does not satisfy upper bound `Serializable` of type variable `S`
+ src/bokeh/models/renderers/graph_renderer.py:89:45 error[invalid-argument-type] Argument to `Object.__init__` is incorrect: Argument type `() -> GlyphRenderer[MultiLine]` does not satisfy upper bound `Serializable` of type variable `S`
- src/bokeh/models/renderers/graph_renderer.py:89:45 error[invalid-argument-type] Argument to `Object.__init__` is incorrect: Expected `GlyphRenderer[Unknown] | UndefinedType | IntrinsicType`, found `() -> GlyphRenderer[Unknown]`
+ src/bokeh/models/renderers/graph_renderer.py:89:45 error[invalid-argument-type] Argument to `Object.__init__` is incorrect: Expected `GlyphRenderer[Unknown] | UndefinedType | IntrinsicType`, found `() -> GlyphRenderer[MultiLine]`
+ src/bokeh/plotting/_figure.py:243:41 error[invalid-argument-type] Argument to `CoordinateMapping.__init__` is incorrect: Expected `Range`, found `Range | None`
+ src/bokeh/plotting/_figure.py:243:60 error[invalid-argument-type] Argument to `CoordinateMapping.__init__` is incorrect: Expected `Range`, found `Range | None`
+ src/bokeh/plotting/_plot.py:90:32 error[invalid-argument-type] Argument to `FactorRange.__init__` is incorrect: Expected `Sequence[str] | Sequence[tuple[str, str]] | Sequence[tuple[str, str, str]]`, found `list[int | float | str]`
+ src/bokeh/plotting/_plot.py:98:32 error[invalid-argument-type] Argument to `Range1d.__init__` is incorrect: Expected `int | float | datetime | timedelta`, found `int | float | (Unknown & ~None) | str | IntrinsicType`
+ src/bokeh/plotting/_plot.py:98:45 error[invalid-argument-type] Argument to `Range1d.__init__` is incorrect: Expected `int | float | datetime | timedelta`, found `int | float | (Unknown & ~None) | str | IntrinsicType`
+ src/bokeh/plotting/_renderer.py:127:9 error[invalid-argument-type] Argument to `GlyphRenderer.__init__` is incorrect: Argument type `Glyph | None` does not satisfy upper bound `Glyph` of type variable `GlyphType`
+ src/bokeh/plotting/_renderer.py:127:9 error[invalid-argument-type] Argument to `GlyphRenderer.__init__` is incorrect: Expected `Glyph`, found `Glyph | None`
+ src/bokeh/plotting/_renderer.py:139:56 error[invalid-argument-type] Argument to function `update_legend` is incorrect: Expected `GlyphRenderer[Glyph]`, found `GlyphRenderer[GlyphType@GlyphRenderer]`
+ src/bokeh/plotting/_renderer.py:141:12 error[invalid-return-type] Return type does not match returned value: expected `GlyphRenderer[Glyph]`, found `GlyphRenderer[GlyphType@GlyphRenderer]`

discord.py (https://github.com/Rapptz/discord.py)
- discord/permissions.py:216:48 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/permissions.py:488:46 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/bot.py:294:59 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/bot.py:306:48 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/bot.py:307:107 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/bot.py:318:57 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/bot.py:330:48 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/bot.py:331:105 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/core.py:1551:48 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/core.py:1608:48 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `CooldownMapping[Context[Any]]`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `MaxConcurrency`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `bool`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `bool`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `bool`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `bool`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `bool`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `bool`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `dict[Any, Any]`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `list[(Context[Any], /) -> bool | Coroutine[Any, Any, bool]]`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `list[str] | tuple[str, ...]`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `str | None`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `str | None`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `str | None`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `str`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
+ discord/ext/commands/core.py:602:38 error[invalid-argument-type] Argument to `Command.__init__` is incorrect: Expected `str`, found `Any | list[str] | tuple[str, ...] | ... omitted 7 union elements`
- discord/ext/commands/hybrid.py:517:50 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:641:47 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:849:59 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:861:48 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:862:107 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:873:57 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:885:48 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:886:105 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:897:54 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:940:92 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:949:52 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- discord/ext/commands/hybrid.py:973:90 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive

hydpy (https://github.com/hydpy-dev/hydpy)
+ hydpy/models/rconc/rconc_control.py:249:23 error[no-matching-overload] No overload of `dict.__init__` matches arguments

pandas (https://github.com/pandas-dev/pandas)
- pandas/io/parsers/readers.py:1604:41 error[invalid-argument-type] Argument to function `len` is incorrect: Expected `Sized`, found `(@Todo & ~Literal[False] & ~_NoDefault) | None`
+ pandas/io/parsers/readers.py:1604:41 error[invalid-argument-type] Argument to function `len` is incorrect: Expected `Sized`, found `Hashable & ~Literal[False] & ~_NoDefault`
+ pandas/tests/io/parser/test_read_fwf.py:986:26 error[invalid-argument-type] Argument to function `read_fwf` is incorrect: Expected `Literal["pyarrow", "numpy_nullable"] | _NoDefault`, found `Literal["numpy"]`
+ pandas/tests/io/test_common.py:442:13 error[no-matching-overload] No overload of function `read_csv` matches arguments

prefect (https://github.com/PrefectHQ/prefect)
+ src/integrations/prefect-dbt/prefect_dbt/core/_orchestrator.py:1916:25 error[invalid-argument-type] Argument to `MaterializingTask.__init__` is incorrect: Expected `list[Asset | str] | None`, found `(list[Asset] & ~AlwaysFalsy) | None`
+ src/prefect/tasks.py:2218:9 error[invalid-method-override] Invalid override of method `with_options`: Definition is incompatible with `Task.with_options`

pycryptodome (https://github.com/Legrandin/pycryptodome)
+ lib/Crypto/SelfTest/Protocol/test_ecdh.py:315:53 error[invalid-argument-type] Argument to function `import_x25519_private_key` is incorrect: Expected `bytes`, found `bytes | bytearray | memoryview[int]`
+ lib/Crypto/SelfTest/Protocol/test_ecdh.py:316:51 error[invalid-argument-type] Argument to function `import_x25519_public_key` is incorrect: Expected `bytes`, found `bytes | bytearray | memoryview[int]`
+ lib/Crypto/SelfTest/Protocol/test_ecdh.py:410:51 error[invalid-argument-type] Argument to function `import_x448_private_key` is incorrect: Expected `bytes`, found `bytes | bytearray | memoryview[int]`
+ lib/Crypto/SelfTest/Protocol/test_ecdh.py:411:49 error[invalid-argument-type] Argument to function `import_x448_public_key` is incorrect: Expected `bytes`, found `bytes | bytearray | memoryview[int]`

pydantic (https://github.com/pydantic/pydantic)
- pydantic/fields.py:239:95 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- pydantic/fields.py:278:57 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- pydantic/fields.py:1278:39 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- pydantic/fields.py:1282:47 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- pydantic/fields.py:1292:47 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- pydantic/fields.py:1302:53 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- pydantic/fields.py:1312:57 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- pydantic/fields.py:1322:39 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- pydantic/fields.py:1335:40 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- pydantic/fields.py:1350:43 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive

pyinstrument (https://github.com/joerick/pyinstrument)
- pyinstrument/context_manager.py:41:9 error[invalid-assignment] Object of type `dict[str, @Todo]` is not assignable to attribute `options` of type `ProfileContextOptions`

pywin32 (https://github.com/mhammond/pywin32)
+ com/win32comext/shell/demos/servers/folder_view.py:855:9 error[invalid-argument-type] Argument to function `UseCommandLine` is incorrect: Expected `bool`, found `Literal[0]`
+ com/win32comext/shell/demos/servers/shell_view.py:968:9 error[invalid-argument-type] Argument to function `UseCommandLine` is incorrect: Expected `bool`, found `Literal[0]`

rotki (https://github.com/rotki/rotki)
+ rotkehlchen/tests/db/test_db.py:407:20 error[no-matching-overload] No overload of bound method `DBHandler.get_dynamic_cache` matches arguments
+ rotkehlchen/tests/db/test_db.py:447:20 error[no-matching-overload] No overload of bound method `DBHandler.get_dynamic_cache` matches arguments

Full report with detailed diff (timing results)

@charliermarsh charliermarsh force-pushed the charlie/unpack branch 3 times, most recently from 1b9a9bd to ffd2dca Compare April 15, 2026 14:21
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 15, 2026

Merging this PR will not alter performance

✅ 53 untouched benchmarks
⏩ 60 skipped benchmarks1


Comparing charlie/unpack (e8ddb60) with main (92221b0)

Open in CodSpeed

Footnotes

  1. 60 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.

has_starred_annotation: bool,

/// Whether this parameter was declared as `**kwargs: Unpack[TypedDict]`.
has_unpacked_kwargs_annotation: 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.

This lets us distinguish **kwargs: Unpack[TD] from **kwargs: TD. The latter means that every kwarg value is itself of type TD.

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 could also make this an enum to combine has_starred_annotation and has_unpacked_kwargs_annotation.)

Comment on lines +1018 to +1019
/// Per [PEP 692](https://peps.python.org/pep-0692/#typeddict-unions), unions (for example) are not
/// allowed in such annotations.
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.

Slightly annoying because we already have some logic for extracting these keys in the more general case (see, e.g., extract_unpacked_typed_dict_keys_from_value_type), but PEP 692 explicitly forbids unions.

/// The argument definitely binds this parameter.
Definitive,
/// The argument may bind this parameter at runtime, but does not guarantee its presence.
Provisional,
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.

This is necessary for cases like...

class MaybeX(TypedDict, total=False):
    x: str

def takes_x(*, x: int) -> None: ...

takes_x(**maybe_x)

Where we need to report both missing-argument and invalid-argument-type.

@charliermarsh
Copy link
Copy Markdown
Member Author

For the ecosystem report...

  • I believe the graphql-core diagnostics are true positives? They're unpacking with a TypedDict(total=False), although according to Codex other type checkers don't raise these errors, i.e., they don't require that the key is definitely present. I prefer our behavior but it is stricter.
  • For bokeh, there are generally a lot of TypedDict usages in that codebase that type checkers can't model (e.g., calls on an event type that then need to narrow the return type, etc.), so I'm not overly concerned.

Based on Codex's analysis, the rest are either true positives or things that are sufficiently dynamic that we shouldn't really expect to model them. But I'll go through a few more on my own.

@charliermarsh
Copy link
Copy Markdown
Member Author

Also, note that the newly-added false positive on the conformance tests is related to extra_items, which we don't yet support.


## `Unpack[TypedDict]` in `**kwargs`

Using `Unpack[TypedDict]` on a `**kwargs` parameter should expose the `TypedDict` shape both inside
Copy link
Copy Markdown
Member

@MichaReiser MichaReiser Apr 15, 2026

Choose a reason for hiding this comment

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

I haven't read the mdtest but could we split this test into multiple smaller snippets with some prose between snippets explaining what's being tested and what the expected behavior is?

This is one of my main learnings from maintaining Ruff. The long fixture files are a pain to maintain over time because they lack context of why something has been tested in the first place and if it's even asserting something intentionally or if they just tried to be exhaustive.

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.

Yes sir!

Copy link
Copy Markdown
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.

This is awesome! Will be great to have this feature.

I reviewed the tests and some of the implementation; there are enough findings that might change enough of the implementation that I think it makes sense to do an iteration before reviewing more.

One meta-comment is that I think this is a case where the PR might have (understandably) suffered a bit of scope-creep. I think there are really two separable things addressed here. One is TypedDict unpacking on the formal-parameter side using Unpack (which is really just a matter of signature transformation, and a bit of code to infer the right type within the function body). The other is the whole Provisional matched argument thing, which is all about caller-side actual-argument unpacked TypedDict, and doesn't actually care whether the formal parameter is from an Unpack[TD] or just regular keyword arguments.

I think it would clarify the PR and the review if we separated those concerns into separate PRs.

Comment thread crates/ty_python_semantic/resources/mdtest/typed_dict.md
Comment thread crates/ty_python_semantic/resources/mdtest/typed_dict.md
Comment on lines +3054 to +3058
def func7(*, v1: int, v3: str, v2: str = "") -> None:
pass

# error: [invalid-assignment]
typed_dict_bad: TypedDictKwargs = func7
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.

Why do we expect invalid-assignment here? It looks to me like this assignment should succeed. func7 has the same signature that we should be treating TypedDictKwargs as having.

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.

Agreed; this should be accepted.

class TD2(TD1):
v3: Required[str]

def func5(v1: int, **kwargs: Unpack[TD2]) -> None: # error: [invalid-type-form]
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.

invalid-type-form feels weird for this one, since the type form Unpack[TD2] is just fine, what's invalid is the overall function signature. But I don't know of any better fitting code, and I don't think this is worth its own dedicated code. The non-Unpack version of this is just a syntax error, and this isn't, so that parallel doesn't help either. So I guess this really is our best option. Other type checkers also tend to use the same code for this as for the cases where the Unpack expression itself is malformed, so at least we're in good company.

Comment on lines +3111 to +3115
### Regression coverage

These cases check a few tricky edges: unpacking non-string-keyed mappings, combining explicit
keyword arguments with unpacked `TypedDict`s, missing required keys from partial `TypedDict`s, and
legacy dunder-style keyword names.
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.

Not to be a pain, but this is kind of a weird catch-all. It would be nicer to split these out into separate tests, each with their own prose...

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.

Also wording nit: "legacy dunder-style positional-only parameters"

Comment on lines +821 to +824
/// The key is guaranteed to be present at runtime when the mapping is unpacked.
Guaranteed,
/// The key may be present at runtime when the mapping is unpacked.
Potential,
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.

Hmm... is there a good reason for us (here and in method names below) to invent new terminology that means the same thing as Required and NotRequired, which is the syntax that is actually used in defining a TypedDict key?

from typing import Any
from typing_extensions import TypedDict, Unpack

class AnyKwargs(TypedDict, total=False):
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: I find this name confusing, because there is nothing Any related about this typed-dict type.

if let Some((parameter_index, parameter, _)) =
self.parameters.unpacked_typed_dict_keyword_variadic(db)
{
let permissive_any_mapping = argument_type.is_some_and(|argument_type| {
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.

Commented above on the tests -- I don't think it is correct to specially handle dict[str, Any]. All dict[str, ...] types should be forgiving in argument matching (but of course should still require that ... is assignable to each matched formal parameter, which is always true for Any).

We (and all type checkers) have the same forgiving behavior for e.g. * unpacking of list[int] -- we optimistically assume the list is the right length for the available parameters. But we still require that int be assignable to each matched parameter.

Comment on lines +999 to +1000
/// Per [PEP 692](https://peps.python.org/pep-0692/#typeddict-unions), this accepts only a concrete
/// `TypedDict` target, or a type alias resolving to one.
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 it is always implied that stringified annotations should be supported too, but this function doesn't support them. All other type checkers support this:

from typing_extensions import TypedDict, Unpack

class TD(TypedDict):
    a: int

def f(**kwargs: "Unpack[TD]") -> None:
    reveal_type(kwargs)  # should be TD

# should be accepted, same as an unquoted `Unpack[TD]`.
f(a=1)

}
}

value.push(keywords);
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 in order to fix some of the assignability bugs I mentioned in the tests, and remove the need for some special casing in call binding, we should eliminate this line -- the original **kwargs: Unpack[TD] parameter should not stay as part of the signature proper.

This might (not totally sure without trying it) require keeping a bit (or index?) around somewhere in the signature recording that this transformation happened, so that we can still refer to the original kwargs argument in diagnostics, detect arguments duplicated via this unpacking, etc. But I think it's important that we do that in a dedicated way that doesn't affect the signature itself, and is only used for these additional validation/diagnostic purposes.

(Keeping the **kwargs argument around in the signature is not necessary for properly inferring the type of kwargs inside the function. That goes through a totally separate Definition path that leads directly to the annotation on **kwargs, it doesn't go through a callable signature at all.)

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 normalization is inside Parameters::new, but we now do roughly this.)

@charliermarsh charliermarsh marked this pull request as draft April 18, 2026 03:03
@charliermarsh
Copy link
Copy Markdown
Member Author

Great review, thank you.

@charliermarsh charliermarsh changed the title [ty] Support Unpack[TypedDict] [ty] Support Unpack[TypedDict] in **kwargs signatures Apr 19, 2026
@charliermarsh charliermarsh force-pushed the charlie/unpack branch 3 times, most recently from 7033e83 to a7096ef Compare April 19, 2026 03:03
@charliermarsh charliermarsh force-pushed the charlie/unpack branch 2 times, most recently from 69559e9 to b23d0ca Compare April 26, 2026 20:39
@charliermarsh charliermarsh marked this pull request as ready for review April 26, 2026 20:54
@astral-sh-bot astral-sh-bot Bot requested a review from carljm April 26, 2026 20:54
Copy link
Copy Markdown
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!

Comment thread crates/ty_python_semantic/resources/mdtest/typed_dict.md Outdated
Comment thread crates/ty_python_semantic/resources/mdtest/typed_dict.md
Comment thread crates/ty_python_semantic/resources/mdtest/typed_dict.md Outdated
Comment thread crates/ty_python_semantic/resources/mdtest/typed_dict.md Outdated
Comment thread crates/ty_python_semantic/resources/mdtest/typed_dict.md
Comment thread crates/ty_python_semantic/src/types/typed_dict.rs Outdated
if resolve_unpacked_typed_dict_kwargs_annotation_target(self.db(), inner_ty)
.is_some()
{
return inner_ty;
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 way we recursively unwrap Unpack and pass it up as a bare type means we allow **kwargs: Unpack[Unpack[TD]] to pass silently, which we really shouldn't (and no other type checker does).

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.

Relatedly, it's kind of odd/unfortunate how we sort of double-handle Unpack, in that we handle it here, unwrap it, drop the information that it was wrapped in Unpack on the floor, and then later (in resolve_unpacked_typed_dict_kwargs_annotation) we have to go back to the raw AST to re-validate whether Unpack was really there after all. And that leads to the whole need for manualenter_string_annotation etc to support string annotations -- while string annotations are already naturally supported here in the inference builder.

It would be a lot nicer (and would give us a way to prevent Unpack[Unpack[...]]) if instead we could smuggle out of here the information about which expression(s) had Unpack. Options that come to mind:

  1. A dedicated Type::Unpack wrapper variant. Too much churn all over the codebase, I don't think this is a good idea.
  2. Add a way to optionally map an expression ID to some bitflags (I guess just one for now) in the type inference builder? I might be missing some details that make this one difficult, too...

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 tried out the second.)

diagnostic::add_type_expression_reference_link(diag);
}
return Type::unknown();
}
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.

There should be an else here where we emit an invalid-type-form for use of Unpack in any other location. (Except I guess for inside a tuple? So maybe we could leave this as a TODO until we actually implement that location too? Though it also probably wouldn't be hard to add IN_TUPLE_ANNOTATION and just carve out that exception for now.)

Comment thread crates/ty_python_semantic/src/types/signatures.rs Outdated
Comment thread crates/ty_python_semantic/src/types/signatures.rs Outdated
@charliermarsh charliermarsh marked this pull request as draft April 27, 2026 01:46
@charliermarsh charliermarsh marked this pull request as ready for review April 27, 2026 02:09
@astral-sh-bot astral-sh-bot Bot requested a review from carljm April 27, 2026 02:09
@charliermarsh
Copy link
Copy Markdown
Member Author

I tried to implement the full range of invalid-type-form diagnostics for Unpack but it got a little messy, so I left it at TODOs (plus some tests with TODOs) which I'll follow-up on with a dedicated PR.

@charliermarsh charliermarsh enabled auto-merge (squash) April 27, 2026 02:09
@charliermarsh charliermarsh disabled auto-merge April 27, 2026 02:09
@charliermarsh charliermarsh merged commit 10c0bb3 into main Apr 27, 2026
55 checks passed
@charliermarsh charliermarsh deleted the charlie/unpack branch April 27, 2026 02:11
carljm added a commit that referenced this pull request Apr 27, 2026
* main: (44 commits)
  Update cargo-bins/cargo-binstall action to v1.18.1 (#24855)
  Update dependency ruff to v0.15.12 (#24857)
  Update taiki-e/install-action action to v2.75.18 (#24864)
  [ty] Model bool-op branch snapshots (#24458)
  [ty] Support `Unpack[TypedDict]` in `**kwargs` signatures (#24653)
  Update prek dependencies (#24858)
  Update Rust crate bitflags to v2.11.1 (#24859)
  Update Rust crate clap to v4.6.1 (#24860)
  Update Rust crate mimalloc to v0.1.49 (#24862)
  Update Rust crate uuid to v1.23.1 (#24863)
  Update Rust crate rayon to v1.12.0 (#24866)
  Update Rust crate libc to v0.2.185 (#24861)
  [ty] Reserve union element storage (#24849)
  [ty] bump conformance suite commit (#24848)
  [ty] Pass unmapped type variables to `SpecializationBuilder::build_with` (#24809)
  [ty] Avoid bookkeeping for unannotated functions (#24842)
  [ty] Optimize signature checking based on number of arguments (#24674)
  [ty] Avoid eagerly inferring legacy generic context (#24841)
  [ty] Skip decorator inference for undecorated functions (#24839)
  [ty] solve unions against generic protocols (#24837)
  ...
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