Skip to content

Commit ff5c9f6

Browse files
authored
Enable Typing and generics based typing to support PEP585 (#727)
* Enable Typing and generics based types to support PEP585 Signed-off-by: Arham Chopra <arham.chopra@cubistsystematic.com> * Additional fixes Signed-off-by: Arham Chopra <arham.chopra@cubistsystematic.com> * More fixes Signed-off-by: Arham Chopra <arham.chopra@cubistsystematic.com> --------- Signed-off-by: Arham Chopra <arham.chopra@cubistsystematic.com>
1 parent f6410ed commit ff5c9f6

6 files changed

Lines changed: 285 additions & 8 deletions

File tree

csp/impl/types/container_type_normalizer.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import collections.abc
2+
import types
13
import typing
24

35
import numpy
@@ -23,11 +25,78 @@ class ContainerTypeNormalizer:
2325
csp.typing.NumpyNDArray: numpy.ndarray,
2426
}
2527

28+
@classmethod
29+
def canonicalize_builtin_generics(cls, typ):
30+
"""Recursively canonicalize PEP 585 builtin generics to their ``typing`` equivalents so that,
31+
e.g., ``list[int]`` and ``typing.List[int]`` (and every nesting combination) normalize to a single
32+
representation that compares and hashes equal.
33+
34+
Only the builtin container origins in ``_ORIGIN_COMPAT_MAP`` (list, set, dict, tuple) are remapped
35+
to their ``typing`` form. Every other generic wrapper (FastList, csp.typing numpy arrays,
36+
typing.Callable, typing.Mapping, custom generics, ...) keeps its own origin/flavor, but we still
37+
recurse into its arguments so nested builtin containers get canonicalized. Unions (both
38+
``typing.Union``/``Optional`` and PEP 604 ``X | Y``) are traversed as well. When nothing actually
39+
changes, the original object is returned unchanged, both to avoid needless allocations and to
40+
preserve object identity for callers that rely on it.
41+
"""
42+
if CspTypingUtils.is_union_type(typ):
43+
args = typing.get_args(typ)
44+
converted_args = tuple(cls._canonicalize_arg(arg) for arg in args)
45+
if converted_args == args:
46+
return typ
47+
return typing.Union[converted_args]
48+
49+
if CspTypingUtils.is_generic_container(typ):
50+
# __args__ (rather than get_args) keeps Callable's flattened ([params], ret) shape, which is
51+
# what copy_with expects for faithful reconstruction.
52+
args = typ.__args__
53+
converted_args = tuple(cls._canonicalize_arg(arg) for arg in args)
54+
canonical_origin = CspTypingUtils._ORIGIN_COMPAT_MAP.get(typ.__origin__)
55+
if canonical_origin is not None:
56+
# list/set/dict/tuple: rewrite to the typing form. A builtin (types.GenericAlias) alias
57+
# must always be rebuilt (that is the whole point); an already-typing alias whose args did
58+
# not change is returned as-is to preserve identity.
59+
if converted_args == args and not isinstance(typ, types.GenericAlias):
60+
return typ
61+
return canonical_origin[converted_args if len(converted_args) != 1 else converted_args[0]]
62+
# Preserved wrapper (FastList, numpy arrays, Callable, Mapping, custom generic, ...): keep the
63+
# outer origin/flavor but rebuild with canonicalized args when a child actually changed.
64+
if converted_args == args:
65+
return typ
66+
if hasattr(typ, "copy_with"):
67+
# typing._GenericAlias (typing.Callable, typing.Mapping, csp numpy arrays, ...): copy_with
68+
# faithfully preserves the alias flavor, including Callable's flattened arg shape.
69+
return typ.copy_with(converted_args)
70+
origin = typ.__origin__
71+
if origin is collections.abc.Callable:
72+
# PEP 585 collections.abc.Callable[[p1, ...], ret]: __args__ is flattened, so restore the
73+
# ([params], ret) subscription shape (an Ellipsis param list stays as Callable[..., ret]).
74+
*params, ret = converted_args
75+
if params == [Ellipsis]:
76+
return origin[..., ret]
77+
return origin[list(params), ret]
78+
try:
79+
return origin[converted_args if len(converted_args) != 1 else converted_args[0]]
80+
except TypeError:
81+
# Exotic / non-subscriptable origin: leave it unchanged rather than fail normalization.
82+
return typ
83+
84+
return typ
85+
86+
@classmethod
87+
def _canonicalize_arg(cls, arg):
88+
# A bare string argument inside a generic (e.g. ``list["T"]``) is stored raw by PEP 585 builtins but
89+
# as a ForwardRef by typing generics; canonicalize to ForwardRef so both spellings match (and so we
90+
# do not mint a fresh TypeVar on every call, which would defeat equality/caching).
91+
if isinstance(arg, str):
92+
return typing.ForwardRef(arg)
93+
return cls.canonicalize_builtin_generics(arg)
94+
2695
@classmethod
2796
def _convert_containers_to_typing_generic_meta(cls, typ, is_within_container):
97+
typ = cls.canonicalize_builtin_generics(typ)
2898
if CspTypingUtils.is_generic_container(typ):
2999
return typ
30-
# cls._deep_convert_generic_meta_to_typing_generic_meta(typ, is_within_container)
31100
elif isinstance(typ, dict):
32101
# warn(
33102
# "Using {K: V} syntax for type declaration is deprecated. Use Dict[K, V] instead.",

csp/impl/types/instantiation_type_resolver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,9 +403,9 @@ def _is_scalar_value_matching_spec(self, inp_def_type, arg):
403403
if self._is_scalar_value_matching_spec(t, arg):
404404
return True
405405
if isinstance(arg, SnapType):
406-
return arg.ts_type.typ is inp_def_type
406+
return arg.ts_type.typ == inp_def_type
407407
if isinstance(arg, SnapKeyType):
408-
return arg.key_tstype.typ is inp_def_type
408+
return arg.key_tstype.typ == inp_def_type
409409
return False
410410

411411
def _rec_validate_container_and_resolve_tvars(self, sub_arg, sub_type_def):

csp/impl/types/pydantic_types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,11 @@ def make_snap_validator(inp_def_type):
111111

112112
def snap_validator(v: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo) -> Any:
113113
if isinstance(v, SnapType):
114-
if v.ts_type.typ is inp_def_type:
114+
if v.ts_type.typ == inp_def_type:
115115
return v
116116
raise ValueError(f"Expecting {inp_def_type} for csp.snap value, but getting {v.ts_type.typ}")
117117
if isinstance(v, SnapKeyType):
118-
if v.key_tstype.typ is inp_def_type:
118+
if v.key_tstype.typ == inp_def_type:
119119
return v
120120
raise ValueError(f"Expecting {inp_def_type} for csp.snap_key value, but getting {v.key_tstype.typ}")
121121
return handler(v)

csp/impl/wiring/base_parser.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,12 @@ def parse_func_signature(self, funcdef):
400400
else:
401401
typ = self._eval_expr(arg.annotation)
402402
arg_kind, basket_kind = self._resolve_input_type_kind(typ)
403+
if arg_kind == ArgKind.SCALAR:
404+
# Store the scalar note in canonical form once, at parse time, so downstream type
405+
# comparisons (e.g. csp.snap) see list[X] and typing.List[X] as the same type without
406+
# re-normalizing on every wiring. Deliberately narrower than normalize_type: numpy
407+
# arrays, {K: V}/[T] shorthands, and bare aliases are intentionally left untouched.
408+
typ = ContainerTypeNormalizer.canonicalize_builtin_generics(typ)
403409

404410
inputs.append(InputDef(arg.arg, typ, arg_kind, basket_kind, tsidx, arg_idx))
405411

csp/tests/impl/types/test_tstype.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
import sys
2-
from typing import Any, Dict, ForwardRef, Generic, List, Mapping, TypeVar, Union, get_args, get_origin
2+
from datetime import datetime, timedelta
3+
from typing import (
4+
Any,
5+
Callable,
6+
Dict,
7+
ForwardRef,
8+
Generic,
9+
List,
10+
Mapping,
11+
Optional,
12+
Set,
13+
Tuple,
14+
TypeVar,
15+
Union,
16+
get_args,
17+
get_origin,
18+
)
319
from unittest import TestCase
420

521
import numpy as np
@@ -9,9 +25,12 @@
925
import csp
1026
from csp import dynamic_demultiplex, ts
1127
from csp.impl.types.common_definitions import OutputBasket, Outputs
28+
from csp.impl.types.container_type_normalizer import ContainerTypeNormalizer
1229
from csp.impl.types.pydantic_type_resolver import TVarValidationContext
1330
from csp.impl.types.pydantic_types import DynamicBasketPydantic
1431
from csp.impl.types.tstype import TsType
32+
from csp.impl.types.typing_utils import FastList
33+
from csp.typing import Numpy1DArray, NumpyNDArray
1534

1635
T = TypeVar("T")
1736
U = TypeVar("U")
@@ -165,3 +184,117 @@ def test_validate(self):
165184
dynamic_basket = dynamic_demultiplex(csp.const(1.0), csp.const("A"))
166185
ta.validate_python(dynamic_basket)
167186
self.assertRaises(Exception, ta.validate_python, {csp.const("A"): csp.const(1.0)})
187+
188+
189+
class TestTsTypePep585Equivalence(TestCase):
190+
"""PEP 585 builtin generics (list[int], dict[str, int], ...) must normalize to the same TsType as
191+
their typing equivalents (typing.List[int], typing.Dict[str, int], ...) so that tooling that rewrites
192+
typing.List -> list (e.g. ruff UP006) does not change TsType equality/hashing."""
193+
194+
def test_equality_and_hash_parity(self):
195+
cases = [
196+
(list[int], List[int]),
197+
(dict[str, int], Dict[str, int]),
198+
(set[int], Set[int]),
199+
(tuple[int, str], Tuple[int, str]),
200+
(tuple[int, ...], Tuple[int, ...]),
201+
]
202+
for builtin, typing_form in cases:
203+
with self.subTest(builtin=builtin):
204+
self.assertEqual(ts[builtin], ts[typing_form])
205+
self.assertEqual(hash(ts[builtin]), hash(ts[typing_form]))
206+
207+
def test_nested_equality(self):
208+
self.assertEqual(ts[list[dict[str, int]]], ts[List[Dict[str, int]]])
209+
self.assertEqual(
210+
ts[dict[str, list[tuple[int, set[str]]]]],
211+
ts[Dict[str, List[Tuple[int, Set[str]]]]],
212+
)
213+
self.assertEqual(
214+
hash(ts[list[dict[str, int]]]),
215+
hash(ts[List[Dict[str, int]]]),
216+
)
217+
218+
def test_canonicalizes_to_typing_form(self):
219+
# The canonical inner type is the typing form (the representation csp inference already emits).
220+
self.assertEqual(ts[list[int]].typ, List[int])
221+
self.assertEqual(ts[dict[str, int]].typ, Dict[str, int])
222+
self.assertEqual(ts[set[int]].typ, Set[int])
223+
self.assertEqual(ts[tuple[int, str]].typ, Tuple[int, str])
224+
225+
def test_const_inference_matches_modernized_annotation(self):
226+
# csp.const infers typing.List[...]; a modernized (ruff UP006) annotation is ts[list[...]].
227+
# These previously compared unequal, which broke strict-equality checks (e.g. csp-gateway channels).
228+
self.assertEqual(csp.const([1, 2, 3]).tstype, ts[list[int]])
229+
self.assertEqual(csp.const({"a": 1}).tstype, ts[dict[str, int]])
230+
231+
def test_preserves_non_builtin_generics(self):
232+
# FastList / csp numpy array types share machinery with typing generics but must NOT be collapsed.
233+
self.assertIs(ContainerTypeNormalizer.normalize_type(FastList[int]).__origin__, FastList)
234+
self.assertIs(ContainerTypeNormalizer.normalize_type(Numpy1DArray[float]).__origin__, Numpy1DArray)
235+
self.assertIs(ContainerTypeNormalizer.normalize_type(NumpyNDArray[float]).__origin__, NumpyNDArray)
236+
237+
def test_node_binding_builtin_generic(self):
238+
# A builtin-generic-annotated node input accepts an edge whose inferred type uses the typing form.
239+
@csp.node
240+
def consume(x: ts[list[int]]) -> ts[int]:
241+
if csp.ticked(x):
242+
return len(x)
243+
244+
@csp.graph
245+
def g():
246+
csp.add_graph_output("o", consume(csp.const([1, 2, 3])))
247+
248+
results = csp.run(g, starttime=datetime(2020, 1, 1), endtime=timedelta(days=1))
249+
self.assertEqual(results["o"][0][1], 3)
250+
251+
def test_builtin_generic_nested_in_union(self):
252+
# A builtin container nested inside a union (typing.Optional or PEP 604 X | None) is canonicalized.
253+
self.assertEqual(ts[list[int] | None], ts[Optional[List[int]]])
254+
self.assertEqual(ts[Optional[list[int]]], ts[Optional[List[int]]])
255+
self.assertEqual(ts[dict[str, list[int]] | None], ts[Optional[Dict[str, List[int]]]])
256+
self.assertEqual(hash(ts[list[int] | None]), hash(ts[Optional[List[int]]]))
257+
258+
def test_builtin_generic_nested_in_preserved_wrapper(self):
259+
# The outer wrapper (Mapping / FastList / Callable) is preserved, but builtin containers nested
260+
# inside it are still canonicalized so equality holds after a typing.List -> list rewrite.
261+
self.assertEqual(ts[Mapping[str, list[int]]], ts[Mapping[str, List[int]]])
262+
self.assertEqual(ts[FastList[list[int]]], ts[FastList[List[int]]])
263+
self.assertEqual(ts[Callable[[list[int]], dict[str, int]]], ts[Callable[[List[int]], Dict[str, int]]])
264+
# ... and the wrapper origin is not collapsed into typing.List/dict/etc.
265+
self.assertIs(ContainerTypeNormalizer.normalize_type(FastList[list[int]]).__origin__, FastList)
266+
267+
def test_string_arg_canonicalizes_to_forward_ref(self):
268+
# PEP 585 builtins store a bare string arg (list["T"]) while typing stores a ForwardRef; both must
269+
# normalize to the same stable ForwardRef (not a freshly-minted TypeVar on each call).
270+
self.assertEqual(ts[list["T"]], ts[list["T"]])
271+
self.assertEqual(ts[list["T"]], ts[List["T"]])
272+
self.assertEqual(ContainerTypeNormalizer.normalize_type(list["T"]).__args__[0], ForwardRef("T"))
273+
274+
def test_identity_preserved_when_unchanged(self):
275+
# Nothing to canonicalize -> return the very same object, since some call sites (csp.snap
276+
# validation) compare normalized types with `is`.
277+
self.assertIs(ContainerTypeNormalizer.normalize_type(List[int]), List[int])
278+
self.assertIs(ContainerTypeNormalizer.normalize_type(List["Foo"]), List["Foo"])
279+
self.assertIs(ContainerTypeNormalizer.normalize_type(Optional[int]), Optional[int])
280+
self.assertIs(ContainerTypeNormalizer.normalize_type(Dict[str, List[int]]), Dict[str, List[int]])
281+
282+
def test_empty_tuple(self):
283+
self.assertEqual(ts[tuple[()]], ts[Tuple[()]])
284+
285+
def test_callable_inner_canonicalized(self):
286+
import collections.abc as abc
287+
288+
normalize = ContainerTypeNormalizer.normalize_type
289+
# typing.Callable: builtin containers nested in params/return canonicalize identically for both
290+
# spellings, so the modernized and classic annotations stay equal.
291+
self.assertEqual(
292+
normalize(Callable[[list[int]], dict[str, int]]),
293+
normalize(Callable[[List[int]], Dict[str, int]]),
294+
)
295+
# collections.abc.Callable (the ruff UP006 rewrite of typing.Callable) must reconstruct without
296+
# error for finite, empty and ellipsis parameter lists, canonicalizing nested builtins while
297+
# keeping its own origin.
298+
self.assertEqual(normalize(abc.Callable[[list[int]], str]), abc.Callable[[List[int]], str])
299+
self.assertEqual(normalize(abc.Callable[[], list[int]]), abc.Callable[[], List[int]])
300+
self.assertEqual(normalize(abc.Callable[..., list[int]]), abc.Callable[..., List[int]])

csp/tests/test_dynamic.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import itertools
2+
import os
23
import random
34
import string
45
import time
56
import unittest
67
from collections import defaultdict
78
from datetime import datetime, timedelta
8-
from typing import Dict, List
9+
from typing import Dict, List, Optional, Set
910

1011
import numpy
1112

1213
import csp
1314
from csp import ts
1415
from csp.utils.datetime import utc_now
1516

17+
USE_PYDANTIC = os.environ.get("CSP_PYDANTIC", True)
18+
1619

1720
class DynData(csp.Struct):
1821
key: str
@@ -188,7 +191,73 @@ def g():
188191
self.assertEqual(len(res[f"{key}_tsadj"]), ts_ticks)
189192
self.assertTrue(all(x[1].val * 2 == y[1] for x, y in zip(res[f"{key}_ts"], res[f"{key}_tsadj"])))
190193

191-
def test_shared_input(self):
194+
def test_snap_builtin_generic_scalar(self):
195+
# csp.snap of a container-typed edge into a scalar arg written with the PEP 585 builtin form
196+
# (list[str] rather than typing.List[str]) must still type-check.
197+
@csp.graph
198+
def dyn_graph(key: str, snapped: list[str]):
199+
csp.add_graph_output(f"{key}_snapped", csp.const(snapped))
200+
201+
def g():
202+
keys = csp.curve(List[str], [(timedelta(seconds=1), ["A", "B"])])
203+
basket = gen_basket(keys, csp.null_ts(List[str]))
204+
csp.dynamic(basket, dyn_graph, csp.snapkey(), csp.snap(keys))
205+
206+
res = csp.run(g, starttime=datetime(2021, 6, 22), endtime=timedelta(seconds=3))
207+
self.assertIn("A", res["A_snapped"][0][1])
208+
self.assertIn("B", res["B_snapped"][0][1])
209+
210+
def test_snap_builtin_generic_dict_scalar(self):
211+
# csp.snap of a dict-typed edge into a scalar arg written in the PEP 585 builtin form
212+
# (dict[str, int] rather than typing.Dict[str, int]) must still type-check.
213+
@csp.graph
214+
def dyn_graph(key: str, snapped: dict[str, int]):
215+
csp.add_graph_output(f"{key}_snapped", csp.const(snapped))
216+
217+
def g():
218+
keys = csp.curve(List[str], [(timedelta(seconds=1), ["A", "B"])])
219+
values = csp.curve(Dict[str, int], [(timedelta(seconds=1), {"A": 1, "B": 2})])
220+
basket = gen_basket(keys, csp.null_ts(List[str]))
221+
csp.dynamic(basket, dyn_graph, csp.snapkey(), csp.snap(values))
222+
223+
res = csp.run(g, starttime=datetime(2021, 6, 22), endtime=timedelta(seconds=3))
224+
self.assertEqual(res["A_snapped"][0][1], {"A": 1, "B": 2})
225+
self.assertEqual(res["B_snapped"][0][1], {"A": 1, "B": 2})
226+
227+
def test_snap_builtin_generic_set_scalar(self):
228+
# csp.snap of a set-typed edge into a scalar arg written in the PEP 585 builtin form
229+
# (set[str] rather than typing.Set[str]) must still type-check.
230+
@csp.graph
231+
def dyn_graph(key: str, snapped: set[str]):
232+
csp.add_graph_output(f"{key}_snapped", csp.const(snapped))
233+
234+
def g():
235+
keys = csp.curve(List[str], [(timedelta(seconds=1), ["A", "B"])])
236+
values = csp.curve(Set[str], [(timedelta(seconds=1), {"X", "Y"})])
237+
basket = gen_basket(keys, csp.null_ts(List[str]))
238+
csp.dynamic(basket, dyn_graph, csp.snapkey(), csp.snap(values))
239+
240+
res = csp.run(g, starttime=datetime(2021, 6, 22), endtime=timedelta(seconds=3))
241+
self.assertEqual(res["A_snapped"][0][1], {"X", "Y"})
242+
self.assertEqual(res["B_snapped"][0][1], {"X", "Y"})
243+
244+
@unittest.skipIf(USE_PYDANTIC, "csp.snap into a union-typed scalar is only supported by the legacy type resolver")
245+
def test_snap_builtin_generic_union_scalar(self):
246+
# A builtin generic nested inside a union scalar annotation (Optional[list[str]]) exercises
247+
# the union branch of the legacy type resolver, which must normalize list[str] to typing.List[str]
248+
# for the snapped edge type to match.
249+
@csp.graph
250+
def dyn_graph(key: str, snapped: Optional[list[str]]):
251+
csp.add_graph_output(f"{key}_snapped", csp.const(snapped))
252+
253+
def g():
254+
keys = csp.curve(List[str], [(timedelta(seconds=1), ["A", "B"])])
255+
basket = gen_basket(keys, csp.null_ts(List[str]))
256+
csp.dynamic(basket, dyn_graph, csp.snapkey(), csp.snap(keys))
257+
258+
res = csp.run(g, starttime=datetime(2021, 6, 22), endtime=timedelta(seconds=3))
259+
self.assertIn("A", res["A_snapped"][0][1])
260+
self.assertIn("B", res["B_snapped"][0][1])
192261
"""ensure an externally wired input is shared / not recreated per sub-graph"""
193262
instances = []
194263

0 commit comments

Comments
 (0)