Skip to content

Commit 77e5934

Browse files
committed
Support parameterized TypedDicts, like Point[int]
Resolves #28
1 parent 0273cbd commit 77e5934

2 files changed

Lines changed: 357 additions & 91 deletions

File tree

tests.py

Lines changed: 229 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,163 @@ def __iter__(self):
12721272
self.assertTryCastSuccess(NamedObject, MyNamedMapping("Isabelle"))
12731273
self.assertTryCastFailure(ValuedObject, MyNamedMapping("Isabelle"))
12741274

1275+
if sys.version_info >= (3, 11):
1276+
1277+
def test_typeddict_generic(self) -> None:
1278+
class Point(RichTypedDict, Generic[_T]):
1279+
x: _T
1280+
y: _T
1281+
1282+
# Point[int]
1283+
self.assertTryCastSuccess(Point[int], {"x": 1, "y": 2})
1284+
self.assertTryCastFailure(Point[int], {"x": 1.5, "y": 2})
1285+
self.assertTryCastFailure(Point[int], {"x": 1, "y": 2.5})
1286+
self.assertTryCastFailure(Point[int], {"x": "a", "y": "b"})
1287+
self.assertTryCastFailure(Point[int], {"x": 1}) # missing y
1288+
self.assertTryCastSuccess(
1289+
Point[int], {"x": 1, "y": 2, "z": 3}
1290+
) # extra key ok
1291+
1292+
# Point[str]
1293+
self.assertTryCastSuccess(Point[str], {"x": "a", "y": "b"})
1294+
self.assertTryCastFailure(Point[str], {"x": 1, "y": 2})
1295+
self.assertTryCastFailure(Point[str], {"x": "a", "y": 2})
1296+
1297+
# Point[float]
1298+
self.assertTryCastSuccess(Point[float], {"x": 1.5, "y": 2.5})
1299+
self.assertTryCastSuccess(
1300+
Point[float], {"x": 1, "y": 2}
1301+
) # int -> float coercion
1302+
1303+
# Multiple type parameters
1304+
K = TypeVar("K")
1305+
V = TypeVar("V")
1306+
1307+
class Entry(RichTypedDict, Generic[K, V]):
1308+
key: K
1309+
value: V
1310+
1311+
# Entry[str, int]
1312+
self.assertTryCastSuccess(Entry[str, int], {"key": "name", "value": 42})
1313+
self.assertTryCastFailure(Entry[str, int], {"key": 1, "value": 42})
1314+
self.assertTryCastFailure(Entry[str, int], {"key": "name", "value": "42"})
1315+
1316+
# Entry[int, str]
1317+
self.assertTryCastSuccess(Entry[int, str], {"key": 1, "value": "42"})
1318+
self.assertTryCastFailure(Entry[int, str], {"key": "1", "value": "42"})
1319+
1320+
# Nested generics
1321+
self.assertTryCastSuccess(
1322+
Entry[str, List[int]], {"key": "numbers", "value": [1, 2, 3]}
1323+
)
1324+
self.assertTryCastFailure(
1325+
Entry[str, List[int]], {"key": "numbers", "value": [1, "2", 3]}
1326+
)
1327+
1328+
def test_typeddict_generic_with_partial(self) -> None:
1329+
class PartialPoint(RichTypedDict, Generic[_T], total=False):
1330+
x: _T
1331+
y: _T
1332+
1333+
# PartialPoint[int]
1334+
self.assertTryCastSuccess(PartialPoint[int], {"x": 1, "y": 2})
1335+
self.assertTryCastSuccess(PartialPoint[int], {"x": 1})
1336+
self.assertTryCastSuccess(PartialPoint[int], {"y": 2})
1337+
self.assertTryCastSuccess(PartialPoint[int], {})
1338+
self.assertTryCastFailure(PartialPoint[int], {"x": "a", "y": 2})
1339+
1340+
def test_typeddict_generic_with_union(self) -> None:
1341+
class Container(RichTypedDict, Generic[_T]):
1342+
value: Union[_T, None]
1343+
1344+
# Container[int] - Basic Union[T, None]
1345+
self.assertTryCastSuccess(Container[int], {"value": 42})
1346+
self.assertTryCastSuccess(Container[int], {"value": None})
1347+
self.assertTryCastFailure(Container[int], {"value": "42"})
1348+
1349+
# Container[str]
1350+
self.assertTryCastSuccess(Container[str], {"value": "hello"})
1351+
self.assertTryCastSuccess(Container[str], {"value": None})
1352+
self.assertTryCastFailure(Container[str], {"value": 42})
1353+
1354+
# Multiple types with Union
1355+
T1 = TypeVar("T1")
1356+
T2 = TypeVar("T2")
1357+
1358+
class TwoValueContainer(RichTypedDict, Generic[T1, T2]):
1359+
first: Union[T1, str]
1360+
second: Union[T2, int]
1361+
1362+
# TwoValueContainer[int, str]
1363+
self.assertTryCastSuccess(
1364+
TwoValueContainer[int, str], {"first": 42, "second": "hello"}
1365+
)
1366+
self.assertTryCastSuccess(
1367+
TwoValueContainer[int, str], {"first": "fallback", "second": "hello"}
1368+
)
1369+
self.assertTryCastSuccess(
1370+
TwoValueContainer[int, str], {"first": 42, "second": 99}
1371+
)
1372+
self.assertTryCastFailure(
1373+
TwoValueContainer[int, str], {"first": 1.5, "second": "hello"}
1374+
)
1375+
1376+
# Union[T, str] where T is also str (edge case)
1377+
self.assertTryCastSuccess(
1378+
TwoValueContainer[str, str], {"first": "a", "second": "b"}
1379+
)
1380+
1381+
def test_typeddict_generic_with_union_pipe_syntax(self) -> None:
1382+
# Test Python 3.10+ pipe syntax for unions
1383+
assert sys.version_info >= (3, 10)
1384+
1385+
# NOTE: Use exec() to avoid syntax errors in older Python versions
1386+
exec(
1387+
dedent(
1388+
"""
1389+
class PipeContainer(RichTypedDict, Generic[_T]):
1390+
value: _T | None
1391+
1392+
# PipeContainer[int]
1393+
self.assertTryCastSuccess(PipeContainer[int], {"value": 42})
1394+
self.assertTryCastSuccess(PipeContainer[int], {"value": None})
1395+
self.assertTryCastFailure(PipeContainer[int], {"value": "42"})
1396+
1397+
# PipeContainer[List[str]]
1398+
self.assertTryCastSuccess(PipeContainer[List[str]], {"value": ["a", "b"]})
1399+
self.assertTryCastSuccess(PipeContainer[List[str]], {"value": None})
1400+
self.assertTryCastFailure(PipeContainer[List[str]], {"value": [1, 2]})
1401+
"""
1402+
),
1403+
{
1404+
"RichTypedDict": RichTypedDict,
1405+
"Generic": Generic,
1406+
"_T": _T,
1407+
"self": self,
1408+
"List": List,
1409+
},
1410+
)
1411+
1412+
def test_typeddict_generic_with_complex_unions(self) -> None:
1413+
# Test more complex Union patterns with TypeVars
1414+
T = TypeVar("T")
1415+
1416+
class ComplexContainer(RichTypedDict, Generic[T]):
1417+
value: Union[T, List[T], None]
1418+
1419+
# ComplexContainer[int] - value can be int, List[int], or None
1420+
self.assertTryCastSuccess(ComplexContainer[int], {"value": 42})
1421+
self.assertTryCastSuccess(ComplexContainer[int], {"value": [1, 2, 3]})
1422+
self.assertTryCastSuccess(ComplexContainer[int], {"value": None})
1423+
self.assertTryCastFailure(ComplexContainer[int], {"value": "string"})
1424+
self.assertTryCastFailure(ComplexContainer[int], {"value": [1, "mixed"]})
1425+
1426+
# ComplexContainer[str]
1427+
self.assertTryCastSuccess(ComplexContainer[str], {"value": "hello"})
1428+
self.assertTryCastSuccess(ComplexContainer[str], {"value": ["a", "b"]})
1429+
self.assertTryCastSuccess(ComplexContainer[str], {"value": None})
1430+
self.assertTryCastFailure(ComplexContainer[str], {"value": [1, 2]})
1431+
12751432
# === Tuples (Heterogeneous) ===
12761433

12771434
if sys.version_info >= (3, 9):
@@ -1910,15 +2067,22 @@ class Point2D(mypy_extensions.TypedDict): # type: ignore[reportGeneralTypeIssue
19102067
class Point3D(Point2D, total=False): # type: ignore[reportGeneralTypeIssues] # pyright
19112068
z: int
19122069

1913-
self.assertRaisesRegex(
1914-
TypeNotSupportedError,
1915-
(
1916-
"trycast cannot determine which keys are required.*?"
1917-
"Suggest use a typing(_extensions)?.TypedDict.*?"
1918-
"strict=False"
1919-
),
1920-
lambda: trycast(Point3D, {"x": 1, "y": 2}, strict=True),
1921-
)
2070+
if sys.version_info[:2] >= (3, 14):
2071+
self.assertRaisesRegex(
2072+
TypeNotSupportedError,
2073+
"trycast cannot determine which keys exist",
2074+
lambda: trycast(Point3D, {"x": 1, "y": 2}, strict=True),
2075+
)
2076+
else:
2077+
self.assertRaisesRegex(
2078+
TypeNotSupportedError,
2079+
(
2080+
"trycast cannot determine which keys are required.*?"
2081+
"Suggest use a typing(_extensions)?.TypedDict.*?"
2082+
"strict=False"
2083+
),
2084+
lambda: trycast(Point3D, {"x": 1, "y": 2}, strict=True),
2085+
)
19222086

19232087
# NOTE: Cannot combine the following two if-checks with an `and`
19242088
# because that is too complicated for Pyre to understand.
@@ -1966,29 +2130,62 @@ class MaybePoint1D(mypy_extensions.TypedDict, total=False): # type: ignore[repo
19662130
class TaggedMaybePoint1D(MaybePoint1D):
19672131
name: str
19682132

1969-
self.assertTryCastSuccess(Point2D, {"x": 1, "y": 2}, strict=False)
1970-
self.assertTryCastFailure(Point2D, {"x": 1, "y": "string"}, strict=False)
1971-
self.assertTryCastFailure(Point2D, {"x": 1}, strict=False)
1972-
1973-
self.assertTryCastSuccess(Point3D, {"x": 1, "y": 2, "z": 3}, strict=False)
1974-
self.assertTryCastFailure(
1975-
Point3D, {"x": 1, "y": 2, "z": "string"}, strict=False
1976-
)
1977-
self.assertTryCastSuccess(Point3D, {"x": 1, "y": 2}, strict=False)
1978-
self.assertTryCastSuccess(Point3D, {"x": 1}, strict=False) # surprise!
1979-
self.assertTryCastSuccess(Point3D, {"q": 1}, strict=False) # surprise!
1980-
1981-
self.assertTryCastSuccess(MaybePoint1D, {"x": 1}, strict=False)
1982-
self.assertTryCastFailure(MaybePoint1D, {"x": "string"}, strict=False)
1983-
self.assertTryCastSuccess(MaybePoint1D, {}, strict=False)
1984-
self.assertTryCastSuccess(MaybePoint1D, {"q": 1}, strict=False) # surprise!
1985-
1986-
self.assertTryCastSuccess(
1987-
TaggedMaybePoint1D, {"x": 1, "name": "one"}, strict=False
1988-
)
1989-
self.assertTryCastFailure(
1990-
TaggedMaybePoint1D, {"name": "one"}, strict=False
1991-
) # surprise!
2133+
@contextmanager
2134+
def assert_raises_if_python_3_14_or_later() -> Iterator[None]:
2135+
try:
2136+
yield
2137+
except TypeNotSupportedError as e:
2138+
if (
2139+
"cannot determine which keys exist on a mypy_extensions.TypedDict"
2140+
in str(e)
2141+
):
2142+
if sys.version_info[:2] >= (3, 14):
2143+
pass # expected
2144+
else:
2145+
raise
2146+
else:
2147+
raise
2148+
else:
2149+
if sys.version_info[:2] >= (3, 14):
2150+
raise AssertionError("Expected TypeNotSupportedError to be raised")
2151+
2152+
with assert_raises_if_python_3_14_or_later():
2153+
self.assertTryCastSuccess(Point2D, {"x": 1, "y": 2}, strict=False)
2154+
with assert_raises_if_python_3_14_or_later():
2155+
self.assertTryCastFailure(Point2D, {"x": 1, "y": "string"}, strict=False)
2156+
with assert_raises_if_python_3_14_or_later():
2157+
self.assertTryCastFailure(Point2D, {"x": 1}, strict=False)
2158+
2159+
with assert_raises_if_python_3_14_or_later():
2160+
self.assertTryCastSuccess(Point3D, {"x": 1, "y": 2, "z": 3}, strict=False)
2161+
with assert_raises_if_python_3_14_or_later():
2162+
self.assertTryCastFailure(
2163+
Point3D, {"x": 1, "y": 2, "z": "string"}, strict=False
2164+
)
2165+
with assert_raises_if_python_3_14_or_later():
2166+
self.assertTryCastSuccess(Point3D, {"x": 1, "y": 2}, strict=False)
2167+
with assert_raises_if_python_3_14_or_later():
2168+
self.assertTryCastSuccess(Point3D, {"x": 1}, strict=False) # surprise!
2169+
with assert_raises_if_python_3_14_or_later():
2170+
self.assertTryCastSuccess(Point3D, {"q": 1}, strict=False) # surprise!
2171+
2172+
with assert_raises_if_python_3_14_or_later():
2173+
self.assertTryCastSuccess(MaybePoint1D, {"x": 1}, strict=False)
2174+
with assert_raises_if_python_3_14_or_later():
2175+
self.assertTryCastFailure(MaybePoint1D, {"x": "string"}, strict=False)
2176+
with assert_raises_if_python_3_14_or_later():
2177+
self.assertTryCastSuccess(MaybePoint1D, {}, strict=False)
2178+
with assert_raises_if_python_3_14_or_later():
2179+
self.assertTryCastSuccess(MaybePoint1D, {"q": 1}, strict=False) # surprise!
2180+
2181+
with assert_raises_if_python_3_14_or_later():
2182+
self.assertTryCastSuccess(
2183+
TaggedMaybePoint1D, {"x": 1, "name": "one"}, strict=False
2184+
)
2185+
with assert_raises_if_python_3_14_or_later():
2186+
self.assertTryCastFailure(
2187+
TaggedMaybePoint1D, {"name": "one"}, strict=False
2188+
) # surprise!
19922189

19932190
# NOTE: Cannot combine the following two if-checks with an `and`
19942191
# because that is too complicated for Pyre to understand.

0 commit comments

Comments
 (0)