py_gen_server 0.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_gen_server-0.0.0/PKG-INFO +27 -0
- py_gen_server-0.0.0/README.md +17 -0
- py_gen_server-0.0.0/pyproject.toml +25 -0
- py_gen_server-0.0.0/src/py_gen_server/__init__.py +13 -0
- py_gen_server-0.0.0/src/py_gen_server/actor.py +258 -0
- py_gen_server-0.0.0/src/py_gen_server/actor_doc.md +104 -0
- py_gen_server-0.0.0/src/py_gen_server/py.typed +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: py_gen_server
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Erlang-style generic server (GenServer) actors
|
|
5
|
+
Author: Steven Hé (Sīchàng)
|
|
6
|
+
Author-email: Steven Hé (Sīchàng) <stevensichanghe@gmail.com>
|
|
7
|
+
Requires-Dist: aio-sync>=0.2.0
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# PyGenServer
|
|
12
|
+
|
|
13
|
+
TODO: summary
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
TODO
|
|
18
|
+
|
|
19
|
+
## Type checking
|
|
20
|
+
|
|
21
|
+
Add the following to `pyproject.toml` (enabled here) for Pyright to validate subclasses of `Actor`:
|
|
22
|
+
|
|
23
|
+
```toml
|
|
24
|
+
[tool.pyright]
|
|
25
|
+
typeCheckingMode = "basic"
|
|
26
|
+
reportIncompatibleMethodOverride = "error"
|
|
27
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# PyGenServer
|
|
2
|
+
|
|
3
|
+
TODO: summary
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
TODO
|
|
8
|
+
|
|
9
|
+
## Type checking
|
|
10
|
+
|
|
11
|
+
Add the following to `pyproject.toml` (enabled here) for Pyright to validate subclasses of `Actor`:
|
|
12
|
+
|
|
13
|
+
```toml
|
|
14
|
+
[tool.pyright]
|
|
15
|
+
typeCheckingMode = "basic"
|
|
16
|
+
reportIncompatibleMethodOverride = "error"
|
|
17
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "py_gen_server"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
description = "Erlang-style generic server (GenServer) actors"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Steven Hé (Sīchàng)", email = "stevensichanghe@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"aio-sync>=0.2.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.pyright]
|
|
15
|
+
typeCheckingMode = "basic"
|
|
16
|
+
reportIncompatibleMethodOverride = "error"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.9.15,<0.10.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=9.0.2",
|
|
25
|
+
]
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""An Elixir/Erlang-GenServer-like actor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from asyncio import FIRST_COMPLETED, QueueShutDown, Task, TaskGroup, create_task, wait
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from aio_sync.mpmc import MPMC, MPMCReceiver, MPMCSender, mpmc_channel
|
|
12
|
+
from aio_sync.oneshot import OneShot, OneShotSender
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class MsgCall[Call, Reply]:
|
|
17
|
+
"""A "call" message (request-reply)."""
|
|
18
|
+
|
|
19
|
+
msg: Call
|
|
20
|
+
reply_sender: OneShotSender[Reply]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class MsgCast[Cast]:
|
|
25
|
+
"""A "cast" message (fire-and-forget)."""
|
|
26
|
+
|
|
27
|
+
msg: Cast
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
type Msg[Call, Cast, Reply] = MsgCall[Call, Reply] | MsgCast[Cast]
|
|
31
|
+
"""A message sent to an actor."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class ActorRunResult[Call, Cast, Reply]:
|
|
36
|
+
"""The result when the `Actor` exits."""
|
|
37
|
+
|
|
38
|
+
actor: Actor[Call, Cast, Reply]
|
|
39
|
+
env: ActorEnv[Call, Cast, Reply]
|
|
40
|
+
exit_result: Exception | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class Env[Call, Cast, Reply]:
|
|
45
|
+
"""The environment the `Actor` runs in."""
|
|
46
|
+
|
|
47
|
+
ref_: ActorRef[Call, Cast, Reply]
|
|
48
|
+
msg_receiver: MPMCReceiver[Msg[Call, Cast, Reply]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
type ActorEnv[Call, Cast, Reply] = Env[Call, Cast, Reply]
|
|
52
|
+
"""The environment the `Actor` runs in."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class ActorRef[Call, Cast, Reply]:
|
|
57
|
+
"""A reference to an instance of `Actor`, to cast or call messages on it."""
|
|
58
|
+
|
|
59
|
+
msg_sender: MPMCSender[Msg[Call, Cast, Reply]]
|
|
60
|
+
actor_task: Task[ActorRunResult[Call, Cast, Reply]] | None = None
|
|
61
|
+
|
|
62
|
+
async def cast(self, msg: Cast) -> QueueShutDown | None:
|
|
63
|
+
"""Cast a message to the actor and do not expect a reply."""
|
|
64
|
+
return await self.msg_sender.send(MsgCast(msg))
|
|
65
|
+
|
|
66
|
+
async def call(self, msg: Call) -> Reply | QueueShutDown:
|
|
67
|
+
"""Call the actor and wait for a reply.
|
|
68
|
+
To time out the call, use `asyncio.wait_for`."""
|
|
69
|
+
reply_sender, reply_receiver = OneShot[Reply].channel()
|
|
70
|
+
send_err = await self.msg_sender.send(MsgCall(msg, reply_sender))
|
|
71
|
+
if send_err is not None:
|
|
72
|
+
return send_err
|
|
73
|
+
|
|
74
|
+
recv_task = create_task(reply_receiver.recv())
|
|
75
|
+
try:
|
|
76
|
+
if self.actor_task is None:
|
|
77
|
+
return await recv_task
|
|
78
|
+
done, _pending = await wait(
|
|
79
|
+
{recv_task, self.actor_task}, return_when=FIRST_COMPLETED
|
|
80
|
+
)
|
|
81
|
+
if recv_task in done:
|
|
82
|
+
return recv_task.result()
|
|
83
|
+
else:
|
|
84
|
+
try:
|
|
85
|
+
rr = self.actor_task.result()
|
|
86
|
+
except asyncio.CancelledError:
|
|
87
|
+
return QueueShutDown("Actor exited.")
|
|
88
|
+
if rr.exit_result is not None:
|
|
89
|
+
raise rr.exit_result
|
|
90
|
+
return QueueShutDown("Actor exited.")
|
|
91
|
+
finally:
|
|
92
|
+
recv_task.cancel()
|
|
93
|
+
|
|
94
|
+
async def relay_call(
|
|
95
|
+
self, msg: Call, reply_sender: OneShotSender[Reply]
|
|
96
|
+
) -> QueueShutDown | None:
|
|
97
|
+
"""Call the actor and let it reply via a given one-shot sender.
|
|
98
|
+
Useful for relaying a call from some other caller."""
|
|
99
|
+
return await self.msg_sender.send(MsgCall(msg, reply_sender))
|
|
100
|
+
|
|
101
|
+
def cancel(self) -> bool:
|
|
102
|
+
"""Cancel the actor referred to, so it exits, and does not wait for
|
|
103
|
+
it to exit.
|
|
104
|
+
@return True if the task was cancelled, False if it already finished or
|
|
105
|
+
never started."""
|
|
106
|
+
if self.actor_task is None:
|
|
107
|
+
return False
|
|
108
|
+
return self.actor_task.cancel()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Actor[Call, Cast, Reply](Protocol):
|
|
112
|
+
"""An Elixir/Erlang-GenServer-like actor"""
|
|
113
|
+
|
|
114
|
+
async def init(self, _env: ActorEnv[Call, Cast, Reply]) -> Exception | None:
|
|
115
|
+
"""Called when the actor starts.
|
|
116
|
+
# Snippet for copying
|
|
117
|
+
```py
|
|
118
|
+
async def init(self, env: ActorEnv[Call, Cast, Reply]) -> None:
|
|
119
|
+
return
|
|
120
|
+
```
|
|
121
|
+
"""
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
async def handle_cast(
|
|
125
|
+
self, _msg: Cast, _env: ActorEnv[Call, Cast, Reply]
|
|
126
|
+
) -> Exception | None:
|
|
127
|
+
"""Called when the actor receives a message and does not need to reply.
|
|
128
|
+
# Snippet for copying
|
|
129
|
+
```py
|
|
130
|
+
async def handle_cast(self, msg: Cast, env: ActorEnv[Call, Cast, Reply]) -> None:
|
|
131
|
+
return
|
|
132
|
+
```
|
|
133
|
+
"""
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
async def handle_call(
|
|
137
|
+
self,
|
|
138
|
+
_msg: Call,
|
|
139
|
+
_env: ActorEnv[Call, Cast, Reply],
|
|
140
|
+
_reply_sender: OneShotSender[Reply],
|
|
141
|
+
) -> Exception | None:
|
|
142
|
+
"""Called when the actor receives a message and needs to reply.
|
|
143
|
+
|
|
144
|
+
Implementations should send the reply using `reply_sender`, otherwise the caller
|
|
145
|
+
may hang.
|
|
146
|
+
# Snippet for copying
|
|
147
|
+
```py
|
|
148
|
+
async def handle_call(
|
|
149
|
+
self,
|
|
150
|
+
msg: Call,
|
|
151
|
+
env: ActorEnv[Call, Cast, Reply],
|
|
152
|
+
reply_sender: OneShotSender[Reply],
|
|
153
|
+
) -> None:
|
|
154
|
+
reply_sender.send(...)
|
|
155
|
+
```
|
|
156
|
+
"""
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
async def before_exit(
|
|
160
|
+
self,
|
|
161
|
+
run_result: Exception | None,
|
|
162
|
+
_env: ActorEnv[Call, Cast, Reply],
|
|
163
|
+
) -> Exception | None:
|
|
164
|
+
"""Called before the actor exits.
|
|
165
|
+
There are 3 cases when this method is called:
|
|
166
|
+
- The actor task is cancelled. `run_result` is `None`.
|
|
167
|
+
- All message senders are closed / channel is shut down.
|
|
168
|
+
`run_result` is `None`.
|
|
169
|
+
- `init`, `handle_cast`, or `handle_call` returned an exception or
|
|
170
|
+
raised. `run_result` is that exception.
|
|
171
|
+
|
|
172
|
+
This method's return value becomes `ActorRunResult.exit_result`.
|
|
173
|
+
|
|
174
|
+
# Snippet for copying
|
|
175
|
+
```py
|
|
176
|
+
async def before_exit(self, run_result: Exception | None, env: ActorEnv[Call, Cast, Reply]) -> Exception | None:
|
|
177
|
+
return run_result
|
|
178
|
+
```
|
|
179
|
+
"""
|
|
180
|
+
return run_result
|
|
181
|
+
|
|
182
|
+
async def _handle_call_or_cast(
|
|
183
|
+
self, msg: Msg[Call, Cast, Reply], env: ActorEnv[Call, Cast, Reply]
|
|
184
|
+
) -> Exception | None:
|
|
185
|
+
match msg:
|
|
186
|
+
case MsgCall(msg=call, reply_sender=reply_sender):
|
|
187
|
+
return await self.handle_call(call, env, reply_sender)
|
|
188
|
+
case MsgCast(msg=cast):
|
|
189
|
+
return await self.handle_cast(cast, env)
|
|
190
|
+
|
|
191
|
+
async def _handle_continuously(
|
|
192
|
+
self, env: ActorEnv[Call, Cast, Reply]
|
|
193
|
+
) -> Exception | None:
|
|
194
|
+
while not isinstance(msg := await env.msg_receiver.recv(), QueueShutDown):
|
|
195
|
+
if (err := await self._handle_call_or_cast(msg, env)) is not None:
|
|
196
|
+
return err
|
|
197
|
+
|
|
198
|
+
async def _run_till_exit(
|
|
199
|
+
self, env: ActorEnv[Call, Cast, Reply]
|
|
200
|
+
) -> Exception | None:
|
|
201
|
+
if (err := await self.init(env)) is not None:
|
|
202
|
+
return err
|
|
203
|
+
return await self._handle_continuously(env)
|
|
204
|
+
|
|
205
|
+
async def _run_and_handle_exit(
|
|
206
|
+
self, env: ActorEnv[Call, Cast, Reply]
|
|
207
|
+
) -> Exception | None:
|
|
208
|
+
run_result: Exception | None = None
|
|
209
|
+
try:
|
|
210
|
+
run_result = await self._run_till_exit(env)
|
|
211
|
+
except asyncio.CancelledError:
|
|
212
|
+
pass
|
|
213
|
+
except Exception as err:
|
|
214
|
+
run_result = err
|
|
215
|
+
env.msg_receiver.shutdown(immediate=False)
|
|
216
|
+
try:
|
|
217
|
+
return await self.before_exit(run_result, env)
|
|
218
|
+
finally:
|
|
219
|
+
env.msg_receiver.shutdown(immediate=True)
|
|
220
|
+
|
|
221
|
+
def spawn(
|
|
222
|
+
self,
|
|
223
|
+
channel: MPMC[Msg[Call, Cast, Reply]] | None = None,
|
|
224
|
+
task_group: TaskGroup | None = None,
|
|
225
|
+
) -> ActorRef[Call, Cast, Reply]:
|
|
226
|
+
"""Spawn the actor in an asyncio task.
|
|
227
|
+
|
|
228
|
+
`channel` can be:
|
|
229
|
+
- `None`: create an unbounded `MPMC`
|
|
230
|
+
- `MPMC`: reuse an existing channel
|
|
231
|
+
"""
|
|
232
|
+
match channel:
|
|
233
|
+
case None:
|
|
234
|
+
msg_sender, msg_receiver = mpmc_channel()
|
|
235
|
+
case MPMC(sender=sender, receiver=receiver):
|
|
236
|
+
msg_sender, msg_receiver = sender, receiver
|
|
237
|
+
actor_ref = ActorRef[Call, Cast, Reply](msg_sender)
|
|
238
|
+
env: ActorEnv[Call, Cast, Reply] = Env(actor_ref, msg_receiver)
|
|
239
|
+
|
|
240
|
+
async def _runner() -> ActorRunResult[Call, Cast, Reply]:
|
|
241
|
+
exit_result = await self._run_and_handle_exit(env)
|
|
242
|
+
return ActorRunResult(actor=self, env=env, exit_result=exit_result)
|
|
243
|
+
|
|
244
|
+
actor_ref.actor_task = (
|
|
245
|
+
asyncio.create_task(_runner())
|
|
246
|
+
if task_group is None
|
|
247
|
+
else task_group.create_task(_runner())
|
|
248
|
+
)
|
|
249
|
+
return actor_ref
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
_DOC_PATH = Path(__file__).with_name("actor_doc.md")
|
|
253
|
+
try:
|
|
254
|
+
_DOC = _DOC_PATH.read_text(encoding="utf-8")
|
|
255
|
+
__doc__ = _DOC
|
|
256
|
+
Actor.__doc__ = _DOC
|
|
257
|
+
except OSError:
|
|
258
|
+
pass
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<!-- DO NOT modify manually! Keep this file very close to tokio_gen_server/src/actor_doc.md. -->
|
|
2
|
+
# An Elixir/Erlang-GenServer-like actor
|
|
3
|
+
|
|
4
|
+
Define 3 message types and at least one callback handler on your class to make it an actor.
|
|
5
|
+
|
|
6
|
+
A GenServer-like actor simply receives messages and acts upon them.
|
|
7
|
+
A message is either a "call" (request-reply) or a "cast" (fire-and-forget).
|
|
8
|
+
Upon a "call" message, we call `Actor.handle_call`;
|
|
9
|
+
upon a "cast" message, we call `Actor.handle_cast`.
|
|
10
|
+
Upon cancellation or error, we call `Actor.before_exit`,
|
|
11
|
+
so you can gracefully shut down.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
1. Define your actor class that stores your states and implement `Actor`.
|
|
16
|
+
1. Declare your message types.
|
|
17
|
+
- If your actor does not expect any "cast", set `Cast` to `object`.
|
|
18
|
+
- If your actor does not expect any "call", set both `Call` and `Reply` to `object`.
|
|
19
|
+
> Tip: use your editor to automatically generate "required fields".
|
|
20
|
+
1. Implement `handle_call` and/or `handle_cast`.
|
|
21
|
+
> Tip: use your editor to automatically generate "provided implementations",
|
|
22
|
+
> then hover on the methods you need and copy the snippets in their docstrings.
|
|
23
|
+
1. Implement `init` and `before_exit` if needed.
|
|
24
|
+
1. Spawn your actor with `Actor.spawn` (or module-level `spawn`) and get `(handle, actor_ref)`.
|
|
25
|
+
1. Use `ActorRef` to send messages to your actor.
|
|
26
|
+
|
|
27
|
+
## Example
|
|
28
|
+
|
|
29
|
+
```py
|
|
30
|
+
import asyncio
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from enum import Enum, auto
|
|
33
|
+
|
|
34
|
+
from py_gen_server import Actor, ActorEnv
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PingOrBang(Enum):
|
|
38
|
+
Ping = auto()
|
|
39
|
+
Bang = auto()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PingOrPong(Enum):
|
|
43
|
+
Ping = auto()
|
|
44
|
+
Pong = auto()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Count:
|
|
49
|
+
counter: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
type PongOrCount = str | Count
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PingPongServer(Actor[PingOrPong, PingOrBang, PongOrCount]):
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self.counter = 0
|
|
58
|
+
|
|
59
|
+
async def init(self, _env: ActorEnv[PingOrPong, PingOrBang, PongOrCount]) -> Exception | None:
|
|
60
|
+
print("PingPongServer starting.")
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
async def handle_cast(
|
|
64
|
+
self,
|
|
65
|
+
msg: PingOrBang,
|
|
66
|
+
_env: ActorEnv[PingOrPong, PingOrBang, PongOrCount],
|
|
67
|
+
) -> Exception | None:
|
|
68
|
+
if msg is PingOrBang.Bang:
|
|
69
|
+
return ValueError("Received Bang! Blowing up.")
|
|
70
|
+
self.counter += 1
|
|
71
|
+
print(f"Received ping #{self.counter}")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
async def handle_call(
|
|
75
|
+
self,
|
|
76
|
+
msg: PingOrPong,
|
|
77
|
+
_env: ActorEnv[PingOrPong, PingOrBang, PongOrCount],
|
|
78
|
+
reply_sender,
|
|
79
|
+
) -> Exception | None:
|
|
80
|
+
match msg:
|
|
81
|
+
case PingOrPong.Ping:
|
|
82
|
+
self.counter += 1
|
|
83
|
+
print(f"Received ping #{self.counter} as a call")
|
|
84
|
+
reply_sender.send("pong")
|
|
85
|
+
case PingOrPong.Pong:
|
|
86
|
+
reply_sender.send(Count(self.counter))
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def main() -> None:
|
|
91
|
+
handle, server_ref = PingPongServer().spawn()
|
|
92
|
+
_ = await server_ref.cast(PingOrBang.Ping)
|
|
93
|
+
pong = await server_ref.call(PingOrPong.Ping)
|
|
94
|
+
assert pong == "pong"
|
|
95
|
+
count = await server_ref.call(PingOrPong.Pong)
|
|
96
|
+
assert count == Count(2)
|
|
97
|
+
server_ref.cancel()
|
|
98
|
+
async with asyncio.timeout(0.1):
|
|
99
|
+
rr = await handle
|
|
100
|
+
assert rr.exit_result is None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
asyncio.run(main())
|
|
104
|
+
```
|
|
File without changes
|