Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions effectful/internals/unification.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,14 +1058,20 @@ def _(value: collections.abc.Mapping):
ktyp = functools.reduce(
operator.or_, [nested_type(x).value for x in value.keys()]
)
if ktyp is str:
# str-keyed multi-entry dicts → always TypedDict
fields = {key: nested_type(vl).value for key, vl in value.items()}
return Box(typing.TypedDict("RuntimeTypeDict", fields)) # type: ignore
vtyp = functools.reduce(
operator.or_, [nested_type(x).value for x in value.values()]
)
if isinstance(ktyp, UnionType) or isinstance(vtyp, UnionType):
if type(value) is dict and ktyp is str and isinstance(vtyp, UnionType):
# str-keyed dicts with *heterogeneous* values → TypedDict, which
# captures the per-field value types that a single ``V`` cannot.
# Homogeneous str-keyed dicts fall through to ``Mapping[str, V]``
# below: a closed required-key TypedDict is unsound for a runtime
# value (it is really an inhabitant of ``dict[str, V]``), and two
# sibling dicts with different keys would otherwise fail to unify
# against a shared TypeVar (gh #662).
fields = {key: nested_type(vl).value for key, vl in value.items()}
return Box(typing.TypedDict("RuntimeTypeDict", fields)) # type: ignore
elif isinstance(ktyp, UnionType) or isinstance(vtyp, UnionType):
return Box(type(value))
else:
return Box(canonicalize(type(value))[ktyp, vtyp]) # type: ignore
Expand Down
42 changes: 33 additions & 9 deletions tests/test_internals_unification.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,18 +831,21 @@ class UserTD(typing.TypedDict):
assert hints == {"name": str, "age": int}


def test_nested_type_typeddict_homogeneous_str_keys():
"""Multi-key str dicts produce TypedDict even with homogeneous value types."""
def test_nested_type_homogeneous_str_keys_stays_mapping():
"""Multi-key str dicts with homogeneous values stay Mapping[str, V], not TypedDict.

A closed required-key TypedDict is unsound for a runtime dict (it is really
an inhabitant of ``dict[str, V]``); inferring one breaks unification of two
sibling dicts against a shared TypeVar (gh #662). TypedDict is reserved for
heterogeneous-valued str dicts, where per-field types carry real information.
"""
result = nested_type({"a": 1, "b": 2}).value
assert typing.is_typeddict(result)
hints = typing.get_type_hints(result)
assert hints == {"a": int, "b": int}
assert not typing.is_typeddict(result)
assert canonicalize(result) == canonicalize(dict[str, int])

result = nested_type({"a": {1, 2}, "b": {3, 4}}).value
assert typing.is_typeddict(result)
hints = typing.get_type_hints(result)
assert canonicalize(hints["a"]) == canonicalize(set[int])
assert canonicalize(hints["b"]) == canonicalize(set[int])
assert not typing.is_typeddict(result)
assert canonicalize(result) == canonicalize(dict[str, set[int]])


def test_nested_type_non_str_keys_mixed_values_stays_dict():
Expand Down Expand Up @@ -2033,3 +2036,24 @@ def g[S, R](x: collections.abc.Callable[[S], R]) -> R:

term = g(f)
assert typeof(term) is int


def test_operation_varargs_homogeneous_dicts_gh662():
"""An op taking ``*args: T`` accepts several homogeneous str-keyed dicts.

Regression test for gh #662: each dict is inferred as ``Mapping[str, int]``
rather than a closed required-key TypedDict, so binding the shared ``T``
against dicts with *different* key sets unifies instead of raising.
"""
from effectful.ops.semantics import typeof
from effectful.ops.types import NotHandled, Operation

@Operation.define
def f[T](*args: T) -> T:
raise NotHandled

# Previously raised "Cannot unify TypedDict ...: required field 'z' ...".
term = f({"x": 0, "z": 3}, {"y": 1, "x": 2})
typ = typeof(term)
assert issubclass(typ, collections.abc.MutableMapping)
assert not typing.is_typeddict(typ)
Loading