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.
@@ -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,13 @@
1
+ """Erlang-style generic server actors."""
2
+
3
+ from .actor import Actor, ActorEnv, ActorRef, ActorRunResult, Msg, MsgCall, MsgCast
4
+
5
+ __all__ = [
6
+ "Actor",
7
+ "ActorEnv",
8
+ "ActorRef",
9
+ "ActorRunResult",
10
+ "Msg",
11
+ "MsgCall",
12
+ "MsgCast",
13
+ ]
@@ -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