Skip to content

Commit 42d1dc6

Browse files
committed
[ty] Validate unpacked TypedDict **kwargs arguments
1 parent 0b571b6 commit 42d1dc6

File tree

3 files changed

+343
-52
lines changed

3 files changed

+343
-52
lines changed

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3010,6 +3010,16 @@ func(v1=1, v2="optional", v3="ok")
30103010

30113011
# error: [unknown-argument]
30123012
func(v1=1, v3="ok", v4=1)
3013+
3014+
td2 = TD2(v1=1, v3="ok")
3015+
func(**td2)
3016+
3017+
untyped_dict: dict[str, str] = {}
3018+
# error: [invalid-argument-type]
3019+
func(**untyped_dict)
3020+
3021+
# error: [parameter-already-assigned]
3022+
func(v1=1, **td2)
30133023
```
30143024

30153025
### Assignability from unpacked kwargs to explicit keyword-only signatures
@@ -3165,6 +3175,114 @@ def stringified(**kwargs: "Unpack[StringifiedTD]") -> None:
31653175
stringified(a=1)
31663176
```
31673177

3178+
### Non-string-keyed mappings are rejected
3179+
3180+
Only string-keyed mappings can be unpacked into named keyword parameters.
3181+
3182+
```py
3183+
def takes_name(*, name: str) -> None: ...
3184+
def _(int_key_dict: dict[int, str]) -> None:
3185+
# snapshot: invalid-argument-type
3186+
takes_name(**int_key_dict)
3187+
```
3188+
3189+
```snapshot
3190+
error[invalid-argument-type]: Argument expression after ** must be a mapping with `str` key type
3191+
--> src/mdtest_snippet.py:4:16
3192+
|
3193+
4 | takes_name(**int_key_dict)
3194+
| ^^^^^^^^^^^^^^ Found `int`
3195+
|
3196+
```
3197+
3198+
### Explicit keywords still conflict with maybe-present unpacked keys
3199+
3200+
If a partial `TypedDict` may provide a key, passing that key explicitly still counts as a duplicate.
3201+
3202+
```py
3203+
from typing_extensions import TypedDict
3204+
3205+
class MaybeX(TypedDict, total=False):
3206+
x: int
3207+
3208+
def takes_x(*, x: int) -> None: ...
3209+
def _(maybe_x: MaybeX) -> None:
3210+
# error: [parameter-already-assigned]
3211+
takes_x(x=1, **maybe_x)
3212+
```
3213+
3214+
### Partial `TypedDict`s do not satisfy required unpacked keys
3215+
3216+
When a `TypedDict` key is not required, unpacking it does not prove that the corresponding required
3217+
parameter is present.
3218+
3219+
```py
3220+
from typing_extensions import TypedDict, Unpack
3221+
3222+
class MaybeX(TypedDict, total=False):
3223+
x: int
3224+
3225+
class HasX(TypedDict):
3226+
x: int
3227+
3228+
def takes_required_x(**kwargs: Unpack[HasX]) -> None: ...
3229+
def _(maybe_x: MaybeX, has_x: HasX) -> None:
3230+
# snapshot: missing-argument
3231+
takes_required_x(**maybe_x)
3232+
3233+
takes_required_x(**has_x)
3234+
```
3235+
3236+
```snapshot
3237+
error[missing-argument]: No argument provided for required parameter `x` of function `takes_required_x`
3238+
--> src/mdtest_snippet.py:12:5
3239+
|
3240+
12 | takes_required_x(**maybe_x)
3241+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
3242+
|
3243+
info: Parameter declared here
3244+
--> src/mdtest_snippet.py:9:22
3245+
|
3246+
9 | def takes_required_x(**kwargs: Unpack[HasX]) -> None: ...
3247+
| ^^^^^^^^^^^^^^^^^^^^^^
3248+
|
3249+
```
3250+
3251+
### Partial `TypedDict`s can still contribute unknown keys
3252+
3253+
If a partial `TypedDict` only offers unrelated keys, the call can fail both because a required key
3254+
is missing and because the provided key is unknown.
3255+
3256+
```py
3257+
from typing_extensions import TypedDict
3258+
3259+
class MaybeExtra(TypedDict, total=False):
3260+
extra: int
3261+
3262+
def takes_y(*, y: int) -> None: ...
3263+
def _(maybe_extra: MaybeExtra) -> None:
3264+
# error: [missing-argument]
3265+
# error: [unknown-argument]
3266+
takes_y(**maybe_extra)
3267+
```
3268+
3269+
### Legacy dunder-style positional-only parameters still coexist with unpacked keys
3270+
3271+
Legacy stub-style positional-only parameter names like `__x` should not conflict with unpacked
3272+
`TypedDict` keys of the same name.
3273+
3274+
```py
3275+
from typing_extensions import TypedDict, Unpack
3276+
3277+
LegacyPositionalOnlyKwargs = TypedDict("LegacyPositionalOnlyKwargs", {"__x": int})
3278+
3279+
def legacy(__x: int, **kwargs: Unpack[LegacyPositionalOnlyKwargs]) -> None:
3280+
reveal_type(kwargs) # revealed: LegacyPositionalOnlyKwargs
3281+
3282+
def _(legacy_kwargs: LegacyPositionalOnlyKwargs) -> None:
3283+
legacy(1, **legacy_kwargs)
3284+
```
3285+
31683286
## Bare `TypedDict` annotations in `**kwargs`
31693287

31703288
A bare `TypedDict` annotation on `**kwargs` still means “arbitrary keyword names whose values have
@@ -3200,6 +3318,37 @@ def unrelated_named_parameter(x: int, **kwargs: BareKwargs) -> None:
32003318
reveal_type(kwargs) # revealed: dict[str, BareKwargs]
32013319
```
32023320

3321+
## `dict[str, T]` remains permissive
3322+
3323+
When the unpacked mapping is a string-keyed mapping like `dict[str, T]`, ty should optimistically
3324+
assume that the right keys may be present. It should still require the mapping's value type `T` to
3325+
be assignable to each parameter exposed by the unpacked `TypedDict`.
3326+
3327+
```py
3328+
from typing_extensions import TypedDict, Unpack
3329+
3330+
class NameKwargs(TypedDict, total=False):
3331+
name: int
3332+
3333+
def accepts_name_kwargs(**kwargs: Unpack[NameKwargs]) -> None: ...
3334+
3335+
class AcceptsNameKwargs:
3336+
def __init__(self, **kwargs: Unpack[NameKwargs]) -> None:
3337+
pass
3338+
3339+
class ForwardingWrapper(AcceptsNameKwargs):
3340+
def __init__(self, **kwargs: int) -> None:
3341+
super().__init__(**kwargs)
3342+
3343+
def _(good_kwargs: dict[str, int], bad_kwargs: dict[str, str]) -> None:
3344+
accepts_name_kwargs(**good_kwargs)
3345+
AcceptsNameKwargs(**good_kwargs)
3346+
ForwardingWrapper(**good_kwargs)
3347+
3348+
# error: [invalid-argument-type]
3349+
accepts_name_kwargs(**bad_kwargs)
3350+
```
3351+
32033352
## Recursive functional `TypedDict` (unstringified forward reference)
32043353

32053354
Forward references in functional `TypedDict` calls must be stringified, since the field types are

0 commit comments

Comments
 (0)