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.
@@ -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
@@ -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")