Skip to content

Commit d2e54eb

Browse files
committed
Add a basic typed client
1 parent 2aa17da commit d2e54eb

File tree

5 files changed

+334
-0
lines changed

5 files changed

+334
-0
lines changed

examples/client.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#
2+
# Copyright (c) 2023-2025 - Restate Software, Inc., Restate GmbH
3+
#
4+
# This file is part of the Restate SDK for Python,
5+
# which is released under the MIT license.
6+
#
7+
# You can find a copy of the license in file LICENSE in the root
8+
# directory of this repository or package, or at
9+
# https://github.com/restatedev/sdk-typescript/blob/main/LICENSE
10+
#
11+
"""client.py"""
12+
# pylint: disable=C0116
13+
# pylint: disable=W0613
14+
15+
import restate
16+
17+
from greeter import greet
18+
19+
async def main():
20+
client = restate.create_client("http://localhost:8080")
21+
res = await client.service_call(greet, arg="World")
22+
print(res)
23+
24+
if __name__ == "__main__":
25+
import asyncio
26+
asyncio.run(main())

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Source = "https://github.com/restatedev/sdk-python"
2424
test = ["pytest", "hypercorn", "pytest-asyncio==1.1.0"]
2525
lint = ["mypy", "pylint"]
2626
harness = ["testcontainers", "hypercorn", "httpx"]
27+
client = ["httpx"]
2728
serde = ["dacite", "pydantic"]
2829

2930
[build-system]

python/restate/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
Restate SDK for Python
1313
"""
1414

15+
import typing
16+
1517
from .service import Service
1618
from .object import VirtualObject
1719
from .workflow import Workflow
@@ -26,6 +28,8 @@
2628

2729
from .endpoint import app
2830

31+
from .client_types import RestateClient, RestateClientSendHandle
32+
2933
try:
3034
from .harness import test_harness # type: ignore
3135
except ImportError:
@@ -35,6 +39,18 @@
3539
def test_harness(app, follow_logs = False, restate_image = ""): # type: ignore
3640
"""a dummy harness constructor to raise ImportError"""
3741
raise ImportError("Install restate-sdk[harness] to use this feature")
42+
43+
44+
45+
try:
46+
from .client import create_client # type: ignore
47+
except ImportError:
48+
# we don't have the appropriate dependencies installed
49+
50+
# pylint: disable=unused-argument, redefined-outer-name
51+
def create_client(ingress: str, headers: typing.Optional[dict] = None) -> "RestateClient": # type: ignore
52+
"""a dummy client constructor to raise ImportError"""
53+
raise ImportError("Install restate-sdk[client] to use this feature")
3854

3955
__all__ = [
4056
"Service",
@@ -54,6 +70,9 @@ def test_harness(app, follow_logs = False, restate_image = ""): # type: ignore
5470
"TerminalError",
5571
"app",
5672
"test_harness",
73+
"create_client",
74+
"RestateClient",
75+
"RestateClientSendHandle",
5776
"gather",
5877
"as_completed",
5978
"wait_completed",

python/restate/client.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#
2+
# Copyright (c) 2023-2025 - Restate Software, Inc., Restate GmbH
3+
#
4+
# This file is part of the Restate SDK for Python,
5+
# which is released under the MIT license.
6+
#
7+
# You can find a copy of the license in file LICENSE in the root
8+
# directory of this repository or package, or at
9+
# https://github.com/restatedev/sdk-typescript/blob/main/LICENSE
10+
#
11+
"""
12+
This is a basic remote client for the Restate service.
13+
"""
14+
15+
from datetime import timedelta
16+
import httpx
17+
import typing
18+
19+
from .client_types import RestateClient, RestateClientSendHandle
20+
21+
from .context import HandlerType
22+
from .serde import BytesSerde, JsonSerde, Serde
23+
from .handler import handler_from_callable
24+
25+
I = typing.TypeVar('I')
26+
O = typing.TypeVar('O')
27+
28+
class Client(RestateClient):
29+
"""
30+
A basic client for connecting to the Restate service.
31+
"""
32+
33+
def __init__(self, ingress: str, headers: typing.Optional[dict] = None):
34+
self.ingress = ingress
35+
self.headers = headers or {}
36+
self.client = httpx.AsyncClient(base_url=ingress, headers=self.headers)
37+
38+
39+
async def do_call(self,
40+
tpe: HandlerType[I, O],
41+
parameter: I,
42+
key: typing.Optional[str] = None,
43+
send_delay: typing.Optional[timedelta] = None,
44+
send: bool = False,
45+
idempotency_key: str | None = None,
46+
headers: typing.Dict[str,str] | None = None
47+
) -> O:
48+
"""Make an RPC call to the given handler"""
49+
target_handler = handler_from_callable(tpe)
50+
service=target_handler.service_tag.name
51+
handler=target_handler.name
52+
input_serde = target_handler.handler_io.input_serde
53+
output_serde = target_handler.handler_io.output_serde
54+
55+
content_type = target_handler.handler_io.accept
56+
if headers is None:
57+
headers = {}
58+
if headers.get('Content-Type') is None:
59+
headers['Content-Type'] = content_type
60+
61+
return await self.do_raw_call(service=service,
62+
handler=handler,
63+
input_param=parameter,
64+
input_serde=input_serde,
65+
output_serde=output_serde,
66+
key=key,
67+
send_delay=send_delay,
68+
send=send,
69+
idempotency_key=idempotency_key,
70+
headers=headers)
71+
72+
async def do_raw_call(self,
73+
service: str,
74+
handler:str,
75+
input_param: I,
76+
input_serde: Serde[I],
77+
output_serde: Serde[O],
78+
key: typing.Optional[str] = None,
79+
send_delay: typing.Optional[timedelta] = None,
80+
send: bool = False,
81+
idempotency_key: str | None = None,
82+
headers: typing.Dict[str, str] | None = None
83+
) -> O:
84+
"""Make an RPC call to the given handler"""
85+
parameter = input_serde.serialize(input_param)
86+
if headers is not None:
87+
headers_kvs = list(headers.items())
88+
else:
89+
headers_kvs = []
90+
if send_delay is not None:
91+
ms = int(send_delay.total_seconds() * 1000)
92+
else:
93+
ms = None
94+
95+
res = await self.post(service=service, handler=handler, send=send, content=parameter, headers=headers_kvs, key=key, delay=ms, idempotency_key=idempotency_key)
96+
return output_serde.deserialize(res) # type: ignore
97+
98+
async def post(self, /,
99+
service: str,
100+
handler: str,
101+
send: bool,
102+
content: bytes,
103+
headers: typing.List[typing.Tuple[(str, str)]] | None = None,
104+
key: str | None = None,
105+
delay: int | None = None,
106+
idempotency_key: str | None = None) -> bytes:
107+
"""
108+
Send a POST request to the Restate service.
109+
"""
110+
endpoint = service
111+
if key:
112+
endpoint += f"/{key}"
113+
endpoint += f"/{handler}"
114+
if send:
115+
endpoint += "/send"
116+
if delay is not None:
117+
endpoint = endpoint + f"?delay={delay}"
118+
dict_headers = dict(headers) if headers is not None else {}
119+
if idempotency_key is not None:
120+
dict_headers['Idempotency-Key'] = idempotency_key
121+
res = await self.client.post(endpoint,
122+
headers=dict_headers,
123+
content=content)
124+
res.raise_for_status()
125+
return res.content
126+
127+
128+
@typing.final
129+
@typing.override
130+
async def service_call(self,
131+
tpe: HandlerType[I, O],
132+
arg: I,
133+
idempotency_key: str | None = None,
134+
headers: typing.Dict[str, str] | None = None
135+
) -> O:
136+
coro = await self.do_call(tpe, arg, idempotency_key=idempotency_key, headers=headers)
137+
return coro
138+
139+
140+
@typing.final
141+
@typing.override
142+
async def object_call(self,
143+
tpe: HandlerType[I, O],
144+
key: str,
145+
arg: I,
146+
idempotency_key: str | None = None,
147+
headers: typing.Dict[str, str] | None = None
148+
) -> O:
149+
coro = await self.do_call(tpe, arg, key, idempotency_key=idempotency_key, headers=headers)
150+
return coro
151+
152+
153+
@typing.final
154+
@typing.override
155+
async def workflow_call(self,
156+
tpe: HandlerType[I, O],
157+
key: str,
158+
arg: I,
159+
idempotency_key: str | None = None,
160+
headers: typing.Dict[str, str] | None = None
161+
) -> O:
162+
return await self.object_call(tpe, key, arg, idempotency_key=idempotency_key, headers=headers)
163+
164+
165+
@typing.final
166+
@typing.override
167+
async def generic_call(self, service: str, handler: str, arg: bytes, key: str | None = None, idempotency_key: str | None = None, headers: typing.Dict[str, str] | None = None) -> bytes:
168+
serde = BytesSerde()
169+
call_handle = await self.do_raw_call(service=service,
170+
handler=handler,
171+
input_param=arg,
172+
input_serde=serde,
173+
output_serde=serde,
174+
key=key,
175+
idempotency_key=idempotency_key,
176+
headers=headers)
177+
return call_handle
178+
179+
@typing.final
180+
@typing.override
181+
async def generic_send(self, service: str, handler: str, arg: bytes, key: str | None = None, send_delay: timedelta | None = None, idempotency_key: str | None = None, headers: typing.Dict[str, str] | None = None) -> RestateClientSendHandle:
182+
serde = BytesSerde()
183+
output_serde: Serde[dict] = JsonSerde()
184+
185+
send_handle_json = await self.do_raw_call(service=service,
186+
handler=handler,
187+
input_param=arg,
188+
input_serde=serde,
189+
output_serde=output_serde,
190+
key=key,
191+
send_delay=send_delay,
192+
send=True,
193+
idempotency_key=idempotency_key,
194+
headers=headers)
195+
196+
return RestateClientSendHandle(send_handle_json.get('invocationId', ''), 200) # TODO: verify
197+
198+
199+
def create_client(ingress: str, headers: typing.Optional[dict] = None) -> RestateClient:
200+
"""
201+
Create a new Restate client.
202+
"""
203+
return Client(ingress, headers)
204+
205+
206+

python/restate/client_types.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#
2+
# Copyright (c) 2023-2025 - Restate Software, Inc., Restate GmbH
3+
#
4+
# This file is part of the Restate SDK for Python,
5+
# which is released under the MIT license.
6+
#
7+
# You can find a copy of the license in file LICENSE in the root
8+
# directory of this repository or package, or at
9+
# https://github.com/restatedev/sdk-typescript/blob/main/LICENSE
10+
#
11+
"""
12+
Type definitions for the Restate client.
13+
"""
14+
import abc
15+
from datetime import timedelta
16+
import typing
17+
18+
from .context import HandlerType
19+
20+
I = typing.TypeVar('I')
21+
O = typing.TypeVar('O')
22+
23+
class RestateClientSendHandle:
24+
"""
25+
A handle for a send operation.
26+
This is used to track the status of a send operation.
27+
"""
28+
def __init__(self, invocation_id: str, status_code: int):
29+
self.invocation_id = invocation_id
30+
self.status_code = status_code
31+
32+
class RestateClient(abc.ABC):
33+
"""
34+
An abstract base class for a Restate client.
35+
This class defines the interface for a Restate client.
36+
"""
37+
38+
@abc.abstractmethod
39+
async def service_call(self,
40+
tpe: HandlerType[I, O],
41+
arg: I,
42+
idempotency_key: str | None = None,
43+
headers: typing.Dict[str, str] | None = None) -> O:
44+
"""Make an RPC call to the given handler"""
45+
pass
46+
47+
@abc.abstractmethod
48+
async def object_call(self,
49+
tpe: HandlerType[I, O],
50+
key: str,
51+
arg: I,
52+
idempotency_key: str | None = None,
53+
headers: typing.Dict[str, str] | None = None) -> O:
54+
"""Make an RPC call to the given object handler"""
55+
pass
56+
57+
@abc.abstractmethod
58+
async def workflow_call(self,
59+
tpe: HandlerType[I, O],
60+
key: str,
61+
arg: I,
62+
idempotency_key: str | None = None,
63+
headers: typing.Dict[str, str] | None = None) -> O:
64+
"""Make an RPC call to the given workflow handler"""
65+
pass
66+
67+
@abc.abstractmethod
68+
async def generic_call(self, service: str, handler: str, arg: bytes,
69+
key: str | None = None,
70+
idempotency_key: str | None = None,
71+
headers: typing.Dict[str, str] | None = None) -> bytes:
72+
"""Make a generic RPC call to the given service and handler"""
73+
pass
74+
75+
@abc.abstractmethod
76+
async def generic_send(self, service: str, handler: str, arg: bytes,
77+
key: str | None = None,
78+
send_delay: timedelta | None = None,
79+
idempotency_key: str | None = None,
80+
headers: typing.Dict[str, str] | None = None) -> RestateClientSendHandle:
81+
"""Make a generic send operation to the given service and handler"""
82+
pass

0 commit comments

Comments
 (0)