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.
@@ -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,11 @@
1
+ from .exchange import ask, tell
2
+ from .messages import Event, Message, Request, Response
3
+
4
+ __all__ = [
5
+ "Event",
6
+ "Message",
7
+ "Request",
8
+ "Response",
9
+ "ask",
10
+ "tell",
11
+ ]
@@ -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