Repository: quora/pyanalyze
Pull request #328 (2021-12-09 14:51:26)

impl for dict.update and dict.copy


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 3c991f6..58425e5 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- Improve type checking of `dict.update` and `dict.copy` (#328)
 - Improve support for complex type aliases in stubs
   (#331)
 - Limit special case for `Literal` callables to
diff --git a/pyanalyze/implementation.py b/pyanalyze/implementation.py
index f7dfb5c..c8b405b 100644
--- a/pyanalyze/implementation.py
+++ b/pyanalyze/implementation.py
@@ -20,10 +20,12 @@ from .value import (
     AnySource,
     AnyValue,
     CallableValue,
+    CanAssignContext,
     CanAssignError,
     HasAttrGuardExtension,
     KVPair,
     ParameterTypeGuardExtension,
+    TypeVarValue,
     TypedValue,
     SubclassValue,
     GenericValue,
@@ -38,12 +40,14 @@ from .value import (
     Value,
     WeakExtension,
     concrete_values_from_iterable,
+    kv_pairs_from_mapping,
     make_weak,
     unite_values,
     flatten_values,
     replace_known_sequence_value,
     dump_value,
     assert_is_value,
+    unpack_values,
 )
 
 from functools import reduce
@@ -53,7 +57,7 @@ import qcore
 import inspect
 import warnings
 from types import FunctionType
-from typing import cast, Dict, NewType, Callable, Optional, Union
+from typing import Sequence, TypeVar, cast, Dict, NewType, Callable, Optional, Union
 
 _NO_ARG_SENTINEL = KnownValue(qcore.MarkerObject("no argument given"))
 
@@ -383,79 +387,37 @@ def _tuple_getitem_impl(ctx: CallContext) -> ImplReturn:
     return _sequence_getitem_impl(ctx, tuple)
 
 
-def _dict_setitem_impl(ctx: CallContext) -> ImplReturn:
-    self_value = replace_known_sequence_value(ctx.vars["self"])
-    key = ctx.vars["k"]
-    value = ctx.vars["v"]
-    # apparently for a[b] = c we get passed the AST node for a
-    varname = ctx.varname_for_arg("self")
-    if isinstance(self_value, TypedDictValue):
-        if not isinstance(key, KnownValue) or not isinstance(key.val, str):
-            ctx.show_error(
-                f"TypedDict key must be a string literal (got {key})",
-                ErrorCode.invalid_typeddict_key,
-                arg="k",
-            )
-        elif key.val not in self_value.items:
-            ctx.show_error(
-                f"Key {key.val!r} does not exist in {self_value}",
-                ErrorCode.invalid_typeddict_key,
-                arg="k",
-            )
-        else:
-            _, expected_type = self_value.items[key.val]
-            tv_map = expected_type.can_assign(value, ctx.visitor)
-            if isinstance(tv_map, CanAssignError):
-                ctx.show_error(
-                    f"Value for key {key.val!r} must be {expected_type}, not {value}",
-                    ErrorCode.incompatible_argument,
-                    arg="v",
-                    detail=str(tv_map),
-                )
-        return ImplReturn(KnownValue(None))
-    elif isinstance(self_value, DictIncompleteValue):
-        if varname is None:
-            return ImplReturn(KnownValue(None))
-        no_return_unless = Constraint(
-            varname,
-            ConstraintType.is_value_object,
-            True,
-            DictIncompleteValue(
-                self_value.typ, [*self_value.kv_pairs, KVPair(key, value)]
-            ),
+def _typeddict_setitem(
+    self_value: TypedDictValue, key: Value, value: Value, ctx: CallContext
+) -> None:
+    if not isinstance(key, KnownValue) or not isinstance(key.val, str):
+        ctx.show_error(
+            f"TypedDict key must be a string literal (got {key})",
+            ErrorCode.invalid_typeddict_key,
+            arg="k",
         )
-        return ImplReturn(KnownValue(None), no_return_unless=no_return_unless)
-    elif isinstance(self_value, TypedValue):
-        key_type = self_value.get_generic_arg_for_type(dict, ctx.visitor, 0)
-        value_type = self_value.get_generic_arg_for_type(dict, ctx.visitor, 1)
-        if _is_weak(ctx.vars["self"]):
-            key_type = unite_values(key_type, key)
-            value_type = unite_values(value_type, value)
-            constrained_value = make_weak(GenericValue(dict, [key_type, value_type]))
-            if varname is None:
-                return ImplReturn(KnownValue(None))
-            no_return_unless = Constraint(
-                varname, ConstraintType.is_value_object, True, constrained_value
+    elif key.val not in self_value.items:
+        ctx.show_error(
+            f"Key {key.val!r} does not exist in {self_value}",
+            ErrorCode.invalid_typeddict_key,
+            arg="k",
+        )
+    else:
+        _, expected_type = self_value.items[key.val]
+        tv_map = expected_type.can_assign(value, ctx.visitor)
+        if isinstance(tv_map, CanAssignError):
+            ctx.show_error(
+                f"Value for key {key.val!r} must be {expected_type}, not {value}",
+                ErrorCode.incompatible_argument,
+                arg="v",
+                detail=str(tv_map),
             )
-            return ImplReturn(KnownValue(None), no_return_unless=no_return_unless)
-        else:
-            tv_map = key_type.can_assign(key, ctx.visitor)
-            if isinstance(tv_map, CanAssignError):
-                ctx.show_error(
-                    f"Cannot set key of type {key} (expecting {key_type})",
-                    ErrorCode.incompatible_argument,
-                    arg="k",
-                    detail=str(tv_map),
-                )
-            tv_map = value_type.can_assign(value, ctx.visitor)
-            if isinstance(tv_map, CanAssignError):
-                ctx.show_error(
-                    f"Cannot set value of type {value} (expecting {value_type})",
-                    ErrorCode.incompatible_argument,
-                    arg="v",
-                    detail=str(tv_map),
-                )
-    return ImplReturn(KnownValue(None))
+
+
+def _dict_setitem_impl(ctx: CallContext) -> ImplReturn:
+    varname = ctx.varname_for_arg("self")
+    pair = KVPair(ctx.vars["k"], ctx.vars["v"])
+    return _add_pairs_to_dict(ctx.vars["self"], [pair], ctx, varname)
 
 
 def _dict_getitem_impl(ctx: CallContext) -> ImplReturn:
@@ -609,6 +571,130 @@ def _dict_setdefault_impl(ctx: CallContext) -> ImplReturn:
         return ImplReturn(AnyValue(AnySource.inference))
 
 
+def _unpack_iterable_of_pairs(
+    val: Value, ctx: CanAssignContext
+) -> Union[Sequence[KVPair], CanAssignError]:
+    concrete = concrete_values_from_iterable(val, ctx)
+    if isinstance(concrete, CanAssignError):
+        return concrete
+    if isinstance(concrete, Value):
+        vals = unpack_values(concrete, ctx, 2)
+        if isinstance(vals, CanAssignError):
+            return CanAssignError(f"{concrete} is not a key-value pair", [vals])
+        return [KVPair(vals[0], vals[1], is_many=True)]
+    kv_pairs = []
+    for i, subval in enumerate(concrete):
+        vals = unpack_values(subval, ctx, 2)
+        if isinstance(vals, CanAssignError):
+            child = CanAssignError(f"{concrete} is not a key-value pair", [vals])
+            return CanAssignError(f"In member {i} of iterable {val}", [child])
+        kv_pairs.append(KVPair(vals[0], vals[1]))
+    return kv_pairs
+
+
+def _weak_dict_update(
+    self_val: Value,
+    pairs: Sequence[KVPair],
+    ctx: CallContext,
+    varname: Optional[Varname],
+) -> ImplReturn:
+    self_pairs = kv_pairs_from_mapping(self_val, ctx.visitor)
+    if isinstance(self_pairs, CanAssignError):
+        ctx.show_error("self is not a mapping", arg="self", detail=str(self_pairs))
+        return ImplReturn(KnownValue(None))
+    pairs = [*self_pairs, *pairs]
+
+    if varname is not None:
+        no_return_unless = Constraint(
+            varname,
+            ConstraintType.is_value_object,
+            True,
+            DictIncompleteValue(
+                self_val.typ if isinstance(self_val, TypedValue) else dict, pairs
+            ),
+        )
+        return ImplReturn(KnownValue(None), no_return_unless=no_return_unless)
+
+    return ImplReturn(KnownValue(None))
+
+
+def _add_pairs_to_dict(
+    self_val: Value,
+    pairs: Sequence[KVPair],
+    ctx: CallContext,
+    varname: Optional[Varname],
+) -> ImplReturn:
+    if _is_weak(self_val):
+        return _weak_dict_update(self_val, pairs, ctx, varname)
+
+    # Now we don't care about Annotated
+    self_val = replace_known_sequence_value(self_val)
+    if isinstance(self_val, TypedDictValue):
+        for pair in pairs:
+            _typeddict_setitem(self_val, pair.key, pair.value, ctx)
+        return ImplReturn(KnownValue(None))
+    elif isinstance(self_val, DictIncompleteValue):
+        return _weak_dict_update(self_val, pairs, ctx, varname)
+    elif isinstance(self_val, TypedValue):
+        key_type = self_val.get_generic_arg_for_type(dict, ctx.visitor, 0)
+        value_type = self_val.get_generic_arg_for_type(dict, ctx.visitor, 1)
+        for pair in pairs:
+            tv_map = key_type.can_assign(pair.key, ctx.visitor)
+            if isinstance(tv_map, CanAssignError):
+                ctx.show_error(
+                    f"Cannot set key of type {pair.key} (expecting {key_type})",
+                    ErrorCode.incompatible_argument,
+                    arg="k",
+                    detail=str(tv_map),
+                )
+            tv_map = value_type.can_assign(pair.value, ctx.visitor)
+            if isinstance(tv_map, CanAssignError):
+                ctx.show_error(
+                    f"Cannot set value of type {pair.value} (expecting {value_type})",
+                    ErrorCode.incompatible_argument,
+                    arg="v",
+                    detail=str(tv_map),
+                )
+        return ImplReturn(KnownValue(None))
+    else:
+        return _weak_dict_update(self_val, pairs, ctx, varname)
+
+
+def _dict_update_impl(ctx: CallContext) -> ImplReturn:
+    def inner(self_val: Value, m_val: Value, kwargs_val: Value) -> ImplReturn:
+        pairs = []
+        # The second argument must be either a mapping or an iterable of key-value pairs.
+        if m_val is not _NO_ARG_SENTINEL:
+            m_pairs = kv_pairs_from_mapping(m_val, ctx.visitor)
+            if isinstance(m_pairs, CanAssignError):
+                # Try an iterable of pairs instead
+                iterable_pairs = _unpack_iterable_of_pairs(m_val, ctx.visitor)
+                if isinstance(iterable_pairs, CanAssignError):
+                    error = CanAssignError(children=[m_pairs, iterable_pairs])
+                    ctx.show_error(
+                        "m is not a mapping or iterable", arg="self", detail=str(error)
+                    )
+                else:
+                    pairs += iterable_pairs
+            else:
+                pairs += m_pairs
+
+        # Separate **kwargs
+        kwargs_pairs = kv_pairs_from_mapping(kwargs_val, ctx.visitor)
+        if isinstance(kwargs_pairs, CanAssignError):
+            # should never happen
+            ctx.show_error(
+                "kwargs is not a mapping", arg="kwargs", detail=str(kwargs_pairs)
+            )
+            return ImplReturn(KnownValue(None))
+        pairs += kwargs_pairs
+
+        varname = ctx.visitor.varname_for_self_constraint(ctx.node)
+        return _add_pairs_to_dict(self_val, pairs, ctx, varname)
+
+    return flatten_unions(inner, ctx.vars["self"], ctx.vars["m"], ctx.vars["kwargs"])
+
+
 def _dict_keys_impl(ctx: CallContext) -> Value:
     self_value = replace_known_sequence_value(ctx.vars["self"])
     if not isinstance(self_value, TypedValue):
@@ -967,6 +1053,9 @@ _ENCODING_PARAMETER = SigParameter(
     "encoding", annotation=TypedValue(str), default=KnownValue("")
 )
 
+K = TypeVar("K")
+V = TypeVar("V")
+
 
 def get_default_argspecs() -> Dict[object, Signature]:
     signatures = [
@@ -1175,6 +1264,30 @@ def get_default_argspecs() -> Dict[object, Signature]:
             callable=dict.setdefault,
             impl=_dict_setdefault_impl,
         ),
+        Signature.make(
+            [
+                SigParameter("self", _POS_ONLY, annotation=TypedValue(dict)),
+                SigParameter("m", _POS_ONLY, default=_NO_ARG_SENTINEL),
+                SigParameter("kwargs", SigParameter.VAR_KEYWORD),
+            ],
+            KnownValue(None),
+            callable=dict.update,
+            impl=_dict_update_impl,
+        ),
+        Signature.make(
+            [
+                SigParameter(
+                    "self",
+                    _POS_ONLY,
+                    annotation=GenericValue(dict, [TypeVarValue(K), TypeVarValue(V)]),
+                )
+            ],
+            AnnotatedValue(
+                GenericValue(dict, [TypeVarValue(K), TypeVarValue(V)]),
+                [WeakExtension()],
+            ),
+            callable=dict.copy,
+        ),
         # Implementations of keys/items/values to compensate for incomplete
         # typeshed support. In the stubs these return instances of a private class
         # that doesn't exist in reality.
diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py
index 16b08f2..83611d9 100644
--- a/pyanalyze/name_check_visitor.py
+++ b/pyanalyze/name_check_visitor.py
@@ -120,9 +120,8 @@ from .value import (
     KnownValueWithTypeVars,
     UNINITIALIZED_VALUE,
     NO_RETURN_VALUE,
-    TypedDictValue,
+    kv_pairs_from_mapping,
     make_weak,
-    replace_known_sequence_value,
     unite_and_simplify,
     unite_values,
     KnownValue,
@@ -148,9 +147,6 @@ AwaitableValue = GenericValue(collections.abc.Awaitable, [TypeVarValue(T)])
 KnownNone = KnownValue(None)
 ExceptionValue = TypedValue(BaseException) | SubclassValue(TypedValue(BaseException))
 ExceptionOrNone = ExceptionValue | KnownNone
-K = TypeVar("K")
-V = TypeVar("V")
-MappingValue = GenericValue(collections.abc.Mapping, [TypeVarValue(K), TypeVarValue(V)])
 FunctionNode = Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda]
 
 
@@ -2298,27 +2294,16 @@ class NameCheckVisitor(
             # ** unpacking
             if key_node is None:
                 has_non_literal = True
-                value_val = replace_known_sequence_value(value_val)
-                if isinstance(value_val, DictIncompleteValue):
-                    all_pairs += value_val.kv_pairs
-                elif isinstance(value_val, TypedDictValue):
-                    all_pairs += [
-                        KVPair(KnownValue(key), value, is_required=required)
-                        for key, (required, value) in value_val.items.items()
-                    ]
-                else:
-                    can_assign = MappingValue.can_assign(value_val, self)
-                    if isinstance(can_assign, CanAssignError):
-                        self._show_error_if_checking(
-                            value_node,
-                            f"{value_val} is not a mapping",
-                            ErrorCode.unsupported_operation,
-                            detail=str(can_assign),
-                        )
-                        return TypedValue(dict)
-                    key_type = can_assign.get(K, AnyValue(AnySource.generic_argument))
-                    value_type = can_assign.get(V, AnyValue(AnySource.generic_argument))
-                    all_pairs.append(KVPair(key_type, value_type, is_many=True))
+                new_pairs = kv_pairs_from_mapping(value_val, self)
+                if isinstance(new_pairs, CanAssignError):
+                    self._show_error_if_checking(
+                        value_node,
+                        f"{value_val} is not a mapping",
+                        ErrorCode.unsupported_operation,
+                        detail=str(new_pairs),
+                    )
+                    return TypedValue(dict)
+                all_pairs += new_pairs
                 continue
             key_val = self.visit(key_node)
             all_pairs.append(KVPair(key_val, value_val))
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index 5c50853..8515aad 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -1964,6 +1964,50 @@ def concrete_values_from_iterable(
     return iter_tv_map
 
 
+K = TypeVar("K")
+V = TypeVar("V")
+MappingValue = GenericValue(collections.abc.Mapping, [TypeVarValue(K), TypeVarValue(V)])
+
+EMPTY_DICTS = (KnownValue({}), DictIncompleteValue(dict, []))
+
+
+def kv_pairs_from_mapping(
+    value_val: Value, ctx: CanAssignContext
+) -> Union[Sequence[KVPair], CanAssignError]:
+    """Return the :class:`KVPair` objects that can be extracted from this value,
+    or a :class:`CanAssignError` on error."""
+    value_val = replace_known_sequence_value(value_val)
+    # Special case: if we have a Union including an empty dict, just get the
+    # pairs from the rest of the union and make them all non-required.
+    if isinstance(value_val, MultiValuedValue) and any(
+        subval in EMPTY_DICTS for subval in value_val.vals
+    ):
+        other_val = unite_values(
+            *[subval for subval in value_val.vals if subval not in EMPTY_DICTS]
+        )
+        pairs = kv_pairs_from_mapping(other_val, ctx)
+        if isinstance(pairs, CanAssignError):
+            return pairs
+        return [
+            KVPair(pair.key, pair.value, pair.is_many, is_required=False)
+            for pair in pairs
+        ]
+    if isinstance(value_val, DictIncompleteValue):
+        return value_val.kv_pairs
+    elif isinstance(value_val, TypedDictValue):
+        return [
+            KVPair(KnownValue(key), value, is_required=required)
+            for key, (required, value) in value_val.items.items()
+        ]
+    else:
+        can_assign = MappingValue.can_assign(value_val, ctx)
+        if isinstance(can_assign, CanAssignError):
+            return can_assign
+        key_type = can_assign.get(K, AnyValue(AnySource.generic_argument))
+        value_type = can_assign.get(V, AnyValue(AnySource.generic_argument))
+        return [KVPair(key_type, value_type, is_many=True)]
+
+
 def unpack_values(
     value: Value,
     ctx: CanAssignContext,


Test patch:
diff --git a/pyanalyze/test_implementation.py b/pyanalyze/test_implementation.py
index bf4fc3e..8311068 100644
--- a/pyanalyze/test_implementation.py
+++ b/pyanalyze/test_implementation.py
@@ -4,9 +4,11 @@ from .test_node_visitor import assert_fails, assert_passes
 from .error_code import ErrorCode
 
 from .value import (
+    AnnotatedValue,
     AnySource,
     AnyValue,
     KVPair,
+    WeakExtension,
     assert_is_value,
     CallableValue,
     GenericValue,
@@ -751,6 +753,61 @@ class TestGenericMutators(TestNameCheckVisitorBase):
             )
             assert_is_value(strong_dict, expected)
 
+    @assert_passes()
+    def test_dict_update(self):
+        def capybara():
+            d1 = {}
+            d1.update({})
+            d2 = {}
+            d2.update(a=3, b=4)
+            assert_is_value(
+                d2,
+                DictIncompleteValue(
+                    dict,
+                    [
+                        KVPair(KnownValue("a"), KnownValue(3)),
+                        KVPair(KnownValue("b"), KnownValue(4)),
+                    ],
+                ),
+            )
+            d2.update([("a", 4), ("b", 5)])
+            assert_is_value(
+                d2,
+                DictIncompleteValue(
+                    dict,
+                    [
+                        KVPair(KnownValue("a"), KnownValue(3)),
+                        KVPair(KnownValue("b"), KnownValue(4)),
+                        KVPair(KnownValue("a"), KnownValue(4)),
+                        KVPair(KnownValue("b"), KnownValue(5)),
+                    ],
+                ),
+            )
+
+    @assert_passes()
+    def test_copy_and_update(self):
+        from typing import Dict
+        from pyanalyze.value import WeakExtension
+
+        def capybara():
+            d1: Dict[str, int] = {"x": 1}
+            d1_val = GenericValue(dict, [TypedValue(str), TypedValue(int)])
+            assert_is_value(d1, d1_val)
+            d1[1] = 3  # E: incompatible_argument
+            d2 = d1.copy()
+            assert_is_value(d2, AnnotatedValue(d1_val, [WeakExtension()]))
+            d2[1] = 3
+            assert_is_value(
+                d2,
+                DictIncompleteValue(
+                    dict,
+                    [
+                        KVPair(TypedValue(str), TypedValue(int), is_many=True),
+                        KVPair(KnownValue(1), KnownValue(3)),
+                    ],
+                ),
+            )
+
 
 class TestSequenceGetItem(TestNameCheckVisitorBase):
     @assert_passes()
@@ -974,7 +1031,7 @@ class TestDictSetItem(TestNameCheckVisitorBase):
         def capybara(td: TD) -> None:
             td["x"] = 42
 
-    @assert_fails(ErrorCode.invalid_typeddict_key)
+    @assert_passes()
     def test_typeddict_non_literal_key(self):
         from typing_extensions import TypedDict
 
@@ -982,9 +1039,9 @@ class TestDictSetItem(TestNameCheckVisitorBase):
             x: int
 
         def capybara(td: TD) -> None:
-            td[41] = 42
+            td[41] = 42  # E: invalid_typeddict_key
 
-    @assert_fails(ErrorCode.invalid_typeddict_key)
+    @assert_passes()
     def test_typeddict_unrecognized_key(self):
         from typing_extensions import TypedDict
 
@@ -992,9 +1049,9 @@ class TestDictSetItem(TestNameCheckVisitorBase):
             x: int
 
         def capybara(td: TD) -> None:
-            td["y"] = 42
+            td["y"] = 42  # E: invalid_typeddict_key
 
-    @assert_fails(ErrorCode.incompatible_argument)
+    @assert_passes()
     def test_typeddict_bad_value(self):
         from typing_extensions import TypedDict
 
@@ -1002,7 +1059,7 @@ class TestDictSetItem(TestNameCheckVisitorBase):
             x: int
 
         def capybara(td: TD) -> None:
-            td["x"] = "y"
+            td["x"] = "y"  # E: incompatible_argument
 
     @assert_passes()
     def test_incomplete_value(self):
@@ -1026,21 +1083,46 @@ class TestDictSetItem(TestNameCheckVisitorBase):
                 ),
             )
 
-    @assert_fails(ErrorCode.incompatible_argument)
+    @assert_passes()
     def test_bad_key_type(self):
         from typing import Dict
 
         def capybara() -> None:
             dct: Dict[str, int] = {}
-            dct[1] = 1
+            dct[1] = 1  # E: incompatible_argument
 
-    @assert_fails(ErrorCode.incompatible_argument)
+    @assert_passes()
     def test_bad_value_type(self):
         from typing import Dict
 
         def capybara() -> None:
             dct: Dict[str, int] = {}
-            dct["1"] = "1"
+            dct["1"] = "1"  # E: incompatible_argument
+
+    @assert_passes()
+    def test_weak(self):
+        from pyanalyze.value import WeakExtension
+
+        def capybara(arg):
+            dct = {int(k): 1 for k in arg}
+            assert_is_value(
+                dct,
+                AnnotatedValue(
+                    GenericValue(dict, [TypedValue(int), KnownValue(1)]),
+                    [WeakExtension()],
+                ),
+            )
+            dct["x"] = "y"
+            assert_is_value(
+                dct,
+                DictIncompleteValue(
+                    dict,
+                    [
+                        KVPair(TypedValue(int), KnownValue(1), is_many=True),
+                        KVPair(KnownValue("x"), KnownValue("y")),
+                    ],
+                ),
+            )
 
 
 class TestIssubclass(TestNameCheckVisitorBase):
diff --git a/pyanalyze/test_name_check_visitor.py b/pyanalyze/test_name_check_visitor.py
index cd292e7..d188178 100644
--- a/pyanalyze/test_name_check_visitor.py
+++ b/pyanalyze/test_name_check_visitor.py
@@ -2171,7 +2171,7 @@ class TestAugAssign(TestNameCheckVisitorBase):
 class TestUnpacking(TestNameCheckVisitorBase):
     @assert_passes()
     def test_dict_unpacking(self):
-        from typing import Dict
+        from typing import Dict, Optional
         from typing_extensions import TypedDict, NotRequired
 
         class FullTD(TypedDict):
@@ -2182,7 +2182,12 @@ class TestUnpacking(TestNameCheckVisitorBase):
             a: int
             b: NotRequired[str]
 
-        def capybara(d: Dict[str, int], ftd: FullTD, ptd: PartialTD):
+        def capybara(
+            d: Dict[str, int],
+            ftd: FullTD,
+            ptd: PartialTD,
+            maybe_ftd: Optional[FullTD] = None,
+        ):
             d1 = {1: 2}
             d2 = {3: 4, **d1}
             assert_is_value(
@@ -2227,6 +2232,16 @@ class TestUnpacking(TestNameCheckVisitorBase):
                     ],
                 ),
             )
+            assert_is_value(
+                {**(maybe_ftd or {})},
+                DictIncompleteValue(
+                    dict,
+                    [
+                        KVPair(KnownValue("a"), TypedValue(int), is_required=False),
+                        KVPair(KnownValue("b"), TypedValue(str), is_required=False),
+                    ],
+                ),
+            )
 
     @assert_passes()
     def test_iterable_unpacking(self):