Skip to content

Commit 42d780e

Browse files
Add support for Python tuple types (#193)
* Add test for tuples and update snapshots. * Add support for tuple types. * Update name of TypedDict to play better with upcoming Pyright versions. * Add a test for error cases in tuples. * Update snapshots.
1 parent 2af0e6b commit 42d780e

9 files changed

Lines changed: 94 additions & 6 deletions

File tree

python/src/typechat/_internal/ts_conversion/python_type_to_ts_nodes.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
StringTypeReferenceNode,
4949
ThisTypeReferenceNode,
5050
TopLevelDeclarationNode,
51+
TupleTypeNode,
5152
TypeAliasDeclarationNode,
5253
TypeNode,
5354
TypeParameterDeclarationNode,
@@ -225,8 +226,30 @@ def convert_to_type_node(py_type: object) -> TypeNode:
225226
if key_type_arg is not NumberTypeReferenceNode:
226227
key_type_arg = StringTypeReferenceNode
227228
return TypeReferenceNode(IdentifierNode("Record"), [key_type_arg, value_type_arg])
228-
229-
# TODO: tuple
229+
230+
if origin is tuple:
231+
# Note that when the type is `tuple[()]`,
232+
# `type_args` will be an empty tuple.
233+
# Which is nice, because we don't have to special-case anything!
234+
type_args = get_args(py_type)
235+
236+
if Ellipsis in type_args:
237+
if len(type_args) != 2:
238+
errors.append(
239+
f"The tuple type '{py_type}' is ill-formed. Tuples with an ellipsis can only take the form 'tuple[SomeType, ...]'."
240+
)
241+
return ArrayTypeNode(AnyTypeReferenceNode)
242+
243+
ellipsis_index = type_args.index(Ellipsis)
244+
if ellipsis_index != 1:
245+
errors.append(
246+
f"The tuple type '{py_type}' is ill-formed because the ellipsis (...) cannot be the first element."
247+
)
248+
return ArrayTypeNode(AnyTypeReferenceNode)
249+
250+
return ArrayTypeNode(convert_to_type_node(type_args[0]))
251+
252+
return TupleTypeNode([convert_to_type_node(py_type_arg) for py_type_arg in type_args])
230253

231254
if origin is Union or origin is UnionType:
232255
type_node = [convert_to_type_node(py_type_arg) for py_type_arg in get_args(py_type)]
@@ -249,7 +272,7 @@ def convert_to_type_node(py_type: object) -> TypeNode:
249272

250273
errors.append(f"'{py_type}' cannot be used as a type annotation.")
251274
return AnyTypeReferenceNode
252-
275+
253276
def declare_property(name: str, py_annotation: type | TypeAliasType, is_typeddict_attribute: bool, optionality_default: bool):
254277
"""
255278
Declare a property for a given type.

python/src/typechat/_internal/ts_conversion/ts_node_to_string.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
NullTypeReferenceNode,
1111
PropertyDeclarationNode,
1212
TopLevelDeclarationNode,
13+
TupleTypeNode,
1314
TypeAliasDeclarationNode,
1415
TypeNode,
1516
TypeReferenceNode,
@@ -38,6 +39,8 @@ def ts_type_to_str(type_node: TypeNode) -> str:
3839
# if type(element_type) is UnionTypeNode:
3940
# return f"Array<{ts_type_to_str(element_type)}>"
4041
return f"{ts_type_to_str(element_type)}[]"
42+
case TupleTypeNode(element_types):
43+
return f"[{', '.join([ts_type_to_str(element_type) for element_type in element_types])}]"
4144
case UnionTypeNode(types):
4245
# Remove duplicates, but try to preserve order of types,
4346
# and put null at the end if it's present.

python/src/typechat/_internal/ts_conversion/ts_type_nodes.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from dataclasses import dataclass
44
from typing_extensions import TypeAlias
55

6-
TypeNode: TypeAlias = "TypeReferenceNode | UnionTypeNode | LiteralTypeNode | ArrayTypeNode"
6+
TypeNode: TypeAlias = "TypeReferenceNode | UnionTypeNode | LiteralTypeNode | ArrayTypeNode | TupleTypeNode"
77

88
@dataclass
99
class IdentifierNode:
@@ -31,6 +31,10 @@ class LiteralTypeNode:
3131
class ArrayTypeNode:
3232
element_type: TypeNode
3333

34+
@dataclass
35+
class TupleTypeNode:
36+
element_types: list[TypeNode]
37+
3438
@dataclass
3539
class InterfaceDeclarationNode:
3640
name: str
File renamed without changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# serializer version: 1
2+
# name: test_tuples_2
3+
TypeScriptSchemaConversionResult(typescript_schema_str='interface TupleContainer {\n "empty_tuples_args_1": [any, any];\n "empty_tuples_args_2": any[];\n "arbitrary_length_1": any[];\n "arbitrary_length_2": any[];\n "arbitrary_length_3": any[];\n "arbitrary_length_4": any[];\n "arbitrary_length_5": any[];\n "arbitrary_length_6": any[];\n}\n', typescript_type_reference='TupleContainer', errors=["'()' cannot be used as a type annotation.", "'()' cannot be used as a type annotation.", "'()' cannot be used as a type annotation.", "The tuple type 'tuple[...]' is ill-formed. Tuples with an ellipsis can only take the form 'tuple[SomeType, ...]'.", "The tuple type 'tuple[int, int, ...]' is ill-formed. Tuples with an ellipsis can only take the form 'tuple[SomeType, ...]'.", "The tuple type 'tuple[..., int]' is ill-formed because the ellipsis (...) cannot be the first element.", "The tuple type 'tuple[..., ...]' is ill-formed because the ellipsis (...) cannot be the first element.", "The tuple type 'tuple[int, ..., int]' is ill-formed. Tuples with an ellipsis can only take the form 'tuple[SomeType, ...]'.", "The tuple type 'tuple[int, ..., int, ...]' is ill-formed. Tuples with an ellipsis can only take the form 'tuple[SomeType, ...]'."])
4+
# ---
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# serializer version: 1
2+
# name: test_tuples_1
3+
TypeScriptSchemaConversionResult(typescript_schema_str='interface TupleContainer {\n "empty_tuple": [];\n "tuple_1": [number];\n "tuple_2": [number, string];\n "tuple_3": [number, string];\n "arbitrary_length_1": number[];\n "arbitrary_length_2": number[];\n "arbitrary_length_3": number[];\n "arbitrary_length_4": number[];\n "arbitrary_length_5": number[] | [number];\n "arbitrary_length_6": number[] | [number] | [number, number];\n}\n', typescript_type_reference='TupleContainer', errors=[])
4+
# ---

python/tests/test_hello_world.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ class D(C[str], total=False):
1919
y: Required[Annotated[bool | None, "This comes from string metadata\nwithin an Annotated hint."]]
2020
z: Optional[list[int]]
2121
other: IndirectC
22-
non_class: "nonclass"
22+
non_class: "NonClass"
2323

2424
multiple_metadata: Annotated[str, None, str, "This comes from later metadata.", int]
2525

2626

27-
nonclass = TypedDict("NonClass", {"a": int, "my-dict": dict[str, int]})
27+
NonClass = TypedDict("NonClass", {"a": int, "my-dict": dict[str, int]})
2828

2929

3030
class E(C[str]):
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
2+
from dataclasses import dataclass
3+
from typing import Any
4+
5+
from typechat import python_type_to_typescript_schema
6+
7+
8+
@dataclass
9+
class TupleContainer:
10+
empty_tuples_args_1: tuple[(), ()] # type: ignore
11+
empty_tuples_args_2: tuple[(), ...] # type: ignore
12+
13+
# Arbitrary-length tuples have exactly two type arguments – the type and an ellipsis.
14+
# Any other tuple form that uses an ellipsis is invalid.
15+
arbitrary_length_1: tuple[...] # type: ignore
16+
arbitrary_length_2: tuple[int, int, ...] # type: ignore
17+
arbitrary_length_3: tuple[..., int] # type: ignore
18+
arbitrary_length_4: tuple[..., ...] # type: ignore
19+
arbitrary_length_5: tuple[int, ..., int] # type: ignore
20+
arbitrary_length_6: tuple[int, ..., int, ...] # type: ignore
21+
22+
def test_tuples_2(snapshot: Any):
23+
assert python_type_to_typescript_schema(TupleContainer) == snapshot

python/tests/test_tuples_1.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
2+
from dataclasses import dataclass
3+
from typing import Any
4+
5+
from typechat import python_type_to_typescript_schema
6+
7+
8+
@dataclass
9+
class TupleContainer:
10+
# The empty tuple can be annotated as tuple[()].
11+
empty_tuple: tuple[()]
12+
13+
tuple_1: tuple[int]
14+
tuple_2: tuple[int, str]
15+
tuple_3: tuple[int, str] | tuple[float, str]
16+
17+
18+
# Arbitrary-length homogeneous tuples can be expressed using one type and an ellipsis, for example tuple[int, ...].
19+
arbitrary_length_1: tuple[int, ...]
20+
arbitrary_length_2: tuple[int, ...] | list[int]
21+
arbitrary_length_3: tuple[int, ...] | tuple[int, ...]
22+
arbitrary_length_4: tuple[int, ...] | tuple[float, ...]
23+
arbitrary_length_5: tuple[int, ...] | tuple[int]
24+
arbitrary_length_6: tuple[int, ...] | tuple[int] | tuple[int, int]
25+
26+
def test_tuples_1(snapshot: Any):
27+
assert python_type_to_typescript_schema(TupleContainer) == snapshot

0 commit comments

Comments
 (0)