mindmesh 0.1.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.
- mindmesh-0.1.0/PKG-INFO +250 -0
- mindmesh-0.1.0/README.md +231 -0
- mindmesh-0.1.0/pyproject.toml +50 -0
- mindmesh-0.1.0/src/mindmesh/__init__.py +15 -0
- mindmesh-0.1.0/src/mindmesh/actor.py +294 -0
- mindmesh-0.1.0/src/mindmesh/core.py +50 -0
- mindmesh-0.1.0/src/mindmesh/hive.py +233 -0
- mindmesh-0.1.0/src/mindmesh/proxy.py +69 -0
- mindmesh-0.1.0/src/mindmesh/py.typed +0 -0
- mindmesh-0.1.0/src/mindmesh/registry.py +119 -0
mindmesh-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mindmesh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Actor system for asyncio environments
|
|
5
|
+
Keywords: asyncio,actors,distributed,mesh,concurrency
|
|
6
|
+
Author: Marcin Glinski
|
|
7
|
+
Author-email: Marcin Glinski <undefinedlamb@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# mindmesh
|
|
21
|
+
|
|
22
|
+
A minimal, dependency-free actor system for asyncio.
|
|
23
|
+
|
|
24
|
+
- asyncio-native, zero external dependencies
|
|
25
|
+
- fire-and-forget (tell) and request/response (ask) messaging
|
|
26
|
+
- automatic message dispatch by message type
|
|
27
|
+
- actor lifecycle hooks and background task support
|
|
28
|
+
- actor linking and supervision (restart on death, stop propagation)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
pip install mindmesh
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
Or with uv:
|
|
37
|
+
|
|
38
|
+
uv add mindmesh
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from mindmesh import ActorHive, BaseActor, Request
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Greet(Request[str]):
|
|
50
|
+
name: str
|
|
51
|
+
|
|
52
|
+
class GreeterActor(BaseActor):
|
|
53
|
+
def on_greet(self, msg: Greet) -> str:
|
|
54
|
+
return f"Hello, {msg.name}!"
|
|
55
|
+
|
|
56
|
+
async def main():
|
|
57
|
+
hive = ActorHive()
|
|
58
|
+
greeter = hive.start_actor(GreeterActor)
|
|
59
|
+
await greeter.wait_for_start()
|
|
60
|
+
|
|
61
|
+
reply = await greeter.ask(Greet(name="world"))
|
|
62
|
+
print(reply) # Hello, world!
|
|
63
|
+
|
|
64
|
+
await hive.shutdown()
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Core Concepts
|
|
70
|
+
|
|
71
|
+
### BaseActor
|
|
72
|
+
|
|
73
|
+
Subclass `BaseActor` to define an actor. Each actor owns a private asyncio
|
|
74
|
+
queue (its mailbox) and processes one message at a time - no parallelism
|
|
75
|
+
within a single actor.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
class MyActor(BaseActor):
|
|
79
|
+
def __init__(self, hive: ActorHive, id: str, extra_arg: int):
|
|
80
|
+
super().__init__(hive, id)
|
|
81
|
+
self._extra = extra_arg
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`hive` and `id` are always the first two constructor parameters; any additional
|
|
85
|
+
arguments are passed by the caller via `hive.start_actor()`.
|
|
86
|
+
|
|
87
|
+
### ActorHive
|
|
88
|
+
|
|
89
|
+
`ActorHive` is the registry and lifecycle manager. It creates, starts, and
|
|
90
|
+
stops actors.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
hive = ActorHive()
|
|
94
|
+
addr = hive.start_actor(MyActor, extra_arg) # returns ActorAddr
|
|
95
|
+
await hive.shutdown() # stops all actors
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### ActorAddr
|
|
99
|
+
|
|
100
|
+
`ActorAddr` is an opaque handle to an actor. It is the only way to interact
|
|
101
|
+
with an actor from outside. Never hold a direct reference to an actor instance.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
addr = hive.start_actor(MyActor)
|
|
105
|
+
addr.tell(SomeMessage())
|
|
106
|
+
result = await addr.ask(SomeRequest())
|
|
107
|
+
addr.stop()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Messaging
|
|
111
|
+
|
|
112
|
+
### tell
|
|
113
|
+
|
|
114
|
+
Enqueues any object as a fire-and-forget message. Returns immediately.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
addr.tell(SomeMessage(payload=42))
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### ask
|
|
121
|
+
|
|
122
|
+
Enqueues a `Request` subclass and returns an awaitable that resolves to the
|
|
123
|
+
handler's return value. Exceptions raised in the handler are re-raised in the
|
|
124
|
+
caller.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
@dataclass
|
|
128
|
+
class AddRequest(Request[int]):
|
|
129
|
+
a: int
|
|
130
|
+
b: int
|
|
131
|
+
|
|
132
|
+
result = await addr.ask(AddRequest(a=1, b=2)) # -> int
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Message dispatch
|
|
136
|
+
|
|
137
|
+
`BaseActor.on_message()` routes incoming messages automatically. For a message
|
|
138
|
+
of class `FooBar`, it calls `self.on_foo_bar(msg)`. You only need to define
|
|
139
|
+
handler methods -- no manual dispatch boilerplate. This is default (optional) behavior,
|
|
140
|
+
feel free to reimplement `BaseActor.on_message()` for your liking.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
class MyActor(BaseActor):
|
|
144
|
+
def on_foo_bar(self, msg: FooBar) -> None: ...
|
|
145
|
+
def on_add_request(self, msg: AddRequest) -> int: ...
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Handler methods may be plain or async.
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
## Lifecycle Hooks
|
|
152
|
+
|
|
153
|
+
Override any of these on your actor class:
|
|
154
|
+
|
|
155
|
+
on_start()
|
|
156
|
+
Called before the mailbox loop begins.
|
|
157
|
+
Use it to initialize resources or start child actors.
|
|
158
|
+
|
|
159
|
+
on_stop()
|
|
160
|
+
Called after the mailbox loop exits, regardless of the stop reason.
|
|
161
|
+
Use it to clean up resources.
|
|
162
|
+
|
|
163
|
+
on_task_create()
|
|
164
|
+
Return a coroutine to run as a background task alongside the mailbox
|
|
165
|
+
loop. If the background task raises an unhandled exception, the actor
|
|
166
|
+
stops.
|
|
167
|
+
|
|
168
|
+
on_link_death(actor_id: str, reason: StopReasonType) -> LinkAction
|
|
169
|
+
Called when a monitored actor stops. Return LinkAction.Stop (default)
|
|
170
|
+
to stop this actor too, or LinkAction.Continue to keep running.
|
|
171
|
+
|
|
172
|
+
All hooks may be async.
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
## Supervision and Linking
|
|
176
|
+
|
|
177
|
+
Actors can monitor each other. When a monitored actor stops, the monitoring
|
|
178
|
+
actor's `on_link_death()` hook is called.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
# source stops -> monitor.on_link_death() is called
|
|
182
|
+
hive.link_actors(source_addr, monitor_addr)
|
|
183
|
+
|
|
184
|
+
# bidirectional: each monitors the other
|
|
185
|
+
hive.link_actors_both(addr_a, addr_b)
|
|
186
|
+
|
|
187
|
+
# monitor from within an actor (self monitors another)
|
|
188
|
+
self.as_ref().monitor(other_addr)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Stop reasons
|
|
192
|
+
|
|
193
|
+
`on_link_death` receives one of:
|
|
194
|
+
|
|
195
|
+
StopReason.Stop -- actor stopped normally
|
|
196
|
+
StopReason.Shutdown -- hive-wide shutdown
|
|
197
|
+
StopReason.LinkDeath -- a linked actor died and propagated the stop
|
|
198
|
+
<exception instance> -- actor crashed with an unhandled exception
|
|
199
|
+
|
|
200
|
+
### Restarting crashed actors
|
|
201
|
+
|
|
202
|
+
To restart a worker that crashes, return `LinkAction.Continue` from
|
|
203
|
+
`on_link_death` and start a replacement:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
async def on_link_death(self, actor_id: str, reason: StopReasonType) -> LinkAction:
|
|
207
|
+
replacement = self.hive.start_actor(WorkerActor)
|
|
208
|
+
self.as_ref().monitor(replacement)
|
|
209
|
+
return LinkAction.Continue
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Examples
|
|
213
|
+
|
|
214
|
+
**examples/calculator.py**: A calculator actor that handles arithmetic requests
|
|
215
|
+
via `ask()` and receives periodic random values via `tell()` from a background
|
|
216
|
+
task.
|
|
217
|
+
|
|
218
|
+
**examples/supervisor.py**: A supervisor that manages a pool of workers, round-robins
|
|
219
|
+
jobs to them, and automatically restarts any worker that crashes.
|
|
220
|
+
|
|
221
|
+
Run an example:
|
|
222
|
+
|
|
223
|
+
uv run examples/calculator.py
|
|
224
|
+
uv run examples/supervisor.py
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
## Development
|
|
228
|
+
|
|
229
|
+
Run tests:
|
|
230
|
+
|
|
231
|
+
uv run pytest
|
|
232
|
+
|
|
233
|
+
Lint and type check:
|
|
234
|
+
|
|
235
|
+
uv run ruff check --fix
|
|
236
|
+
uv run pyright
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT - see LICENSE file.
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
## Use of LLMs
|
|
245
|
+
|
|
246
|
+
This project has been made by the use of LLMs in following areas:
|
|
247
|
+
|
|
248
|
+
- Code review
|
|
249
|
+
- Preparing test cases for discovered issues
|
|
250
|
+
- Preparing and verifying documentation
|
mindmesh-0.1.0/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# mindmesh
|
|
2
|
+
|
|
3
|
+
A minimal, dependency-free actor system for asyncio.
|
|
4
|
+
|
|
5
|
+
- asyncio-native, zero external dependencies
|
|
6
|
+
- fire-and-forget (tell) and request/response (ask) messaging
|
|
7
|
+
- automatic message dispatch by message type
|
|
8
|
+
- actor lifecycle hooks and background task support
|
|
9
|
+
- actor linking and supervision (restart on death, stop propagation)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
pip install mindmesh
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Or with uv:
|
|
18
|
+
|
|
19
|
+
uv add mindmesh
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import asyncio
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from mindmesh import ActorHive, BaseActor, Request
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Greet(Request[str]):
|
|
31
|
+
name: str
|
|
32
|
+
|
|
33
|
+
class GreeterActor(BaseActor):
|
|
34
|
+
def on_greet(self, msg: Greet) -> str:
|
|
35
|
+
return f"Hello, {msg.name}!"
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
hive = ActorHive()
|
|
39
|
+
greeter = hive.start_actor(GreeterActor)
|
|
40
|
+
await greeter.wait_for_start()
|
|
41
|
+
|
|
42
|
+
reply = await greeter.ask(Greet(name="world"))
|
|
43
|
+
print(reply) # Hello, world!
|
|
44
|
+
|
|
45
|
+
await hive.shutdown()
|
|
46
|
+
|
|
47
|
+
asyncio.run(main())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Core Concepts
|
|
51
|
+
|
|
52
|
+
### BaseActor
|
|
53
|
+
|
|
54
|
+
Subclass `BaseActor` to define an actor. Each actor owns a private asyncio
|
|
55
|
+
queue (its mailbox) and processes one message at a time - no parallelism
|
|
56
|
+
within a single actor.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
class MyActor(BaseActor):
|
|
60
|
+
def __init__(self, hive: ActorHive, id: str, extra_arg: int):
|
|
61
|
+
super().__init__(hive, id)
|
|
62
|
+
self._extra = extra_arg
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`hive` and `id` are always the first two constructor parameters; any additional
|
|
66
|
+
arguments are passed by the caller via `hive.start_actor()`.
|
|
67
|
+
|
|
68
|
+
### ActorHive
|
|
69
|
+
|
|
70
|
+
`ActorHive` is the registry and lifecycle manager. It creates, starts, and
|
|
71
|
+
stops actors.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
hive = ActorHive()
|
|
75
|
+
addr = hive.start_actor(MyActor, extra_arg) # returns ActorAddr
|
|
76
|
+
await hive.shutdown() # stops all actors
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### ActorAddr
|
|
80
|
+
|
|
81
|
+
`ActorAddr` is an opaque handle to an actor. It is the only way to interact
|
|
82
|
+
with an actor from outside. Never hold a direct reference to an actor instance.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
addr = hive.start_actor(MyActor)
|
|
86
|
+
addr.tell(SomeMessage())
|
|
87
|
+
result = await addr.ask(SomeRequest())
|
|
88
|
+
addr.stop()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Messaging
|
|
92
|
+
|
|
93
|
+
### tell
|
|
94
|
+
|
|
95
|
+
Enqueues any object as a fire-and-forget message. Returns immediately.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
addr.tell(SomeMessage(payload=42))
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### ask
|
|
102
|
+
|
|
103
|
+
Enqueues a `Request` subclass and returns an awaitable that resolves to the
|
|
104
|
+
handler's return value. Exceptions raised in the handler are re-raised in the
|
|
105
|
+
caller.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
@dataclass
|
|
109
|
+
class AddRequest(Request[int]):
|
|
110
|
+
a: int
|
|
111
|
+
b: int
|
|
112
|
+
|
|
113
|
+
result = await addr.ask(AddRequest(a=1, b=2)) # -> int
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Message dispatch
|
|
117
|
+
|
|
118
|
+
`BaseActor.on_message()` routes incoming messages automatically. For a message
|
|
119
|
+
of class `FooBar`, it calls `self.on_foo_bar(msg)`. You only need to define
|
|
120
|
+
handler methods -- no manual dispatch boilerplate. This is default (optional) behavior,
|
|
121
|
+
feel free to reimplement `BaseActor.on_message()` for your liking.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
class MyActor(BaseActor):
|
|
125
|
+
def on_foo_bar(self, msg: FooBar) -> None: ...
|
|
126
|
+
def on_add_request(self, msg: AddRequest) -> int: ...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Handler methods may be plain or async.
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
## Lifecycle Hooks
|
|
133
|
+
|
|
134
|
+
Override any of these on your actor class:
|
|
135
|
+
|
|
136
|
+
on_start()
|
|
137
|
+
Called before the mailbox loop begins.
|
|
138
|
+
Use it to initialize resources or start child actors.
|
|
139
|
+
|
|
140
|
+
on_stop()
|
|
141
|
+
Called after the mailbox loop exits, regardless of the stop reason.
|
|
142
|
+
Use it to clean up resources.
|
|
143
|
+
|
|
144
|
+
on_task_create()
|
|
145
|
+
Return a coroutine to run as a background task alongside the mailbox
|
|
146
|
+
loop. If the background task raises an unhandled exception, the actor
|
|
147
|
+
stops.
|
|
148
|
+
|
|
149
|
+
on_link_death(actor_id: str, reason: StopReasonType) -> LinkAction
|
|
150
|
+
Called when a monitored actor stops. Return LinkAction.Stop (default)
|
|
151
|
+
to stop this actor too, or LinkAction.Continue to keep running.
|
|
152
|
+
|
|
153
|
+
All hooks may be async.
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
## Supervision and Linking
|
|
157
|
+
|
|
158
|
+
Actors can monitor each other. When a monitored actor stops, the monitoring
|
|
159
|
+
actor's `on_link_death()` hook is called.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
# source stops -> monitor.on_link_death() is called
|
|
163
|
+
hive.link_actors(source_addr, monitor_addr)
|
|
164
|
+
|
|
165
|
+
# bidirectional: each monitors the other
|
|
166
|
+
hive.link_actors_both(addr_a, addr_b)
|
|
167
|
+
|
|
168
|
+
# monitor from within an actor (self monitors another)
|
|
169
|
+
self.as_ref().monitor(other_addr)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Stop reasons
|
|
173
|
+
|
|
174
|
+
`on_link_death` receives one of:
|
|
175
|
+
|
|
176
|
+
StopReason.Stop -- actor stopped normally
|
|
177
|
+
StopReason.Shutdown -- hive-wide shutdown
|
|
178
|
+
StopReason.LinkDeath -- a linked actor died and propagated the stop
|
|
179
|
+
<exception instance> -- actor crashed with an unhandled exception
|
|
180
|
+
|
|
181
|
+
### Restarting crashed actors
|
|
182
|
+
|
|
183
|
+
To restart a worker that crashes, return `LinkAction.Continue` from
|
|
184
|
+
`on_link_death` and start a replacement:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
async def on_link_death(self, actor_id: str, reason: StopReasonType) -> LinkAction:
|
|
188
|
+
replacement = self.hive.start_actor(WorkerActor)
|
|
189
|
+
self.as_ref().monitor(replacement)
|
|
190
|
+
return LinkAction.Continue
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Examples
|
|
194
|
+
|
|
195
|
+
**examples/calculator.py**: A calculator actor that handles arithmetic requests
|
|
196
|
+
via `ask()` and receives periodic random values via `tell()` from a background
|
|
197
|
+
task.
|
|
198
|
+
|
|
199
|
+
**examples/supervisor.py**: A supervisor that manages a pool of workers, round-robins
|
|
200
|
+
jobs to them, and automatically restarts any worker that crashes.
|
|
201
|
+
|
|
202
|
+
Run an example:
|
|
203
|
+
|
|
204
|
+
uv run examples/calculator.py
|
|
205
|
+
uv run examples/supervisor.py
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
## Development
|
|
209
|
+
|
|
210
|
+
Run tests:
|
|
211
|
+
|
|
212
|
+
uv run pytest
|
|
213
|
+
|
|
214
|
+
Lint and type check:
|
|
215
|
+
|
|
216
|
+
uv run ruff check --fix
|
|
217
|
+
uv run pyright
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT - see LICENSE file.
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
## Use of LLMs
|
|
226
|
+
|
|
227
|
+
This project has been made by the use of LLMs in following areas:
|
|
228
|
+
|
|
229
|
+
- Code review
|
|
230
|
+
- Preparing test cases for discovered issues
|
|
231
|
+
- Preparing and verifying documentation
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mindmesh"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Actor system for asyncio environments"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Marcin Glinski", email = "undefinedlamb@gmail.com" }]
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
keywords = ["asyncio", "actors", "distributed", "mesh", "concurrency"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Framework :: AsyncIO",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["uv_build>=0.10.4,<0.11.0"]
|
|
24
|
+
build-backend = "uv_build"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=9.0.2",
|
|
29
|
+
"pytest-asyncio>=1.3.0",
|
|
30
|
+
"ruff>=0.15.2",
|
|
31
|
+
"pyright>=1.1.408",
|
|
32
|
+
"pre-commit>=4.5.1",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.uv]
|
|
36
|
+
managed = true
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "B", "UP", "ASYNC"]
|
|
40
|
+
ignore = ["B027"]
|
|
41
|
+
|
|
42
|
+
[tool.pyright]
|
|
43
|
+
include = ["src"]
|
|
44
|
+
typeCheckingMode = "strict"
|
|
45
|
+
reportMissingTypeStubs = true
|
|
46
|
+
pythonVersion = "3.11"
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .actor import BaseActor, LinkAction
|
|
2
|
+
from .core import Request, StopReason, StopReasonType, T_Response
|
|
3
|
+
from .hive import ActorHive
|
|
4
|
+
from .proxy import ActorAddr
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"BaseActor",
|
|
8
|
+
"ActorHive",
|
|
9
|
+
"Request",
|
|
10
|
+
"T_Response",
|
|
11
|
+
"ActorAddr",
|
|
12
|
+
"LinkAction",
|
|
13
|
+
"StopReason",
|
|
14
|
+
"StopReasonType",
|
|
15
|
+
]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from .core import Envelope, StopReason
|
|
10
|
+
from .proxy import ActorAddr
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Coroutine, Generator
|
|
14
|
+
|
|
15
|
+
from .core import Request, StopReasonType, T_Response
|
|
16
|
+
from .hive import ActorHive
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__package__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LinkAction(Enum):
|
|
23
|
+
"""Return value of :meth:`BaseActor.on_link_death` controlling what happens next."""
|
|
24
|
+
|
|
25
|
+
Continue = 0
|
|
26
|
+
"""Keep this actor running after a monitored actor dies."""
|
|
27
|
+
Stop = 1
|
|
28
|
+
"""Stop this actor when a monitored actor dies."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BaseActor:
|
|
32
|
+
"""Base class for all actors. Subclass this to implement actor behaviour.
|
|
33
|
+
|
|
34
|
+
Messages are dispatched by :meth:`on_message` to methods named
|
|
35
|
+
``on_<snake_case_type>`` - e.g. a ``MyRequest`` message calls
|
|
36
|
+
``on_my_request(msg)``.
|
|
37
|
+
|
|
38
|
+
Lifecycle::
|
|
39
|
+
|
|
40
|
+
on_start() -> [message loop] -> on_stop()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, hive: ActorHive, actor_id: str):
|
|
44
|
+
self._hive = hive
|
|
45
|
+
self._id = actor_id
|
|
46
|
+
self._mailbox: asyncio.Queue[Envelope] = asyncio.Queue()
|
|
47
|
+
self._startup_future: asyncio.Future[bool] = asyncio.Future()
|
|
48
|
+
self._running = False
|
|
49
|
+
self._event_loop: asyncio.Task[None] | None = None
|
|
50
|
+
self._bg_task: asyncio.Task[None] | None = None
|
|
51
|
+
self._stop_event: asyncio.Event = asyncio.Event()
|
|
52
|
+
self._stop_reason: StopReasonType | None = None
|
|
53
|
+
self._ref: ActorAddr = ActorAddr(hive, actor_id)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def hive(self) -> ActorHive:
|
|
57
|
+
"""The hive this actor belongs to."""
|
|
58
|
+
return self._hive
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def id(self) -> str:
|
|
62
|
+
"""Unique ID of this actor."""
|
|
63
|
+
return self._id
|
|
64
|
+
|
|
65
|
+
def as_ref(self) -> ActorAddr:
|
|
66
|
+
"""Return an :class:`ActorAddr` pointing to this actor."""
|
|
67
|
+
return self._ref
|
|
68
|
+
|
|
69
|
+
def start(self) -> None:
|
|
70
|
+
"""Start the actor's event loop.
|
|
71
|
+
|
|
72
|
+
Called by the hive; use :meth:`ActorAddr.start` instead.
|
|
73
|
+
"""
|
|
74
|
+
if self._event_loop:
|
|
75
|
+
logger.warning(f"Actor {self.id} already running")
|
|
76
|
+
else:
|
|
77
|
+
self._event_loop = asyncio.create_task(self._loop())
|
|
78
|
+
|
|
79
|
+
def stop(self, reason: StopReasonType = StopReason.Stop) -> None:
|
|
80
|
+
"""Cancel the actor's event loop with the given reason."""
|
|
81
|
+
if not self._event_loop:
|
|
82
|
+
raise RuntimeError(f"Actor {self.id} not started")
|
|
83
|
+
if self._stop_reason is not None:
|
|
84
|
+
return
|
|
85
|
+
self._stop_reason = reason
|
|
86
|
+
self._event_loop.cancel()
|
|
87
|
+
|
|
88
|
+
def stop_self(self) -> None:
|
|
89
|
+
"""Schedule this actor to stop itself.
|
|
90
|
+
|
|
91
|
+
Safe to call from within a message handler.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
async def stop_deffered(hive: ActorHive, actor_id: str):
|
|
95
|
+
hive.request_actor_stop(actor_id, StopReason.Stop)
|
|
96
|
+
|
|
97
|
+
asyncio.create_task(stop_deffered(self._hive, self.id))
|
|
98
|
+
|
|
99
|
+
def ask(self, request: Request[T_Response]) -> asyncio.Future[T_Response]:
|
|
100
|
+
"""Enqueue a request and return a future for the response.
|
|
101
|
+
|
|
102
|
+
Must not be called from within the actor's own message loop.
|
|
103
|
+
Use :meth:`ActorAddr.ask` from outside instead.
|
|
104
|
+
"""
|
|
105
|
+
if self._is_called_from_self():
|
|
106
|
+
raise RuntimeError("ask() called from the actor's message loop")
|
|
107
|
+
future = asyncio.get_running_loop().create_future()
|
|
108
|
+
self._mailbox.put_nowait(Envelope(payload=request, reply_to=future))
|
|
109
|
+
return future
|
|
110
|
+
|
|
111
|
+
def tell(self, event: Any) -> None:
|
|
112
|
+
"""Enqueue a fire-and-forget message with no reply."""
|
|
113
|
+
self._mailbox.put_nowait(Envelope(payload=event, reply_to=None))
|
|
114
|
+
|
|
115
|
+
async def wait_for_startup(self) -> None:
|
|
116
|
+
"""Block until this actor has completed :meth:`on_start`."""
|
|
117
|
+
await self._startup_future
|
|
118
|
+
|
|
119
|
+
async def wait_for_stop(self) -> None:
|
|
120
|
+
"""Block until this actor has fully stopped."""
|
|
121
|
+
await self._stop_event.wait()
|
|
122
|
+
|
|
123
|
+
# --------------------- Lifecycle callbacks ----------------------------- #
|
|
124
|
+
|
|
125
|
+
async def on_link_death(self, actor_id: str, reason: StopReasonType) -> LinkAction:
|
|
126
|
+
"""Called when a monitored actor stops.
|
|
127
|
+
|
|
128
|
+
Default behaviour: stop self. Override to handle the event differently
|
|
129
|
+
and return :attr:`LinkAction.Continue` to keep running.
|
|
130
|
+
"""
|
|
131
|
+
logger.warning(f"Link '{actor_id}' died: {reason} - stopping {self.id}")
|
|
132
|
+
return LinkAction.Stop
|
|
133
|
+
|
|
134
|
+
async def on_message(self, message: Any) -> Any:
|
|
135
|
+
"""Dispatch an incoming message to the appropriate handler method.
|
|
136
|
+
|
|
137
|
+
Resolves the handler as ``on_<snake_case_type_name>`` and
|
|
138
|
+
calls it. Override to implement custom dispatch logic.
|
|
139
|
+
"""
|
|
140
|
+
type_name = type(message).__name__
|
|
141
|
+
method_name = f"on_{snake_case(type_name)}"
|
|
142
|
+
if (method := getattr(self, method_name, None)) is None:
|
|
143
|
+
raise NotImplementedError(
|
|
144
|
+
f"{type(self).__name__} does not implement {method_name}"
|
|
145
|
+
)
|
|
146
|
+
if not callable(method):
|
|
147
|
+
raise TypeError(f"{type_name}.{method_name} must be callable")
|
|
148
|
+
result = method(message)
|
|
149
|
+
if inspect.isawaitable(result):
|
|
150
|
+
return await result
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
async def on_start(self) -> None:
|
|
154
|
+
"""Called once before the message loop begins.
|
|
155
|
+
|
|
156
|
+
Override for initialisation logic.
|
|
157
|
+
"""
|
|
158
|
+
logger.debug(f"Actor {self.id} is starting")
|
|
159
|
+
|
|
160
|
+
async def on_stop(self) -> None:
|
|
161
|
+
"""Called once after the message loop ends.
|
|
162
|
+
|
|
163
|
+
Override for cleanup logic.
|
|
164
|
+
"""
|
|
165
|
+
logger.debug(f"Actor {self.id} is stopping")
|
|
166
|
+
|
|
167
|
+
def on_task_create(self) -> Coroutine[Any, Any, None] | None:
|
|
168
|
+
"""Return a coroutine to run as a background task alongside the message loop.
|
|
169
|
+
|
|
170
|
+
The task is cancelled when the actor stops. Return ``None`` (default) for
|
|
171
|
+
no background task.
|
|
172
|
+
"""
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
# --------------------- Internals --------------------------------------- #
|
|
176
|
+
|
|
177
|
+
async def _loop(self) -> None:
|
|
178
|
+
"""Main event loop."""
|
|
179
|
+
try:
|
|
180
|
+
# Handle start lifecycle event
|
|
181
|
+
await self.on_start()
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f"Actor {self.id} not started", exc_info=e)
|
|
184
|
+
self._startup_future.set_exception(e)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
if task := self.on_task_create():
|
|
188
|
+
logger.debug(f"Starting background task {task}")
|
|
189
|
+
self._bg_task = asyncio.create_task(task)
|
|
190
|
+
self._bg_task.add_done_callback(self._on_bg_task_done)
|
|
191
|
+
|
|
192
|
+
# Notify we started
|
|
193
|
+
self._startup_future.set_result(True)
|
|
194
|
+
self._running = True
|
|
195
|
+
|
|
196
|
+
reason: StopReasonType = StopReason.Stop
|
|
197
|
+
try:
|
|
198
|
+
# Start the main loop
|
|
199
|
+
while self._running:
|
|
200
|
+
envelope = await self._mailbox.get()
|
|
201
|
+
if envelope.payload is None and envelope.reply_to is None:
|
|
202
|
+
# Skip dummy envelopes
|
|
203
|
+
continue
|
|
204
|
+
exception_to_set = None
|
|
205
|
+
result_to_set = None
|
|
206
|
+
try:
|
|
207
|
+
# Handle message lifecycle event
|
|
208
|
+
result_to_set = await self.on_message(envelope.payload)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
exception_to_set = e
|
|
211
|
+
finally:
|
|
212
|
+
if envelope.reply_to and not envelope.reply_to.done():
|
|
213
|
+
if exception_to_set:
|
|
214
|
+
envelope.reply_to.set_exception(exception_to_set)
|
|
215
|
+
else:
|
|
216
|
+
envelope.reply_to.set_result(result_to_set)
|
|
217
|
+
self._mailbox.task_done()
|
|
218
|
+
|
|
219
|
+
except asyncio.CancelledError:
|
|
220
|
+
if self._stop_reason:
|
|
221
|
+
reason = self._stop_reason
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error("Unhandled exception in main loop!", exc_info=e)
|
|
225
|
+
reason = e
|
|
226
|
+
|
|
227
|
+
finally:
|
|
228
|
+
if self._bg_task:
|
|
229
|
+
logger.debug("Cancelling background task")
|
|
230
|
+
self._bg_task.cancel()
|
|
231
|
+
|
|
232
|
+
self._drain_mailbox()
|
|
233
|
+
|
|
234
|
+
# Handle stop lifecycle event
|
|
235
|
+
try:
|
|
236
|
+
await self.on_stop()
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.error(f"on_stop() raised in actor {self.id}", exc_info=e)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
await self.hive.on_actor_stopped(self.id, reason)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.error(
|
|
244
|
+
f"on_actor_stopped() raised for actor {self.id}",
|
|
245
|
+
exc_info=e,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
self._stop_event.set()
|
|
249
|
+
|
|
250
|
+
def _drain_mailbox(self) -> None:
|
|
251
|
+
while not self._mailbox.empty():
|
|
252
|
+
try:
|
|
253
|
+
envelope = self._mailbox.get_nowait()
|
|
254
|
+
if envelope.reply_to and not envelope.reply_to.done():
|
|
255
|
+
envelope.reply_to.set_exception(
|
|
256
|
+
RuntimeError(
|
|
257
|
+
f"Actor {self.id} stopped while request was pending"
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
except asyncio.QueueEmpty:
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
def _on_bg_task_done(self, task: asyncio.Task[None]) -> None:
|
|
264
|
+
if task.cancelled():
|
|
265
|
+
return
|
|
266
|
+
if exc := task.exception():
|
|
267
|
+
logger.error(f"Background task failed for actor {self.id}", exc_info=exc)
|
|
268
|
+
self.stop(exc)
|
|
269
|
+
|
|
270
|
+
def _is_called_from_self(self) -> bool:
|
|
271
|
+
current_task = asyncio.current_task()
|
|
272
|
+
return current_task is not None and current_task is self._event_loop
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def snake_case(name: str) -> str:
|
|
276
|
+
"""Convert a CamelCase name to snake_case."""
|
|
277
|
+
|
|
278
|
+
def sliding_window(x: str) -> Generator[tuple[str, str, str], Any, Any]:
|
|
279
|
+
x = "_" + x + "_"
|
|
280
|
+
for i in range(1, len(x) - 1):
|
|
281
|
+
yield (x[i - 1], x[i], x[i + 1])
|
|
282
|
+
|
|
283
|
+
def convert(prev: str, cur: str, nxt: str) -> str:
|
|
284
|
+
if cur == "_" or cur.isdigit():
|
|
285
|
+
return cur
|
|
286
|
+
if cur.islower() and not prev.isdigit():
|
|
287
|
+
return cur
|
|
288
|
+
if cur.isupper() and prev.isupper() and not nxt.islower():
|
|
289
|
+
return cur.lower()
|
|
290
|
+
return "_" + cur.lower()
|
|
291
|
+
|
|
292
|
+
return "".join(
|
|
293
|
+
convert(prev, cur, nxt) for prev, cur, nxt in sliding_window(name)
|
|
294
|
+
).lstrip("_")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StopReason(Enum):
|
|
8
|
+
"""Reason an actor was stopped normally (non-exception path)."""
|
|
9
|
+
|
|
10
|
+
Stop = "stop"
|
|
11
|
+
"""Explicit stop requested via :meth:`ActorAddr.stop` or
|
|
12
|
+
:meth:`BaseActor.stop_self`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
Shutdown = "shutdown"
|
|
16
|
+
"""Hive-wide shutdown triggered by :meth:`ActorHive.shutdown`."""
|
|
17
|
+
|
|
18
|
+
LinkDeath = "link_death"
|
|
19
|
+
"""A monitored actor stopped, propagating its death to this actor."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
StopReasonType = StopReason | BaseException
|
|
23
|
+
"""Either a :class:`StopReason` value or an unhandled exception that caused
|
|
24
|
+
the actor to die.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Envelope:
|
|
30
|
+
"""Internal message wrapper used by the actor mailbox.
|
|
31
|
+
|
|
32
|
+
Not part of the public API.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
payload: Any
|
|
36
|
+
reply_to: asyncio.Future[Any] | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
T_Response = TypeVar("T_Response")
|
|
40
|
+
"""Type variable for the response type of a :class:`Request`."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Request(Generic[T_Response]):
|
|
44
|
+
"""Base class for request messages sent via :meth:`ActorAddr.ask`.
|
|
45
|
+
|
|
46
|
+
Subclass this to define a typed request/response pair::
|
|
47
|
+
|
|
48
|
+
class GetCount(Request[int]):
|
|
49
|
+
pass
|
|
50
|
+
"""
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from .actor import BaseActor, LinkAction
|
|
8
|
+
from .core import StopReason
|
|
9
|
+
from .registry import ActorRegistry
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .core import Request, StopReasonType, T_Response
|
|
13
|
+
from .proxy import ActorAddr
|
|
14
|
+
from .registry import ActorContext
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__package__)
|
|
17
|
+
T = TypeVar("T", bound=BaseActor)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ActorFactory:
|
|
21
|
+
"""Responsible for instantiating actors.
|
|
22
|
+
|
|
23
|
+
Override to customise actor creation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def create(
|
|
27
|
+
self,
|
|
28
|
+
hive: ActorHive,
|
|
29
|
+
actor_class: type[T],
|
|
30
|
+
actor_id: str,
|
|
31
|
+
*args: Any,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
) -> T:
|
|
34
|
+
return actor_class(hive, actor_id, *args, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ActorHive:
|
|
38
|
+
"""Central runtime that owns and coordinates all actors.
|
|
39
|
+
|
|
40
|
+
Typical usage:
|
|
41
|
+
|
|
42
|
+
hive = ActorHive()
|
|
43
|
+
addr = hive.start_actor(MyActor)
|
|
44
|
+
await addr.ask(MyRequest())
|
|
45
|
+
await hive.shutdown()
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
factory: Optional custom :class:`ActorFactory` for actor instantiation.
|
|
49
|
+
registry: Optional custom :class:`ActorRegistry`.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self, factory: ActorFactory | None = None, registry: ActorRegistry | None = None
|
|
54
|
+
) -> None:
|
|
55
|
+
self._next_id = 0
|
|
56
|
+
self._actor_factory = factory if factory else ActorFactory()
|
|
57
|
+
self._registry = registry if registry else ActorRegistry()
|
|
58
|
+
|
|
59
|
+
# ------------------------------- Public API ---------------------------------------
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def actor_ids(self) -> list[str]:
|
|
63
|
+
"""IDs of all currently registered actors."""
|
|
64
|
+
return self._registry.actor_ids
|
|
65
|
+
|
|
66
|
+
async def ask_actor(
|
|
67
|
+
self,
|
|
68
|
+
actor_id: str,
|
|
69
|
+
request: Request[T_Response],
|
|
70
|
+
) -> T_Response:
|
|
71
|
+
"""Send a request to an actor by ID and await its typed response."""
|
|
72
|
+
if actor := self._registry.get_actor(actor_id):
|
|
73
|
+
return await actor.ask(request)
|
|
74
|
+
raise ValueError(f"Actor {actor_id} not found")
|
|
75
|
+
|
|
76
|
+
def create_actor(
|
|
77
|
+
self, actor_class: type[T], *args: Any, **kwargs: Any
|
|
78
|
+
) -> ActorAddr:
|
|
79
|
+
"""Instantiate and register an actor without starting it.
|
|
80
|
+
|
|
81
|
+
Returns proxy object.
|
|
82
|
+
"""
|
|
83
|
+
actor_id = f"{actor_class.__name__}-{self._next_id}"
|
|
84
|
+
self._next_id += 1
|
|
85
|
+
actor = self._actor_factory.create(self, actor_class, actor_id, *args, **kwargs)
|
|
86
|
+
return self._registry.register(actor_id, actor).actor_ref
|
|
87
|
+
|
|
88
|
+
def create_named_actor(
|
|
89
|
+
self, name: str, actor_class: type[T], *args: Any, **kwargs: Any
|
|
90
|
+
) -> ActorAddr:
|
|
91
|
+
"""Instantiate and register an actor under a given name without starting it.
|
|
92
|
+
|
|
93
|
+
Returns proxy object.
|
|
94
|
+
"""
|
|
95
|
+
actor_id = f"{actor_class.__name__}-{self._next_id}"
|
|
96
|
+
self._next_id += 1
|
|
97
|
+
actor = self._actor_factory.create(self, actor_class, actor_id, *args, **kwargs)
|
|
98
|
+
return self._registry.register(actor_id, actor, name=name).actor_ref
|
|
99
|
+
|
|
100
|
+
def link_actors(self, source_id: str, monitor_id: str) -> None:
|
|
101
|
+
"""Make *monitor_id* watch *source_id*
|
|
102
|
+
|
|
103
|
+
When *source_id* stops, *monitor_id* is notified."""
|
|
104
|
+
self._registry.add_monitor(source_id, monitor_id)
|
|
105
|
+
|
|
106
|
+
def link_actors_both(self, actor1: str, actor2: str) -> None:
|
|
107
|
+
"""Bidirectional link: each actor monitors the other."""
|
|
108
|
+
self._registry.add_monitor(actor1, actor2, both=True)
|
|
109
|
+
|
|
110
|
+
def lookup(self, actor_name: str) -> ActorAddr | None:
|
|
111
|
+
"""Return the address of a named actor, or ``None`` if not found."""
|
|
112
|
+
if context := self._registry.get_by_name(actor_name):
|
|
113
|
+
return context.actor_ref
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def register(self, actor_name: str, addr: ActorAddr) -> None:
|
|
117
|
+
"""Associate *name* with an existing actor address for later :meth:`lookup`."""
|
|
118
|
+
self._registry.register_name(actor_name, addr.id())
|
|
119
|
+
|
|
120
|
+
def request_actor_start(self, actor_id: str) -> None:
|
|
121
|
+
"""Trigger the start sequence of an already-registered actor."""
|
|
122
|
+
if not (actor := self._registry.get_actor(actor_id)):
|
|
123
|
+
raise ValueError(f"Actor {actor_id} not found")
|
|
124
|
+
try:
|
|
125
|
+
actor.start()
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error(
|
|
128
|
+
f"Unhandled exception while starting actor {actor_id}", exc_info=e
|
|
129
|
+
)
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
def request_actor_stop(self, actor_id: str, reason: StopReasonType) -> None:
|
|
133
|
+
"""Request an actor to stop with the given reason.
|
|
134
|
+
|
|
135
|
+
No-op if already stopped.
|
|
136
|
+
"""
|
|
137
|
+
if not (actor := self._registry.get_actor(actor_id)):
|
|
138
|
+
return # Actor already stopped and removed
|
|
139
|
+
actor.stop(reason)
|
|
140
|
+
|
|
141
|
+
async def shutdown(self) -> None:
|
|
142
|
+
"""Stop all actors and wait for them to finish.
|
|
143
|
+
|
|
144
|
+
Call this to cleanly tear down the hive.
|
|
145
|
+
"""
|
|
146
|
+
logger.debug("Shutting down the hive")
|
|
147
|
+
for actor_id in self._registry.actor_ids:
|
|
148
|
+
try:
|
|
149
|
+
self.request_actor_stop(actor_id, StopReason.Shutdown)
|
|
150
|
+
except RuntimeError:
|
|
151
|
+
self._registry.unregister(actor_id)
|
|
152
|
+
await asyncio.gather(
|
|
153
|
+
*(
|
|
154
|
+
self.wait_for_actor_stop(actor_id)
|
|
155
|
+
for actor_id in self._registry.actor_ids
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def start_actor(self, actor_class: type[T], *args: Any, **kwargs: Any) -> ActorAddr:
|
|
160
|
+
"""Create and immediately start an actor.
|
|
161
|
+
|
|
162
|
+
Equivalent to :meth:`create_actor` + :meth:`request_actor_start`.
|
|
163
|
+
"""
|
|
164
|
+
actor_ref = self.create_actor(actor_class, *args, **kwargs)
|
|
165
|
+
self.request_actor_start(actor_ref.id())
|
|
166
|
+
return actor_ref
|
|
167
|
+
|
|
168
|
+
def start_named_actor(
|
|
169
|
+
self, name: str, actor_class: type[T], *args: Any, **kwargs: Any
|
|
170
|
+
) -> ActorAddr:
|
|
171
|
+
"""Create and immediately start an actor.
|
|
172
|
+
|
|
173
|
+
Actor is registered under the name for later :meth:`lookup`.
|
|
174
|
+
|
|
175
|
+
Like :meth:`start_actor` but also registers the actor under *name*.
|
|
176
|
+
"""
|
|
177
|
+
actor_ref = self.create_named_actor(name, actor_class, *args, **kwargs)
|
|
178
|
+
self.request_actor_start(actor_ref.id())
|
|
179
|
+
return actor_ref
|
|
180
|
+
|
|
181
|
+
def tell(self, actor_id: str, event: Any) -> None:
|
|
182
|
+
"""Send a fire-and-forget message to an actor by ID."""
|
|
183
|
+
if actor := self._registry.get_actor(actor_id):
|
|
184
|
+
actor.tell(event)
|
|
185
|
+
else:
|
|
186
|
+
raise ValueError(f"Tell: actor {actor_id} not found")
|
|
187
|
+
|
|
188
|
+
async def wait_for_actor_start(self, actor_id: str) -> None:
|
|
189
|
+
"""Block until the actor has finished its startup sequence."""
|
|
190
|
+
if actor := self._registry.get_actor(actor_id):
|
|
191
|
+
await actor.wait_for_startup()
|
|
192
|
+
|
|
193
|
+
async def wait_for_actor_stop(self, actor_id: str) -> None:
|
|
194
|
+
"""Block until the actor has fully stopped."""
|
|
195
|
+
if actor := self._registry.get_actor(actor_id):
|
|
196
|
+
await actor.wait_for_stop()
|
|
197
|
+
|
|
198
|
+
# ------------------------------- Callbacks ----------------------------------------
|
|
199
|
+
|
|
200
|
+
async def on_actor_stopped(self, actor_id: str, reason: StopReasonType) -> None:
|
|
201
|
+
"""Called by the framework when an actor stops.
|
|
202
|
+
|
|
203
|
+
Notifies monitors and cleans up the registry.
|
|
204
|
+
"""
|
|
205
|
+
logger.debug(f"on_actor_stopped: {actor_id}: {reason}")
|
|
206
|
+
try:
|
|
207
|
+
actors_to_stop: list[ActorContext] = []
|
|
208
|
+
monitor_reason = (
|
|
209
|
+
reason if reason != StopReason.Stop else StopReason.LinkDeath
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
for monitor_context in self._registry.monitors(actor_id):
|
|
213
|
+
try:
|
|
214
|
+
action = await monitor_context.actor.on_link_death(
|
|
215
|
+
actor_id, monitor_reason
|
|
216
|
+
)
|
|
217
|
+
if action == LinkAction.Stop:
|
|
218
|
+
actors_to_stop.append(monitor_context)
|
|
219
|
+
except ValueError:
|
|
220
|
+
logger.error(
|
|
221
|
+
f"Monitor {monitor_context.actor.id} of source {actor_id}"
|
|
222
|
+
" - not found"
|
|
223
|
+
)
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
for actor_context in actors_to_stop:
|
|
227
|
+
actor_context.actor_ref.stop()
|
|
228
|
+
|
|
229
|
+
self._registry.unregister(actor_id)
|
|
230
|
+
|
|
231
|
+
except ValueError:
|
|
232
|
+
logger.error(f"on_actor_stopped: actor {actor_id} no longer exists")
|
|
233
|
+
pass
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from .core import StopReason
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .core import Request, T_Response
|
|
9
|
+
from .hive import ActorHive
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ActorAddr:
|
|
13
|
+
"""A reference to an actor.
|
|
14
|
+
|
|
15
|
+
Use this to interact with actors without holding a direct reference.
|
|
16
|
+
|
|
17
|
+
Obtain an ``ActorAddr`` with:
|
|
18
|
+
- :meth:`ActorHive.create_actor`
|
|
19
|
+
- :meth:`ActorHive.start_actor`,
|
|
20
|
+
- :meth:`BaseActor.as_ref`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, hive: ActorHive, actor_id: str):
|
|
24
|
+
self._hive = hive
|
|
25
|
+
self._id = actor_id
|
|
26
|
+
|
|
27
|
+
def id(self) -> str:
|
|
28
|
+
"""Return the unique ID of the referenced actor."""
|
|
29
|
+
return self._id
|
|
30
|
+
|
|
31
|
+
async def ask(self, request: Request[T_Response]) -> T_Response:
|
|
32
|
+
"""Send a request and await a typed response."""
|
|
33
|
+
return await self._hive.ask_actor(self._id, request)
|
|
34
|
+
|
|
35
|
+
def monitor(self, actor_ref: ActorAddr) -> None:
|
|
36
|
+
"""Monitor *actor_ref* from this actor.
|
|
37
|
+
|
|
38
|
+
Death of *actor_ref* triggers ``on_link_death``.
|
|
39
|
+
"""
|
|
40
|
+
self._hive.link_actors(actor_ref.id(), self.id())
|
|
41
|
+
|
|
42
|
+
def start(self) -> None:
|
|
43
|
+
"""Schedule the actor to start."""
|
|
44
|
+
self._hive.request_actor_start(self._id)
|
|
45
|
+
|
|
46
|
+
def stop(self) -> None:
|
|
47
|
+
"""Schedule the actor to stop."""
|
|
48
|
+
self._hive.request_actor_stop(self._id, StopReason.Stop)
|
|
49
|
+
|
|
50
|
+
def tell(self, message: Any) -> None:
|
|
51
|
+
"""Send a fire-and-forget message."""
|
|
52
|
+
self._hive.tell(self._id, message)
|
|
53
|
+
|
|
54
|
+
async def wait_for_start(self) -> None:
|
|
55
|
+
"""Block until the actor has finished starting up."""
|
|
56
|
+
await self._hive.wait_for_actor_start(self._id)
|
|
57
|
+
|
|
58
|
+
async def wait_for_stop(self) -> None:
|
|
59
|
+
"""Block until the actor has fully stopped."""
|
|
60
|
+
await self._hive.wait_for_actor_stop(self._id)
|
|
61
|
+
|
|
62
|
+
def __eq__(self, other: object) -> bool:
|
|
63
|
+
return isinstance(other, ActorAddr) and self._id == other._id
|
|
64
|
+
|
|
65
|
+
def __hash__(self) -> int:
|
|
66
|
+
return hash(self._id)
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return f"ActorAddr{{id={self._id}}}"
|
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .actor import BaseActor
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .proxy import ActorAddr
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__package__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ActorContext:
|
|
18
|
+
actor: BaseActor
|
|
19
|
+
actor_ref: ActorAddr
|
|
20
|
+
monitors: set[str] = field(default_factory=set[str])
|
|
21
|
+
monitored_by: set[str] = field(default_factory=set[str])
|
|
22
|
+
name: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ActorRegistry:
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self._contexts: dict[str, ActorContext] = {}
|
|
28
|
+
self._names: dict[str, ActorContext] = {}
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def actor_ids(self) -> list[str]:
|
|
32
|
+
return list(self._contexts.keys())
|
|
33
|
+
|
|
34
|
+
def add_monitor(self, source_id: str, monitor_id: str, both: bool = False) -> None:
|
|
35
|
+
if source_id == monitor_id:
|
|
36
|
+
logger.warning(
|
|
37
|
+
f"Source {source_id} and Monitor {monitor_id} are the same actors"
|
|
38
|
+
)
|
|
39
|
+
return
|
|
40
|
+
if not (source := self.get(source_id)):
|
|
41
|
+
logger.debug(f"Source actor {source_id} not found")
|
|
42
|
+
return
|
|
43
|
+
if not (monitor := self.get(monitor_id)):
|
|
44
|
+
logger.debug(f"Monitor actor {monitor_id} not found")
|
|
45
|
+
return
|
|
46
|
+
source.monitored_by.add(monitor_id)
|
|
47
|
+
monitor.monitors.add(source_id)
|
|
48
|
+
if both:
|
|
49
|
+
monitor.monitored_by.add(source_id)
|
|
50
|
+
source.monitors.add(monitor_id)
|
|
51
|
+
|
|
52
|
+
def drop_monitor(self, monitor_id: str) -> None:
|
|
53
|
+
if not (monitor := self.get(monitor_id)):
|
|
54
|
+
return
|
|
55
|
+
for source_id in monitor.monitors:
|
|
56
|
+
if source := self.get(source_id):
|
|
57
|
+
source.monitored_by.discard(monitor_id)
|
|
58
|
+
monitor.monitors.clear()
|
|
59
|
+
|
|
60
|
+
def get(self, actor_id: str) -> ActorContext | None:
|
|
61
|
+
return self._contexts.get(actor_id)
|
|
62
|
+
|
|
63
|
+
def get_actor(self, actor_id: str) -> BaseActor | None:
|
|
64
|
+
if context := self.get(actor_id):
|
|
65
|
+
return context.actor
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def get_by_name(self, actor_name: str) -> ActorContext | None:
|
|
69
|
+
return self._names.get(actor_name)
|
|
70
|
+
|
|
71
|
+
def monitors(self, source_id: str) -> Generator[ActorContext, None, None]:
|
|
72
|
+
if not (source := self.get(source_id)):
|
|
73
|
+
logger.debug(f"Source {source_id} not found")
|
|
74
|
+
return
|
|
75
|
+
for monitor_id in list(source.monitored_by):
|
|
76
|
+
if monitor := self.get(monitor_id):
|
|
77
|
+
yield monitor
|
|
78
|
+
else:
|
|
79
|
+
logging.debug(f"Monitor {monitor_id} of source {source_id} not found")
|
|
80
|
+
|
|
81
|
+
def register(
|
|
82
|
+
self, actor_id: str, actor: BaseActor, name: str | None = None
|
|
83
|
+
) -> ActorContext:
|
|
84
|
+
if actor_id in self._contexts:
|
|
85
|
+
raise ValueError(f"Actor {actor_id} already in the registry")
|
|
86
|
+
context = ActorContext(actor=actor, actor_ref=actor.as_ref())
|
|
87
|
+
self._contexts[actor_id] = context
|
|
88
|
+
if name:
|
|
89
|
+
try:
|
|
90
|
+
self.register_name(name, actor_id)
|
|
91
|
+
except ValueError:
|
|
92
|
+
self.unregister(actor_id)
|
|
93
|
+
raise
|
|
94
|
+
return context
|
|
95
|
+
|
|
96
|
+
def register_name(self, name: str, actor_id: str) -> None:
|
|
97
|
+
if not (context := self.get(actor_id)):
|
|
98
|
+
raise ValueError(f"Actor {actor_id} not found")
|
|
99
|
+
if context.name:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Actor {actor_id} previously registered as {context.name}"
|
|
102
|
+
)
|
|
103
|
+
if name in self._names:
|
|
104
|
+
other = self._names[name]
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Actor {actor_id} could not be registered under name "
|
|
107
|
+
f"{name} - Actor {other.actor.id} was first."
|
|
108
|
+
)
|
|
109
|
+
context.name = name
|
|
110
|
+
self._names[name] = context
|
|
111
|
+
|
|
112
|
+
def unregister(self, actor_id: str) -> None:
|
|
113
|
+
self.drop_monitor(actor_id)
|
|
114
|
+
if actor := self.get(actor_id):
|
|
115
|
+
if actor.name and actor.name in self._names:
|
|
116
|
+
del self._names[actor.name]
|
|
117
|
+
del self._contexts[actor_id]
|
|
118
|
+
else:
|
|
119
|
+
logger.warning(f"Actor {actor_id} not registered")
|