objectspell 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.
- objectspell-0.1.0/LICENSE +21 -0
- objectspell-0.1.0/PKG-INFO +146 -0
- objectspell-0.1.0/README.md +124 -0
- objectspell-0.1.0/pyproject.toml +71 -0
- objectspell-0.1.0/pyproject.toml.orig +60 -0
- objectspell-0.1.0/src/objectspell/__init__.py +13 -0
- objectspell-0.1.0/src/objectspell/connector.py +123 -0
- objectspell-0.1.0/src/objectspell/emitter.py +69 -0
- objectspell-0.1.0/src/objectspell/py.typed +0 -0
- objectspell-0.1.0/src/objectspell/receiver.py +21 -0
- objectspell-0.1.0/src/objectspell/state.py +178 -0
- objectspell-0.1.0/src/objectspell/struct.py +15 -0
- objectspell-0.1.0/src/objectspell/types.py +22 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Max Sukhorukov <signaldetect@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: objectspell
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A library for building asynchronous messaging topologies using declarative implicit wiring.
|
|
5
|
+
Keywords: asyncio,event-driven,messaging,signals,pubsub
|
|
6
|
+
Author: Max Sukhorukov
|
|
7
|
+
Author-email: Max Sukhorukov <signaldetect@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Project-URL: Homepage, https://github.com/invertedpoint/objectspell-py
|
|
20
|
+
Project-URL: Issues, https://github.com/invertedpoint/objectspell-py/issues
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ObjectSpell
|
|
24
|
+
|
|
25
|
+
A small asyncio framework for wiring components together with signals, without registries,
|
|
26
|
+
decorators, or callback lists. You declare what a component sends and what it listens to;
|
|
27
|
+
ObjectSpell connects them by name.
|
|
28
|
+
|
|
29
|
+
Zero dependencies. Python 3.12+.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install objectspell # or: uv add objectspell
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
Each component lives in its own module. Here `X` sends a message, `Y` prints it, and the
|
|
40
|
+
program stops once `Y` is done.
|
|
41
|
+
|
|
42
|
+
**`x.py`** — sends `something_happened`
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from objectspell import struct
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Emitter(struct.Emitter):
|
|
49
|
+
async def something_happened(self, message: str) -> None:
|
|
50
|
+
pass # a declaration, not an implementation
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class X(struct.State, Emitter):
|
|
54
|
+
async def action(self) -> None:
|
|
55
|
+
await self.something_happened(message="OK Computer")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Connector(struct.Receiver, X):
|
|
59
|
+
async def connected(self) -> None:
|
|
60
|
+
await self.action()
|
|
61
|
+
|
|
62
|
+
async def disconnected(self) -> None:
|
|
63
|
+
pass
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**`y.py`** — listens to `X`, then reports it is done
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from objectspell import struct
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Emitter(struct.Emitter):
|
|
73
|
+
async def completed(self) -> None:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Y(struct.State, Emitter):
|
|
78
|
+
async def show(self, message: str) -> None:
|
|
79
|
+
print("Y received:", message)
|
|
80
|
+
await self.completed()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class X(struct.Receiver, Y):
|
|
84
|
+
async def something_happened(self, message: str) -> None:
|
|
85
|
+
await self.show(message)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**`connector.py`** — stops everything when `Y` is done
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from objectspell import struct
|
|
92
|
+
from objectspell.struct import Connector
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Y(struct.Receiver, Connector):
|
|
96
|
+
async def completed(self) -> None:
|
|
97
|
+
await self.disconnect()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**`main.py`**
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import asyncio
|
|
104
|
+
|
|
105
|
+
from connector import Connector
|
|
106
|
+
from x import X
|
|
107
|
+
from y import Y
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def main() -> None:
|
|
111
|
+
await Connector().connect([X(), Y()])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
asyncio.run(main())
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```console
|
|
119
|
+
$ python main.py
|
|
120
|
+
Y received: OK Computer
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Notice that `X` and `Y` never import each other. The only thing linking them is the class
|
|
124
|
+
name: `y.py` declares `class X(struct.Receiver, Y)`, which means "on `Y`, handle signals
|
|
125
|
+
from `X`".
|
|
126
|
+
|
|
127
|
+
## The four pieces
|
|
128
|
+
|
|
129
|
+
- **Emitter** — declares the signals a component can send. The method bodies are never run;
|
|
130
|
+
ObjectSpell replaces them with senders.
|
|
131
|
+
- **State** — a component. It owns a queue and handles one signal at a time.
|
|
132
|
+
- **Receiver** — a handler group attached to a State, **named after the State it listens to**.
|
|
133
|
+
- **Connector** — starts and stops the whole topology.
|
|
134
|
+
|
|
135
|
+
## Documentation
|
|
136
|
+
|
|
137
|
+
- [Concepts](https://github.com/invertedpoint/objectspell-py/blob/main/docs/concepts.md) — the four pieces and how a signal travels
|
|
138
|
+
- [Wiring](https://github.com/invertedpoint/objectspell-py/blob/main/docs/wiring.md) — the naming rule, and why each component gets its own module
|
|
139
|
+
- [Lifecycle](https://github.com/invertedpoint/objectspell-py/blob/main/docs/lifecycle.md) — starting up, shutting down, and message order
|
|
140
|
+
- [Limitations](https://github.com/invertedpoint/objectspell-py/blob/main/docs/limitations.md) — what this library does not do
|
|
141
|
+
|
|
142
|
+
Runnable examples live in [`examples/`](https://github.com/invertedpoint/objectspell-py/tree/main/examples).
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT — see [LICENSE](https://github.com/invertedpoint/objectspell-py/blob/main/LICENSE).
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# ObjectSpell
|
|
2
|
+
|
|
3
|
+
A small asyncio framework for wiring components together with signals, without registries,
|
|
4
|
+
decorators, or callback lists. You declare what a component sends and what it listens to;
|
|
5
|
+
ObjectSpell connects them by name.
|
|
6
|
+
|
|
7
|
+
Zero dependencies. Python 3.12+.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install objectspell # or: uv add objectspell
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
Each component lives in its own module. Here `X` sends a message, `Y` prints it, and the
|
|
18
|
+
program stops once `Y` is done.
|
|
19
|
+
|
|
20
|
+
**`x.py`** — sends `something_happened`
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from objectspell import struct
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Emitter(struct.Emitter):
|
|
27
|
+
async def something_happened(self, message: str) -> None:
|
|
28
|
+
pass # a declaration, not an implementation
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class X(struct.State, Emitter):
|
|
32
|
+
async def action(self) -> None:
|
|
33
|
+
await self.something_happened(message="OK Computer")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Connector(struct.Receiver, X):
|
|
37
|
+
async def connected(self) -> None:
|
|
38
|
+
await self.action()
|
|
39
|
+
|
|
40
|
+
async def disconnected(self) -> None:
|
|
41
|
+
pass
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**`y.py`** — listens to `X`, then reports it is done
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from objectspell import struct
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Emitter(struct.Emitter):
|
|
51
|
+
async def completed(self) -> None:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Y(struct.State, Emitter):
|
|
56
|
+
async def show(self, message: str) -> None:
|
|
57
|
+
print("Y received:", message)
|
|
58
|
+
await self.completed()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class X(struct.Receiver, Y):
|
|
62
|
+
async def something_happened(self, message: str) -> None:
|
|
63
|
+
await self.show(message)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**`connector.py`** — stops everything when `Y` is done
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from objectspell import struct
|
|
70
|
+
from objectspell.struct import Connector
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Y(struct.Receiver, Connector):
|
|
74
|
+
async def completed(self) -> None:
|
|
75
|
+
await self.disconnect()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**`main.py`**
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import asyncio
|
|
82
|
+
|
|
83
|
+
from connector import Connector
|
|
84
|
+
from x import X
|
|
85
|
+
from y import Y
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def main() -> None:
|
|
89
|
+
await Connector().connect([X(), Y()])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
asyncio.run(main())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```console
|
|
97
|
+
$ python main.py
|
|
98
|
+
Y received: OK Computer
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Notice that `X` and `Y` never import each other. The only thing linking them is the class
|
|
102
|
+
name: `y.py` declares `class X(struct.Receiver, Y)`, which means "on `Y`, handle signals
|
|
103
|
+
from `X`".
|
|
104
|
+
|
|
105
|
+
## The four pieces
|
|
106
|
+
|
|
107
|
+
- **Emitter** — declares the signals a component can send. The method bodies are never run;
|
|
108
|
+
ObjectSpell replaces them with senders.
|
|
109
|
+
- **State** — a component. It owns a queue and handles one signal at a time.
|
|
110
|
+
- **Receiver** — a handler group attached to a State, **named after the State it listens to**.
|
|
111
|
+
- **Connector** — starts and stops the whole topology.
|
|
112
|
+
|
|
113
|
+
## Documentation
|
|
114
|
+
|
|
115
|
+
- [Concepts](https://github.com/invertedpoint/objectspell-py/blob/main/docs/concepts.md) — the four pieces and how a signal travels
|
|
116
|
+
- [Wiring](https://github.com/invertedpoint/objectspell-py/blob/main/docs/wiring.md) — the naming rule, and why each component gets its own module
|
|
117
|
+
- [Lifecycle](https://github.com/invertedpoint/objectspell-py/blob/main/docs/lifecycle.md) — starting up, shutting down, and message order
|
|
118
|
+
- [Limitations](https://github.com/invertedpoint/objectspell-py/blob/main/docs/limitations.md) — what this library does not do
|
|
119
|
+
|
|
120
|
+
Runnable examples live in [`examples/`](https://github.com/invertedpoint/objectspell-py/tree/main/examples).
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT — see [LICENSE](https://github.com/invertedpoint/objectspell-py/blob/main/LICENSE).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "objectspell"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A library for building asynchronous messaging topologies using declarative implicit wiring."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"asyncio",
|
|
11
|
+
"event-driven",
|
|
12
|
+
"messaging",
|
|
13
|
+
"signals",
|
|
14
|
+
"pubsub",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Framework :: AsyncIO",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "Max Sukhorukov"
|
|
30
|
+
email = "signaldetect@gmail.com"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/invertedpoint/objectspell-py"
|
|
34
|
+
Issues = "https://github.com/invertedpoint/objectspell-py/issues"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.uv.build-backend]
|
|
41
|
+
module-name = "objectspell"
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 120
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = [
|
|
48
|
+
"E",
|
|
49
|
+
"F",
|
|
50
|
+
"I",
|
|
51
|
+
"UP",
|
|
52
|
+
"B",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
strict = true
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
asyncio_mode = "strict"
|
|
61
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
62
|
+
timeout = 10
|
|
63
|
+
|
|
64
|
+
[dependency-groups]
|
|
65
|
+
dev = [
|
|
66
|
+
"mypy>=2.3.1",
|
|
67
|
+
"pytest>=9.1.1",
|
|
68
|
+
"pytest-asyncio>=1.4.0",
|
|
69
|
+
"pytest-timeout>=2.3.1",
|
|
70
|
+
"ruff>=0.16.4",
|
|
71
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "objectspell"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A library for building asynchronous messaging topologies using declarative implicit wiring."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Max Sukhorukov", email = "signaldetect@gmail.com" }]
|
|
10
|
+
keywords = ["asyncio", "event-driven", "messaging", "signals", "pubsub"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: AsyncIO",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/invertedpoint/objectspell-py"
|
|
25
|
+
Issues = "https://github.com/invertedpoint/objectspell-py/issues"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
# Pinned rather than inferred from the project name, so renaming the distribution
|
|
32
|
+
# cannot silently change which directory gets packaged.
|
|
33
|
+
[tool.uv.build-backend]
|
|
34
|
+
module-name = "objectspell"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"mypy>=2.3.1",
|
|
39
|
+
"pytest>=9.1.1",
|
|
40
|
+
"pytest-asyncio>=1.4.0",
|
|
41
|
+
"pytest-timeout>=2.3.1",
|
|
42
|
+
"ruff>=0.16.4",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 120
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint]
|
|
49
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
strict = true
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
asyncio_mode = "strict"
|
|
57
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
58
|
+
# The library's characteristic failure mode is an infinite hang, so an untimed
|
|
59
|
+
# suite would wedge CI instead of reporting a failure.
|
|
60
|
+
timeout = 10
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ObjectSpell: a small asyncio framework for wiring components together with signals.
|
|
3
|
+
|
|
4
|
+
The public API lives in `objectspell.struct`.
|
|
5
|
+
"""
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("objectspell")
|
|
10
|
+
except PackageNotFoundError: # running from a source tree, not an install
|
|
11
|
+
__version__ = "0.0.0.dev0"
|
|
12
|
+
|
|
13
|
+
__all__ = ("__version__",)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Starts and stops a topology, after checking that its wiring makes sense.
|
|
3
|
+
"""
|
|
4
|
+
import inspect
|
|
5
|
+
from contextlib import AsyncExitStack
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
# Imported directly rather than via `objectspell.struct`: struct imports this module, so
|
|
9
|
+
# going back through it would make struct's own import order load-bearing.
|
|
10
|
+
from objectspell.emitter import Emitter as BaseEmitter
|
|
11
|
+
from objectspell.state import State
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Emitter(BaseEmitter):
|
|
15
|
+
"""
|
|
16
|
+
The two signals the Connector sends: one once everything is wired, one to shut down.
|
|
17
|
+
"""
|
|
18
|
+
async def connected(self) -> None:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
async def disconnected(self) -> None:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Connector(State, Emitter):
|
|
26
|
+
"""
|
|
27
|
+
Starts and stops a topology.
|
|
28
|
+
|
|
29
|
+
`connect` opens every State's listener loop, wires emitters to receivers, then emits
|
|
30
|
+
`connected`. `disconnect` emits `disconnected`, which every State listens for, so that
|
|
31
|
+
single signal shuts the whole topology down.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
async def connect(self, states: list[State]) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Check the wiring, start every State, link senders to listeners, then emit `connected`.
|
|
37
|
+
|
|
38
|
+
Does not return until the topology has shut down, so it is normally the whole program.
|
|
39
|
+
"""
|
|
40
|
+
states.append(self)
|
|
41
|
+
|
|
42
|
+
emitters: dict[str, BaseEmitter] = {}
|
|
43
|
+
routes: dict[str, set[str]] = {}
|
|
44
|
+
|
|
45
|
+
for state in states:
|
|
46
|
+
state_class = type(state)
|
|
47
|
+
|
|
48
|
+
for base_class in state_class.__bases__:
|
|
49
|
+
if BaseEmitter in base_class.__bases__:
|
|
50
|
+
if state_class.__name__ in emitters:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"Two connected States are both named {state_class.__name__!r}. "
|
|
53
|
+
f"Routing is by class name, so State names must be unique."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# The base-class check above establishes that this State is an Emitter.
|
|
57
|
+
emitters[state_class.__name__] = cast(BaseEmitter, state)
|
|
58
|
+
routes[state_class.__name__] = self._o9l_signal_routes(state_class=state_class)
|
|
59
|
+
|
|
60
|
+
# Checked before any listener starts. A broken topology never delivers the signal that
|
|
61
|
+
# stops the listeners, so raising once they are running would hang in __aexit__.
|
|
62
|
+
self._o9l_check_wiring(states=states, routes=routes)
|
|
63
|
+
|
|
64
|
+
async with AsyncExitStack() as stack:
|
|
65
|
+
for state in states:
|
|
66
|
+
await stack.enter_async_context(state)
|
|
67
|
+
|
|
68
|
+
for state in states:
|
|
69
|
+
state.o9l_connecting(emitters)
|
|
70
|
+
|
|
71
|
+
await self.connected()
|
|
72
|
+
|
|
73
|
+
async def disconnect(self) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Emit `disconnected`. Every State listens for it, so this stops all of them.
|
|
76
|
+
"""
|
|
77
|
+
await self.disconnected()
|
|
78
|
+
|
|
79
|
+
def _o9l_signal_routes(self, state_class: type[State]) -> set[str]:
|
|
80
|
+
"""
|
|
81
|
+
The signals a State sends: the public async methods declared by its Emitter base.
|
|
82
|
+
|
|
83
|
+
Read from the base class, not from the State itself, because Emitter.__new__
|
|
84
|
+
overwrites those methods on the State with generated senders.
|
|
85
|
+
"""
|
|
86
|
+
routes: set[str] = set()
|
|
87
|
+
|
|
88
|
+
for base_class in state_class.__bases__:
|
|
89
|
+
if BaseEmitter in base_class.__bases__:
|
|
90
|
+
routes.update(
|
|
91
|
+
attr_name
|
|
92
|
+
for (attr_name, attr_ref) in base_class.__dict__.items()
|
|
93
|
+
if not attr_name.startswith("_") and inspect.iscoroutinefunction(attr_ref)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return routes
|
|
97
|
+
|
|
98
|
+
def _o9l_check_wiring(self, states: list[State], routes: dict[str, set[str]]) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Verify the topology before any signal moves.
|
|
101
|
+
|
|
102
|
+
Both problems caught here would otherwise surface far from their cause: a receiver
|
|
103
|
+
named after nothing leaves the system waiting for a signal that never arrives, and a
|
|
104
|
+
receiver missing a route fails only once that route is used, often during shutdown.
|
|
105
|
+
"""
|
|
106
|
+
for state in states:
|
|
107
|
+
state_name = type(state).__name__
|
|
108
|
+
|
|
109
|
+
for (channel, receiver_class) in state.o9l_receiver_classes().items():
|
|
110
|
+
if channel not in routes:
|
|
111
|
+
channels = ", ".join(sorted(routes)) or "none"
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"{state_name} declares a receiver named {channel!r}, but no connected State "
|
|
114
|
+
f"emits on that channel. A receiver class must be named exactly after the "
|
|
115
|
+
f"State whose signals it handles. Connected channels: {channels}."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if missing := sorted(routes[channel] - receiver_class.__dict__.keys()):
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
f"{state_name}.{channel} does not handle every signal on channel "
|
|
121
|
+
f"{channel!r}: missing {', '.join(missing)}. A receiver must define one "
|
|
122
|
+
f"method per signal its channel declares."
|
|
123
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The sending half of the wiring: turns declared async methods into signal senders.
|
|
3
|
+
"""
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from objectspell.receiver import Receivable
|
|
8
|
+
from objectspell.types import Signal, SignalSender
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Emitter:
|
|
12
|
+
"""
|
|
13
|
+
A base class for signal emitters. Subclasses can define async methods that act as signal triggers.
|
|
14
|
+
When triggered, the Emitter sends a Signal to all connected Receivables.
|
|
15
|
+
"""
|
|
16
|
+
_o9l_receivables: list[Receivable]
|
|
17
|
+
|
|
18
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> "Emitter":
|
|
19
|
+
"""
|
|
20
|
+
Replace every public async method declared by the Emitter base with a sender.
|
|
21
|
+
|
|
22
|
+
The declared bodies are never run: they only name the signal and its arguments.
|
|
23
|
+
"""
|
|
24
|
+
for base_class in cls.__bases__:
|
|
25
|
+
if Emitter in base_class.__bases__:
|
|
26
|
+
for (attr_name, attr_ref) in base_class.__dict__.items():
|
|
27
|
+
if not attr_name.startswith("_") and inspect.iscoroutinefunction(attr_ref):
|
|
28
|
+
setattr(cls, attr_name, cls._o9l_signal_sender(signal_route=attr_name))
|
|
29
|
+
|
|
30
|
+
return super().__new__(cls)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def _o9l_signal_sender(cls, signal_route: str) -> SignalSender:
|
|
34
|
+
"""
|
|
35
|
+
Build the sender that replaces one declared method.
|
|
36
|
+
|
|
37
|
+
`cls` is the concrete State being created, so its name becomes the signal's channel.
|
|
38
|
+
"""
|
|
39
|
+
async def _send(self: Any, **kwargs: Any) -> None:
|
|
40
|
+
for receivable in self._o9l_receivables:
|
|
41
|
+
await receivable.o9l_receive(
|
|
42
|
+
signal=Signal(
|
|
43
|
+
channel=cls.__name__,
|
|
44
|
+
route=signal_route,
|
|
45
|
+
message=kwargs
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return _send
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Start with nothing connected. The Connector fills this in during `connect`.
|
|
54
|
+
"""
|
|
55
|
+
super().__init__()
|
|
56
|
+
|
|
57
|
+
self._o9l_receivables = []
|
|
58
|
+
|
|
59
|
+
def o9l_connect(self, receivable: Receivable) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Connect a Receivable to this Emitter. The Receivable will receive Signals when this Emitter triggers.
|
|
62
|
+
"""
|
|
63
|
+
if receivable not in self._o9l_receivables:
|
|
64
|
+
self._o9l_receivables.append(receivable)
|
|
65
|
+
else:
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
f"Receivable {receivable.__class__.__name__} already connected "
|
|
68
|
+
f"to {self.__class__.__name__}"
|
|
69
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The receiving half of the wiring: where a signal is delivered, and how handlers are grouped.
|
|
3
|
+
"""
|
|
4
|
+
from objectspell.types import Signal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Receivable:
|
|
8
|
+
"""
|
|
9
|
+
Anything a Signal can be delivered to. State is the only implementation.
|
|
10
|
+
"""
|
|
11
|
+
async def o9l_receive(self, signal: Signal) -> None:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Receiver:
|
|
16
|
+
"""
|
|
17
|
+
Marks a class as a group of signal handlers rather than a component.
|
|
18
|
+
|
|
19
|
+
Subclass it alongside the State the handlers belong to, and name the subclass after the
|
|
20
|
+
State whose signals it handles. That name is the wiring.
|
|
21
|
+
"""
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A component: its signal queue, its listener loop, and the receivers routed to it.
|
|
3
|
+
"""
|
|
4
|
+
import asyncio
|
|
5
|
+
import inspect
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from objectspell.emitter import Emitter
|
|
10
|
+
from objectspell.receiver import Receivable, Receiver
|
|
11
|
+
from objectspell.types import Signal, SignalHandler
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class State(Receivable):
|
|
15
|
+
"""
|
|
16
|
+
A base class for maintaining state and managing signal routing between Emitters and Receivers.
|
|
17
|
+
It runs an asynchronous listener loop to process incoming signals.
|
|
18
|
+
"""
|
|
19
|
+
_o9l_receiver_classes: dict[str, type[Receiver]]
|
|
20
|
+
|
|
21
|
+
_o9l_queue: asyncio.Queue[Signal]
|
|
22
|
+
_o9l_is_stopped: bool
|
|
23
|
+
_o9l_listener: asyncio.Task[None]
|
|
24
|
+
|
|
25
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> "State":
|
|
26
|
+
"""
|
|
27
|
+
Collect this class's Receiver subclasses, keyed by class name -- the channel they listen to.
|
|
28
|
+
|
|
29
|
+
Any State that listens to something also listens to the Connector, so it can be told
|
|
30
|
+
to stop. If no Connector receiver was written, the default one is used.
|
|
31
|
+
"""
|
|
32
|
+
cls._o9l_receiver_classes = {}
|
|
33
|
+
|
|
34
|
+
for sub_class in cls.__subclasses__():
|
|
35
|
+
if Receiver in sub_class.__bases__:
|
|
36
|
+
channel = sub_class.__name__
|
|
37
|
+
cls._o9l_receiver_classes[channel] = cast(type[Receiver], sub_class)
|
|
38
|
+
|
|
39
|
+
if cls._o9l_receiver_classes:
|
|
40
|
+
if "Connector" in cls._o9l_receiver_classes:
|
|
41
|
+
cls._o9l_enrich_connector(connector_cls=cls._o9l_receiver_classes["Connector"])
|
|
42
|
+
else:
|
|
43
|
+
cls._o9l_receiver_classes["Connector"] = Connector
|
|
44
|
+
|
|
45
|
+
return super().__new__(cls)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def _o9l_enrich_connector(cls, connector_cls: type[Receiver]) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Keep the built-in Connector behaviour when a custom Connector receiver overrides it.
|
|
51
|
+
|
|
52
|
+
Without this, writing your own `disconnected` would replace the one that stops the
|
|
53
|
+
listener, and the State would never shut down.
|
|
54
|
+
"""
|
|
55
|
+
for (attr_name, attr_ref) in Connector.__dict__.items():
|
|
56
|
+
if (
|
|
57
|
+
not attr_name.startswith("_")
|
|
58
|
+
and inspect.iscoroutinefunction(attr_ref)
|
|
59
|
+
and (signal_handler := connector_cls.__dict__.get(attr_name))
|
|
60
|
+
):
|
|
61
|
+
setattr(
|
|
62
|
+
connector_cls,
|
|
63
|
+
attr_name,
|
|
64
|
+
cls._o9l_extended_signal_handler(signal_handler=signal_handler, extra_handler=attr_ref)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def _o9l_extended_signal_handler(cls, signal_handler: SignalHandler, extra_handler: SignalHandler) -> SignalHandler:
|
|
69
|
+
"""
|
|
70
|
+
Chain two handlers into one, running the custom handler before the built-in one.
|
|
71
|
+
"""
|
|
72
|
+
async def _handle(self: Any, **kwargs: Any) -> None:
|
|
73
|
+
await signal_handler(self, **kwargs)
|
|
74
|
+
await extra_handler(self, **kwargs)
|
|
75
|
+
|
|
76
|
+
return _handle
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Create the signal queue. The listener that drains it starts on `__aenter__`.
|
|
81
|
+
"""
|
|
82
|
+
super().__init__()
|
|
83
|
+
|
|
84
|
+
self._o9l_queue = asyncio.Queue()
|
|
85
|
+
self._o9l_is_stopped = False
|
|
86
|
+
|
|
87
|
+
async def __aenter__(self) -> "State":
|
|
88
|
+
"""
|
|
89
|
+
Enter the asynchronous context, starting the signal listener loop if receivers are present.
|
|
90
|
+
"""
|
|
91
|
+
if self._o9l_receiver_classes:
|
|
92
|
+
# Start listening for signals
|
|
93
|
+
self._o9l_listener = asyncio.create_task(self._o9l_listening())
|
|
94
|
+
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
async def __aexit__(
|
|
98
|
+
self,
|
|
99
|
+
exc_type: type[BaseException] | None,
|
|
100
|
+
exc_value: BaseException | None,
|
|
101
|
+
traceback: TracebackType | None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Exit the asynchronous context, waiting for the listener to complete if it was started.
|
|
105
|
+
"""
|
|
106
|
+
if self._o9l_receiver_classes:
|
|
107
|
+
# Wait for the listener to complete listening for signals
|
|
108
|
+
await self._o9l_listener
|
|
109
|
+
|
|
110
|
+
def o9l_receiver_classes(self) -> dict[str, type[Receiver]]:
|
|
111
|
+
"""
|
|
112
|
+
The receiver classes of this State, keyed by the channel each one listens to.
|
|
113
|
+
"""
|
|
114
|
+
return self._o9l_receiver_classes
|
|
115
|
+
|
|
116
|
+
def o9l_connecting(self, emitters: dict[str, Emitter]) -> None:
|
|
117
|
+
"""
|
|
118
|
+
Connect the provided emitters to this State based on registered receiver classes.
|
|
119
|
+
"""
|
|
120
|
+
for channel in self._o9l_receiver_classes.keys():
|
|
121
|
+
if emitter := emitters.get(channel):
|
|
122
|
+
emitter.o9l_connect(receivable=self)
|
|
123
|
+
|
|
124
|
+
async def o9l_receive(self, signal: Signal) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Receive a signal and enqueue it for processing by the listener loop.
|
|
127
|
+
"""
|
|
128
|
+
await self._o9l_queue.put(signal)
|
|
129
|
+
|
|
130
|
+
async def _o9l_listening(self) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Start processing signals in the queue
|
|
133
|
+
"""
|
|
134
|
+
while not self._o9l_is_stopped:
|
|
135
|
+
try:
|
|
136
|
+
signal = await self._o9l_queue.get()
|
|
137
|
+
|
|
138
|
+
if receiver_class := self._o9l_receiver_classes.get(signal.channel):
|
|
139
|
+
if signal_handler := receiver_class.__dict__.get(signal.route):
|
|
140
|
+
await signal_handler(self, **signal.message)
|
|
141
|
+
else:
|
|
142
|
+
raise RuntimeError(
|
|
143
|
+
f"No handler found for {signal.channel}.{signal.route} "
|
|
144
|
+
f"in {receiver_class.__name__}"
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
raise RuntimeError(f"[{self.__class__.__name__}] No receiver found for {signal.channel}")
|
|
148
|
+
|
|
149
|
+
self._o9l_queue.task_done()
|
|
150
|
+
except asyncio.CancelledError:
|
|
151
|
+
self._o9l_is_stopped = True
|
|
152
|
+
|
|
153
|
+
def _o9l_stop_listening(self) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Stop processing signals
|
|
156
|
+
"""
|
|
157
|
+
self._o9l_is_stopped = True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Connector(Receiver, State):
|
|
161
|
+
"""
|
|
162
|
+
The default handling of the Connector channel, given to every State that listens.
|
|
163
|
+
|
|
164
|
+
This is what makes one `disconnected` signal stop the whole topology. Not to be confused
|
|
165
|
+
with `objectspell.connector.Connector`, the component you instantiate.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
async def connected(self) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Nothing to do by default. Override it in your own receiver named `Connector`.
|
|
171
|
+
"""
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
async def disconnected(self) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Stop this State's listener loop.
|
|
177
|
+
"""
|
|
178
|
+
self._o9l_stop_listening()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core components of the objectspell library.
|
|
3
|
+
Provides Emitter, Receiver, State, and Connector for building asynchronous messaging topologies.
|
|
4
|
+
"""
|
|
5
|
+
from objectspell.connector import Connector
|
|
6
|
+
from objectspell.emitter import Emitter
|
|
7
|
+
from objectspell.receiver import Receiver
|
|
8
|
+
from objectspell.state import State
|
|
9
|
+
|
|
10
|
+
__all__ = (
|
|
11
|
+
"Emitter",
|
|
12
|
+
"Receiver",
|
|
13
|
+
"State",
|
|
14
|
+
"Connector",
|
|
15
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What a signal is, and the shape of the callables that send and handle one.
|
|
3
|
+
"""
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Signal:
|
|
11
|
+
"""
|
|
12
|
+
One message in flight: which State sent it, which method it maps to, and its payload.
|
|
13
|
+
"""
|
|
14
|
+
channel: str
|
|
15
|
+
route: str
|
|
16
|
+
message: dict[str, Any]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Both are unbound methods called as `fn(self, **message)`, so the parameters
|
|
20
|
+
# cannot be described more precisely than `...`.
|
|
21
|
+
type SignalSender = Callable[..., Awaitable[None]]
|
|
22
|
+
type SignalHandler = Callable[..., Awaitable[None]]
|