chorus-actors 0.1.1__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.
- chorus_actors-0.1.1/LICENSE +7 -0
- chorus_actors-0.1.1/PKG-INFO +57 -0
- chorus_actors-0.1.1/README.md +43 -0
- chorus_actors-0.1.1/pyproject.toml +41 -0
- chorus_actors-0.1.1/pyproject.toml.orig +33 -0
- chorus_actors-0.1.1/src/chorus/__init__.py +60 -0
- chorus_actors-0.1.1/src/chorus/dispatch/__init__.py +15 -0
- chorus_actors-0.1.1/src/chorus/dispatch/handlers.py +104 -0
- chorus_actors-0.1.1/src/chorus/dispatch/messages.py +33 -0
- chorus_actors-0.1.1/src/chorus/dispatch/proxy.py +61 -0
- chorus_actors-0.1.1/src/chorus/messaging/__init__.py +11 -0
- chorus_actors-0.1.1/src/chorus/messaging/exchange.py +29 -0
- chorus_actors-0.1.1/src/chorus/messaging/messages.py +23 -0
- chorus_actors-0.1.1/src/chorus/runtime/__init__.py +27 -0
- chorus_actors-0.1.1/src/chorus/runtime/api.py +76 -0
- chorus_actors-0.1.1/src/chorus/runtime/lifecycle.py +57 -0
- chorus_actors-0.1.1/src/chorus/runtime/mailbox.py +104 -0
- chorus_actors-0.1.1/src/chorus/runtime/system.py +105 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 David Morrison
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chorus-actors
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: An actor library for Python
|
|
5
|
+
Author: David Morrison
|
|
6
|
+
Author-email: David Morrison <dm236@st-andrews.ac.uk>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Project-URL: Repository, https://github.com/progspaces/chorus
|
|
11
|
+
Project-URL: Documentation, https://chorus-actors.readthedocs.io/
|
|
12
|
+
Project-URL: Issues, https://github.com/progspaces/chorus/issues
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Chorus - A simple actor library for Python
|
|
16
|
+
|
|
17
|
+
Chorus is a Python package that provides a version of the Actor model of concurrency. It is built ontop of Python's built-in threading package and each Actor has its own thread. Actors can send and recieve messges from other actors as well as spawn new Actors.
|
|
18
|
+
|
|
19
|
+
The package has three layers:
|
|
20
|
+
|
|
21
|
+
- `chorus.runtime` a minimal API for managing Python threads using the Actor model, roughly based on Erlangs API.
|
|
22
|
+
- `chorus.messaging` provides message base classes and request/reply exchanges with `tell` and `ask`.
|
|
23
|
+
- `chorus.dispatch` provides an object-oriented API for sending and receiving messages between actors.
|
|
24
|
+
|
|
25
|
+
There APIs can be imported directly from the ``chorus`` top-level package.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
Chorus requires Python 3.12 or later. The distribution is named `chorus-actors`; the Python import remains `chorus`. To add it to a project using uv:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
uv add chorus-actors
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or install it with pip:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
python -m pip install chorus-actors
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Documentation
|
|
41
|
+
The documentation can be found [here](https://chorus-actors.readthedocs.io/en/latest/).
|
|
42
|
+
|
|
43
|
+
## Examples:
|
|
44
|
+
The [examples](./examples) are grouped by layer:
|
|
45
|
+
|
|
46
|
+
- [runtime](./examples/runtime/): actors, raw messages, main-thread execution, and drawing.
|
|
47
|
+
- [messaging](./examples/messaging/): message subclasses and request/reply exchanges.
|
|
48
|
+
- [dispatch](./examples/dispatch/): counters, proxies, and GUI event-loop integration.
|
|
49
|
+
|
|
50
|
+
To run an example from the repository root:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
uv run examples/dispatch/proxy_counter.py
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
This project is licensed under the [MIT licence](./LICENSE).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Chorus - A simple actor library for Python
|
|
2
|
+
|
|
3
|
+
Chorus is a Python package that provides a version of the Actor model of concurrency. It is built ontop of Python's built-in threading package and each Actor has its own thread. Actors can send and recieve messges from other actors as well as spawn new Actors.
|
|
4
|
+
|
|
5
|
+
The package has three layers:
|
|
6
|
+
|
|
7
|
+
- `chorus.runtime` a minimal API for managing Python threads using the Actor model, roughly based on Erlangs API.
|
|
8
|
+
- `chorus.messaging` provides message base classes and request/reply exchanges with `tell` and `ask`.
|
|
9
|
+
- `chorus.dispatch` provides an object-oriented API for sending and receiving messages between actors.
|
|
10
|
+
|
|
11
|
+
There APIs can be imported directly from the ``chorus`` top-level package.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
Chorus requires Python 3.12 or later. The distribution is named `chorus-actors`; the Python import remains `chorus`. To add it to a project using uv:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
uv add chorus-actors
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or install it with pip:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
python -m pip install chorus-actors
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Documentation
|
|
27
|
+
The documentation can be found [here](https://chorus-actors.readthedocs.io/en/latest/).
|
|
28
|
+
|
|
29
|
+
## Examples:
|
|
30
|
+
The [examples](./examples) are grouped by layer:
|
|
31
|
+
|
|
32
|
+
- [runtime](./examples/runtime/): actors, raw messages, main-thread execution, and drawing.
|
|
33
|
+
- [messaging](./examples/messaging/): message subclasses and request/reply exchanges.
|
|
34
|
+
- [dispatch](./examples/dispatch/): counters, proxies, and GUI event-loop integration.
|
|
35
|
+
|
|
36
|
+
To run an example from the repository root:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv run examples/dispatch/proxy_counter.py
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
This project is licensed under the [MIT licence](./LICENSE).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "chorus-actors"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "An actor library for Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[[project.authors]]
|
|
12
|
+
name = "David Morrison"
|
|
13
|
+
email = "dm236@st-andrews.ac.uk"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/progspaces/chorus"
|
|
17
|
+
Documentation = "https://chorus-actors.readthedocs.io/"
|
|
18
|
+
Issues = "https://github.com/progspaces/chorus/issues"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.11.13,<0.12.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[tool.uv.build-backend]
|
|
25
|
+
module-name = "chorus"
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 88
|
|
29
|
+
|
|
30
|
+
[tool.ruff.format]
|
|
31
|
+
quote-style = "double"
|
|
32
|
+
indent-style = "space"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=9.1.1",
|
|
37
|
+
"ruff>=0.15.21",
|
|
38
|
+
"shibuya>=2026.7.12",
|
|
39
|
+
"sphinxcontrib-mermaid>=2.1.1",
|
|
40
|
+
]
|
|
41
|
+
docs = ["sphinx>=9.1.0"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "chorus-actors"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "An actor library for Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "David Morrison", email = "dm236@st-andrews.ac.uk" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Repository = "https://github.com/progspaces/chorus"
|
|
14
|
+
Documentation = "https://chorus-actors.readthedocs.io/"
|
|
15
|
+
Issues = "https://github.com/progspaces/chorus/issues"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.11.13,<0.12.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend]
|
|
22
|
+
module-name = "chorus"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = ["pytest>=9.1.1", "ruff>=0.15.21", "shibuya>=2026.7.12", "sphinxcontrib-mermaid>=2.1.1"]
|
|
26
|
+
docs = ["sphinx>=9.1.0"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
line-length = 88
|
|
30
|
+
|
|
31
|
+
[tool.ruff.format]
|
|
32
|
+
quote-style = "double"
|
|
33
|
+
indent-style = "space"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from .dispatch import (
|
|
2
|
+
Call,
|
|
3
|
+
Error,
|
|
4
|
+
Invoke,
|
|
5
|
+
Kill,
|
|
6
|
+
Proxy,
|
|
7
|
+
Success,
|
|
8
|
+
process_pending,
|
|
9
|
+
spawn_server_with_proxy,
|
|
10
|
+
)
|
|
11
|
+
from .messaging import Event, Message, Request, Response, ask, tell
|
|
12
|
+
from .runtime import (
|
|
13
|
+
ActorFunction,
|
|
14
|
+
ActorID,
|
|
15
|
+
ActorRecord,
|
|
16
|
+
ActorSystem,
|
|
17
|
+
ActorUnavailableException,
|
|
18
|
+
Mailbox,
|
|
19
|
+
me,
|
|
20
|
+
receive,
|
|
21
|
+
run,
|
|
22
|
+
run_main,
|
|
23
|
+
send,
|
|
24
|
+
set_local_actor,
|
|
25
|
+
register_thread_as_actor,
|
|
26
|
+
unregister_thread_as_actor,
|
|
27
|
+
spawn,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ActorFunction",
|
|
32
|
+
"ActorID",
|
|
33
|
+
"ActorRecord",
|
|
34
|
+
"ActorSystem",
|
|
35
|
+
"ActorUnavailableException",
|
|
36
|
+
"Call",
|
|
37
|
+
"Error",
|
|
38
|
+
"Event",
|
|
39
|
+
"Invoke",
|
|
40
|
+
"Kill",
|
|
41
|
+
"Mailbox",
|
|
42
|
+
"Message",
|
|
43
|
+
"Proxy",
|
|
44
|
+
"Request",
|
|
45
|
+
"Response",
|
|
46
|
+
"Success",
|
|
47
|
+
"ask",
|
|
48
|
+
"process_pending",
|
|
49
|
+
"me",
|
|
50
|
+
"receive",
|
|
51
|
+
"run",
|
|
52
|
+
"run_main",
|
|
53
|
+
"send",
|
|
54
|
+
"set_local_actor",
|
|
55
|
+
"register_thread_as_actor",
|
|
56
|
+
"unregister_thread_as_actor",
|
|
57
|
+
"spawn",
|
|
58
|
+
"spawn_server_with_proxy",
|
|
59
|
+
"tell",
|
|
60
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .handlers import process_pending, server, spawn_server_with_proxy
|
|
2
|
+
from .messages import Call, Invoke, Kill, Error, Success
|
|
3
|
+
from .proxy import Proxy
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"server",
|
|
7
|
+
"process_pending",
|
|
8
|
+
"spawn_server_with_proxy",
|
|
9
|
+
"Proxy",
|
|
10
|
+
"Invoke",
|
|
11
|
+
"Kill",
|
|
12
|
+
"Error",
|
|
13
|
+
"Success",
|
|
14
|
+
"Call",
|
|
15
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from ..messaging import tell
|
|
5
|
+
from ..runtime import receive, spawn
|
|
6
|
+
from .messages import Call, Error, Invoke, Kill, Success
|
|
7
|
+
from .proxy import Proxy
|
|
8
|
+
|
|
9
|
+
# The router works by getting a Invoke or Call message and finding an object in a
|
|
10
|
+
# chain that can accept that message as a method call.
|
|
11
|
+
# In the case of a Call, it then sends a response
|
|
12
|
+
|
|
13
|
+
# the chain is a list of objects, refered to as handlers
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def can_handle(
|
|
17
|
+
handler: object, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
18
|
+
) -> bool:
|
|
19
|
+
method = getattr(handler, name, None)
|
|
20
|
+
|
|
21
|
+
if not callable(method):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
inspect.signature(method).bind(*args, **kwargs)
|
|
26
|
+
return True
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_handler(
|
|
32
|
+
handlers: list[object], name: str, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
33
|
+
) -> object | None:
|
|
34
|
+
for handler in handlers:
|
|
35
|
+
if can_handle(handler, name, args, kwargs):
|
|
36
|
+
return handler # stops on the first one that can handle
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dispatch(
|
|
41
|
+
handlers: list[object], name: str, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
42
|
+
) -> Any:
|
|
43
|
+
handler = find_handler(handlers, name, args, kwargs)
|
|
44
|
+
if handler:
|
|
45
|
+
method = getattr(handler, name) # default omitted as method is there
|
|
46
|
+
return method(*args, **kwargs)
|
|
47
|
+
else:
|
|
48
|
+
# todo: consider if this should just add the message back into the mailbox
|
|
49
|
+
# this would allow it to be handled later in a different way
|
|
50
|
+
raise ValueError("Cannot find a handler to dispatch the method on.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def dispatch_invoke(message: Invoke, handlers: list[object]) -> None:
|
|
54
|
+
try:
|
|
55
|
+
dispatch(handlers, message.name, message.args, message.kwargs)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"Unable to route Invoke: {message}.")
|
|
58
|
+
print(e)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def dispatch_call(message: Call, handlers: list[object]) -> None:
|
|
62
|
+
try:
|
|
63
|
+
result = dispatch(handlers, message.name, message.args, message.kwargs)
|
|
64
|
+
reply = Success(result, exchange_id=message.exchange_id)
|
|
65
|
+
tell(message.sender, reply)
|
|
66
|
+
except Exception as reason:
|
|
67
|
+
error = Error(reason, exchange_id=message.exchange_id)
|
|
68
|
+
tell(message.sender, error)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def process_messages(handlers: list[object], keep_running=False) -> bool:
|
|
72
|
+
# todo - I am unsure thow to handle timeouts here
|
|
73
|
+
# currently the receive will just block forever or not block
|
|
74
|
+
while True:
|
|
75
|
+
message = receive(block=keep_running)
|
|
76
|
+
match message:
|
|
77
|
+
case Invoke():
|
|
78
|
+
dispatch_invoke(message, handlers)
|
|
79
|
+
case Call():
|
|
80
|
+
dispatch_call(message, handlers)
|
|
81
|
+
case Kill():
|
|
82
|
+
# this exits the loop and returns False
|
|
83
|
+
# meaning that the actor should terminate
|
|
84
|
+
return False
|
|
85
|
+
case None:
|
|
86
|
+
# this can only happen when block is false
|
|
87
|
+
# it tells any outer event-loop that the queue has been
|
|
88
|
+
# processed successfully
|
|
89
|
+
return True
|
|
90
|
+
case _:
|
|
91
|
+
print(f"Cannot route unknowns message: {message}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def server(handlers: list[object]):
|
|
95
|
+
process_messages(handlers, keep_running=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def process_pending(handlers: list[object]):
|
|
99
|
+
return process_messages(handlers, keep_running=False)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def spawn_server_with_proxy(handlers: list[object]) -> Proxy:
|
|
103
|
+
actor_id = spawn(server, handlers)
|
|
104
|
+
return Proxy(actor_id)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from ..messaging.messages import Event, Request, Response
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Invoke(Event):
|
|
9
|
+
name: str
|
|
10
|
+
args: tuple[Any, ...] = ()
|
|
11
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Call(Request):
|
|
16
|
+
name: str
|
|
17
|
+
args: tuple[Any, ...] = ()
|
|
18
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Success(Response):
|
|
23
|
+
result: Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Error(Response):
|
|
28
|
+
reason: Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Kill(Event):
|
|
33
|
+
pass
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from ..messaging.exchange import ask, tell
|
|
5
|
+
from ..runtime.system import ActorID
|
|
6
|
+
from .messages import Call, Error, Invoke, Kill, Success
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Dispatcher(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
|
|
12
|
+
raise NotImplementedError()
|
|
13
|
+
|
|
14
|
+
def __getattr__(self, name: str):
|
|
15
|
+
def missing(*args: Any, **kwargs: Any):
|
|
16
|
+
return self.dispatch(name, *args, **kwargs)
|
|
17
|
+
|
|
18
|
+
return missing
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Teller(Dispatcher):
|
|
22
|
+
def __init__(self, target_id: ActorID):
|
|
23
|
+
self.target_id = target_id
|
|
24
|
+
|
|
25
|
+
def dispatch(self, name: str, *args: Any, **kwargs: Any) -> None:
|
|
26
|
+
event = Invoke(name=name, args=args, kwargs=kwargs)
|
|
27
|
+
tell(self.target_id, event)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Asker(Dispatcher):
|
|
31
|
+
def __init__(self, target_id: ActorID):
|
|
32
|
+
self.target_id = target_id
|
|
33
|
+
|
|
34
|
+
def dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
|
|
35
|
+
request = Call(name=name, args=args, kwargs=kwargs)
|
|
36
|
+
response = ask(self.target_id, request)
|
|
37
|
+
match response:
|
|
38
|
+
case Success(result):
|
|
39
|
+
return result
|
|
40
|
+
case Error(reason):
|
|
41
|
+
raise reason
|
|
42
|
+
case _:
|
|
43
|
+
raise TypeError(f"Unexpected call response: {response!r}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Proxy:
|
|
47
|
+
def __init__(self, target_id: ActorID):
|
|
48
|
+
self.target_id = target_id
|
|
49
|
+
self.teller = Teller(target_id)
|
|
50
|
+
self.asker = Asker(target_id)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def tell(self):
|
|
54
|
+
return self.teller
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def ask(self):
|
|
58
|
+
return self.asker
|
|
59
|
+
|
|
60
|
+
def kill(self):
|
|
61
|
+
tell(self.target_id, Kill())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from ..runtime.api import me, receive, send
|
|
5
|
+
from ..runtime.system import ActorID
|
|
6
|
+
from .messages import Event, Request, Response
|
|
7
|
+
|
|
8
|
+
next_exchange_id = itertools.count(1)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_exchange_id() -> int:
|
|
12
|
+
return next(next_exchange_id)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tell(id: ActorID, event: Event) -> None:
|
|
16
|
+
event.sender = me()
|
|
17
|
+
send(id, event)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ask(id: ActorID, request: Request, timeout: float | None = None) -> Response:
|
|
21
|
+
request.sender = me()
|
|
22
|
+
request.exchange_id = make_exchange_id()
|
|
23
|
+
send(id, request)
|
|
24
|
+
|
|
25
|
+
def match_id(msg: Any) -> bool:
|
|
26
|
+
# todo: there is a type checking issue but I don't know how to fix it
|
|
27
|
+
return isinstance(msg, Response) and msg.exchange_id == request.exchange_id
|
|
28
|
+
|
|
29
|
+
return receive(match_id, timeout=timeout)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from ..runtime.system import ActorID
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Message:
|
|
8
|
+
sender: ActorID = field(init=False)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Event(Message):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Request(Message):
|
|
18
|
+
exchange_id: int = field(init=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Response(Message):
|
|
23
|
+
exchange_id: int = field(kw_only=True)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .api import ActorUnavailableException, me, receive, send, spawn
|
|
2
|
+
from .lifecycle import (
|
|
3
|
+
register_thread_as_actor,
|
|
4
|
+
run,
|
|
5
|
+
run_main,
|
|
6
|
+
unregister_thread_as_actor,
|
|
7
|
+
)
|
|
8
|
+
from .mailbox import Mailbox
|
|
9
|
+
from .system import ActorFunction, ActorID, ActorRecord, ActorSystem, set_local_actor
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ActorFunction",
|
|
13
|
+
"ActorID",
|
|
14
|
+
"ActorRecord",
|
|
15
|
+
"Mailbox",
|
|
16
|
+
"ActorSystem",
|
|
17
|
+
"ActorUnavailableException",
|
|
18
|
+
"me",
|
|
19
|
+
"receive",
|
|
20
|
+
"run",
|
|
21
|
+
"run_main",
|
|
22
|
+
"send",
|
|
23
|
+
"set_local_actor",
|
|
24
|
+
"register_thread_as_actor",
|
|
25
|
+
"unregister_thread_as_actor",
|
|
26
|
+
"spawn",
|
|
27
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Main actor API: spawn actors, send and receive messages, and get actor identity."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from .system import ActorFunction, ActorID, thread_context
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def spawn(target: ActorFunction, *args: Any, **kwargs: Any) -> ActorID:
|
|
9
|
+
"""Spawn a new actor.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
target (ActorFunction): The function to run in the actor.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
ActorID: The id of the new actor.
|
|
16
|
+
"""
|
|
17
|
+
return thread_context.actor.system.run(target, *args, **kwargs)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ActorUnavailableException(Exception):
|
|
21
|
+
"""Raised when
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
Exception (_type_): _description_
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, actor_id: ActorID) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.actor_id = actor_id
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def send(id: ActorID, message: Any) -> None:
|
|
33
|
+
"""Send an actor a message.
|
|
34
|
+
|
|
35
|
+
Messages can be of any type. Convention is strings or tuples.
|
|
36
|
+
String if the messages have no arguments.
|
|
37
|
+
Tuples if they have arguments.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
id (ActorID): The id of the target actor.
|
|
41
|
+
message (Any): The message to send.
|
|
42
|
+
"""
|
|
43
|
+
actors = thread_context.actor.system.actors
|
|
44
|
+
try:
|
|
45
|
+
target = actors[id]
|
|
46
|
+
except KeyError:
|
|
47
|
+
raise ActorUnavailableException(id)
|
|
48
|
+
else:
|
|
49
|
+
target.mailbox.put(message)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def receive(
|
|
53
|
+
predicate: Callable[[Any], bool] | None = None,
|
|
54
|
+
block: bool = True,
|
|
55
|
+
timeout: float | None = None,
|
|
56
|
+
) -> Any:
|
|
57
|
+
"""Recieve the next message in the mailbox.
|
|
58
|
+
|
|
59
|
+
Blocks and waits until there is a message in the mailbox then gets it.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Any: The next message in the mailbox.
|
|
63
|
+
"""
|
|
64
|
+
mailbox = thread_context.actor.mailbox
|
|
65
|
+
return mailbox.get(predicate, block, timeout)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def me() -> ActorID:
|
|
69
|
+
"""Get the id of the current actor.
|
|
70
|
+
|
|
71
|
+
Equivalent to self in Erlang.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
ActorID: The id of the current actor.
|
|
75
|
+
"""
|
|
76
|
+
return thread_context.actor.id
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import Any, Tuple
|
|
2
|
+
|
|
3
|
+
from .system import ActorFunction, ActorID, ActorSystem, set_local_actor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run(
|
|
7
|
+
target: ActorFunction, *args: Any, **kwargs: Any
|
|
8
|
+
) -> Tuple[ActorID, ActorSystem]:
|
|
9
|
+
"""Run starts the target function as an actor in a new ActorSystem.
|
|
10
|
+
|
|
11
|
+
This helper abstracts a common pattern used when programs are started.
|
|
12
|
+
It let's you just do something like:
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
run(main)
|
|
16
|
+
|
|
17
|
+
where main is the function for an actor.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
target (ActorFunction): The function to run in the actor.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Tuple[ActorID, ActorSystem]: The id of the new actor and the system.
|
|
24
|
+
"""
|
|
25
|
+
system = ActorSystem()
|
|
26
|
+
id = system.run(target, *args, **kwargs)
|
|
27
|
+
return id, system
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_thread_as_actor(system: ActorSystem) -> ActorID:
|
|
31
|
+
actor = system.next_actor()
|
|
32
|
+
set_local_actor(actor)
|
|
33
|
+
return actor.id
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def unregister_thread_as_actor(system: ActorSystem, main_actor_id: ActorID) -> None:
|
|
37
|
+
system.remove_actor(main_actor_id)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_main(func: ActorFunction, *args: Any, **kwargs: Any) -> None:
|
|
41
|
+
"""Create an actor for the main thread and then run the passesd function.
|
|
42
|
+
|
|
43
|
+
This helper abstracts a common pattern used when programs are started.
|
|
44
|
+
It let's you just do something like:
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
run_main(main)
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
func (ActorFunction): A function to act as our main.
|
|
51
|
+
"""
|
|
52
|
+
system = ActorSystem()
|
|
53
|
+
actor = register_thread_as_actor(system)
|
|
54
|
+
try:
|
|
55
|
+
func(*args, **kwargs)
|
|
56
|
+
finally:
|
|
57
|
+
unregister_thread_as_actor(system, actor)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections import deque
|
|
3
|
+
from threading import Condition
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Mailbox:
|
|
8
|
+
"""Mailbox implements a queue of messages used for each actor."""
|
|
9
|
+
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self.messages: deque[Any] = deque()
|
|
12
|
+
self.condition = Condition()
|
|
13
|
+
|
|
14
|
+
def put(self, message: Any):
|
|
15
|
+
"""Add a message to the mailbox.
|
|
16
|
+
|
|
17
|
+
This operation does not block.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
message (Any): A value to use as a message.
|
|
21
|
+
"""
|
|
22
|
+
with self.condition:
|
|
23
|
+
self.messages.append(message) # add new ones to the back
|
|
24
|
+
self.condition.notify() # notify threads that are waiting
|
|
25
|
+
|
|
26
|
+
def get(
|
|
27
|
+
self,
|
|
28
|
+
predicate: Callable[[Any], bool] | None = None,
|
|
29
|
+
block: bool = True,
|
|
30
|
+
timeout: float | None = None,
|
|
31
|
+
) -> Any:
|
|
32
|
+
"""Gets a message from the mailbox.
|
|
33
|
+
|
|
34
|
+
The default behaviour is get the oldest message out of the mailbox.
|
|
35
|
+
If there isn't one, the method blocks indefinately until on arrives.
|
|
36
|
+
If block is False it means non-blocking and return immediately (timeout is ignored).
|
|
37
|
+
If block is True the behaviour is determined by timeout parameter.
|
|
38
|
+
If timeout is None, this mean block indefinately.
|
|
39
|
+
Otherwise block for that number of seconds.
|
|
40
|
+
Note that this means that if timeout is 0.0, this is the same as non-blocking.
|
|
41
|
+
Selective receive is supported by passing a predicate function that matches message.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
predicate (Callable[..., bool] | None, optional): A condition to use to select a message. Defaults to None.
|
|
45
|
+
block (bool, optional): Should it block until there is a message. Defaults to True.
|
|
46
|
+
timeout (float, optional): How long it should block for. Defaults to 0.0.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
TimeoutError: The get timed out waiting for a message.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Any: The message or None.
|
|
53
|
+
"""
|
|
54
|
+
# this object is used when the queue is EMPTY
|
|
55
|
+
# this avoids falsey messasges like [] being missed
|
|
56
|
+
EMPTY = object()
|
|
57
|
+
|
|
58
|
+
# if there is a timeout, work out when the deadline is
|
|
59
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
60
|
+
|
|
61
|
+
def get_next_message() -> Any:
|
|
62
|
+
"""Pop the next message in the messages queue.
|
|
63
|
+
Return EMPTY if there isn't one.
|
|
64
|
+
Do not block.
|
|
65
|
+
"""
|
|
66
|
+
if predicate:
|
|
67
|
+
# the queue is organised oldest at front
|
|
68
|
+
for idx, message in enumerate(self.messages):
|
|
69
|
+
if predicate(message):
|
|
70
|
+
del self.messages[idx]
|
|
71
|
+
return message
|
|
72
|
+
return EMPTY
|
|
73
|
+
else:
|
|
74
|
+
if self.messages:
|
|
75
|
+
return self.messages.popleft()
|
|
76
|
+
else:
|
|
77
|
+
return EMPTY
|
|
78
|
+
|
|
79
|
+
# while there are no message
|
|
80
|
+
with self.condition:
|
|
81
|
+
while (message := get_next_message()) is EMPTY:
|
|
82
|
+
if not block:
|
|
83
|
+
# if there is no messages are we are not blocking
|
|
84
|
+
message = None
|
|
85
|
+
break
|
|
86
|
+
elif not deadline:
|
|
87
|
+
# if there is no message or timeout
|
|
88
|
+
# wait until the queue changes
|
|
89
|
+
self.condition.wait()
|
|
90
|
+
else:
|
|
91
|
+
# if there are no messages and we are blocking with a timeout
|
|
92
|
+
# workout the remaining time
|
|
93
|
+
remaining = deadline - time.monotonic()
|
|
94
|
+
|
|
95
|
+
# if it is less than or equal to zero, throw an exception
|
|
96
|
+
if remaining <= 0:
|
|
97
|
+
raise TimeoutError
|
|
98
|
+
|
|
99
|
+
# otherwise block until message is added to queue or we run
|
|
100
|
+
# out of remaining time on the timeout
|
|
101
|
+
self.condition.wait(remaining)
|
|
102
|
+
|
|
103
|
+
# return the oldest message (new messages are added on the right)
|
|
104
|
+
return message
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from threading import Thread, local
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from .mailbox import Mailbox
|
|
9
|
+
|
|
10
|
+
# an actor function is a function that can have any args and returns None
|
|
11
|
+
ActorFunction = Callable[..., None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ActorID:
|
|
16
|
+
"""Identifies an actor in the system.
|
|
17
|
+
Equivilent to an Erlang PID.
|
|
18
|
+
|
|
19
|
+
If we move to a distributed system, this can be exteneded to a more
|
|
20
|
+
complex address. Also we could us it as an adapter to fake method style
|
|
21
|
+
sends. E.g. my_actor.send(()) rather then send(my_actor, ()).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
identifier: int
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
return str(self.identifier)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ActorRecord:
|
|
32
|
+
"""Holds all the information about a specific actor.
|
|
33
|
+
|
|
34
|
+
For convinience there is also a reference to the ActorSystem that is
|
|
35
|
+
running the actor.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
id: ActorID
|
|
39
|
+
system: ActorSystem
|
|
40
|
+
mailbox: Mailbox = field(default_factory=Mailbox)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# a thread local object to hold each threads instance of the Actor class
|
|
44
|
+
# See https://docs.python.org/3/library/threading.html#thread-local-data.
|
|
45
|
+
thread_context = local()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_local_actor(actor: ActorRecord):
|
|
49
|
+
"""Set the actor for this thread.
|
|
50
|
+
|
|
51
|
+
Useful when setting the actor for the main thread.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
actor (Actor): The actor to use for this thread.
|
|
55
|
+
"""
|
|
56
|
+
thread_context.actor = actor
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ActorSystem:
|
|
60
|
+
"""A container for the actors."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self.next_id = itertools.count(1)
|
|
64
|
+
self.actors: dict[ActorID, ActorRecord] = {}
|
|
65
|
+
|
|
66
|
+
def next_actor(self):
|
|
67
|
+
id = ActorID(next(self.next_id))
|
|
68
|
+
actor = ActorRecord(id=id, system=self)
|
|
69
|
+
self.actors[id] = actor
|
|
70
|
+
return actor
|
|
71
|
+
|
|
72
|
+
def remove_actor(self, actor_id: ActorID) -> None:
|
|
73
|
+
del self.actors[actor_id]
|
|
74
|
+
del thread_context.actor
|
|
75
|
+
|
|
76
|
+
def run(self, target: ActorFunction, *args: Any, **kwargs: Any) -> ActorID:
|
|
77
|
+
"""Creates a new actor in the system based on the function that is passed in.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
target (ActorFunction): The function to run as the actor.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
ActorID: The actor id object, used to send messages to the actor.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
actor = self.next_actor()
|
|
87
|
+
|
|
88
|
+
def run_actor():
|
|
89
|
+
set_local_actor(actor)
|
|
90
|
+
try:
|
|
91
|
+
target(*args, **kwargs)
|
|
92
|
+
finally:
|
|
93
|
+
self.remove_actor(actor.id)
|
|
94
|
+
|
|
95
|
+
# initialise the thread to target the run_actor function
|
|
96
|
+
thread = Thread(target=run_actor)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
# start the thread
|
|
100
|
+
thread.start()
|
|
101
|
+
except Exception:
|
|
102
|
+
# if it fails remove it for the actors list
|
|
103
|
+
del self.actors[actor.id]
|
|
104
|
+
raise
|
|
105
|
+
return actor.id
|