radiodish 1.0.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.
- radiodish-1.0.0/LICENSE +21 -0
- radiodish-1.0.0/PKG-INFO +79 -0
- radiodish-1.0.0/README.md +65 -0
- radiodish-1.0.0/pyproject.toml +35 -0
- radiodish-1.0.0/radiodish.egg-info/PKG-INFO +79 -0
- radiodish-1.0.0/radiodish.egg-info/SOURCES.txt +27 -0
- radiodish-1.0.0/radiodish.egg-info/dependency_links.txt +1 -0
- radiodish-1.0.0/radiodish.egg-info/top_level.txt +1 -0
- radiodish-1.0.0/setup.cfg +4 -0
- radiodish-1.0.0/src/__init__.py +38 -0
- radiodish-1.0.0/src/_internal/__init__.py +19 -0
- radiodish-1.0.0/src/_internal/errors.py +16 -0
- radiodish-1.0.0/src/_internal/groups.py +50 -0
- radiodish-1.0.0/src/autowire/__init__.py +31 -0
- radiodish-1.0.0/src/autowire/compose.py +91 -0
- radiodish-1.0.0/src/autowire/discovery.py +519 -0
- radiodish-1.0.0/src/autowire/local_discovery.py +78 -0
- radiodish-1.0.0/src/autowire/network.py +142 -0
- radiodish-1.0.0/src/autowire/wiring.py +110 -0
- radiodish-1.0.0/src/dish.py +161 -0
- radiodish-1.0.0/src/errors.py +13 -0
- radiodish-1.0.0/src/protocol.py +60 -0
- radiodish-1.0.0/src/radio.py +137 -0
- radiodish-1.0.0/src/transport.py +68 -0
- radiodish-1.0.0/src/unified.py +157 -0
- radiodish-1.0.0/tests/test_autowire.py +139 -0
- radiodish-1.0.0/tests/test_network_resilience.py +314 -0
- radiodish-1.0.0/tests/test_radiodish.py +111 -0
- radiodish-1.0.0/tests/test_radish_socket.py +38 -0
radiodish-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pow4you
|
|
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.
|
radiodish-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: radiodish
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A standalone, async RADIO-DISH implementation over UDP (ZeroMQ RFC 48), with automatic peer discovery and autowiring.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/pow4you/radiodish
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Topic :: System :: Networking
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# radiodish
|
|
16
|
+
|
|
17
|
+
A standalone, dependency-free, **async** implementation of ZeroMQ's
|
|
18
|
+
[RADIO-DISH pattern](https://rfc.zeromq.org/spec/48/), built directly on
|
|
19
|
+
`asyncio`'s UDP transport rather than on libzmq/pyzmq — plus automatic
|
|
20
|
+
port assignment, peer discovery, and autowiring on top.
|
|
21
|
+
|
|
22
|
+
- **Standalone**: pure Python stdlib (`asyncio`, `struct`, `collections`,
|
|
23
|
+
`socket`). No libzmq, no third-party dependencies at all.
|
|
24
|
+
- **UDP**: each RADIO and DISH is a plain UDP endpoint. A UDP datagram is
|
|
25
|
+
already an atomic, non-multipart unit, which lines up naturally with
|
|
26
|
+
RFC 48's "no multipart messages" requirement for the thread-safe
|
|
27
|
+
socket family.
|
|
28
|
+
- **Async**: `RadioSocket.send()` never blocks; `DishSocket.recv()` is an
|
|
29
|
+
`await`-able coroutine fed by `asyncio`'s event loop — no threads.
|
|
30
|
+
|
|
31
|
+
Looking for the actor-model layer built on top of this? That's a separate
|
|
32
|
+
package, [`radiodish-actor`](../radiodish-actor/), which depends on this
|
|
33
|
+
one.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install radiodish
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For local/editable development against this repo specifically, see the
|
|
42
|
+
workspace root README — this package is a member of a `uv` workspace.
|
|
43
|
+
|
|
44
|
+
## Documentation
|
|
45
|
+
|
|
46
|
+
Full documentation, with the reasoning behind the design decisions (not
|
|
47
|
+
just the API surface), lives in [`docs/`](docs/index.md):
|
|
48
|
+
|
|
49
|
+
- **[docs/index.md](docs/index.md)** — quick start, API sketch, package layout
|
|
50
|
+
- **[docs/protocol.md](docs/protocol.md)** — the RFC 48 wire format and how this implementation maps to it
|
|
51
|
+
- **[docs/autowire.md](docs/autowire.md)** — automatic port assignment, peer discovery (two tiers: same-machine and LAN), and autowiring; the real bugs found while building it and how they were fixed
|
|
52
|
+
- **[docs/examples/](docs/examples/)** — runnable examples, each documented with what it demonstrates and why
|
|
53
|
+
|
|
54
|
+
## 30-second version
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from radish import RadioSocket, DishSocket
|
|
58
|
+
|
|
59
|
+
radio = await RadioSocket().bind("0.0.0.0", 9000)
|
|
60
|
+
radio.connect("127.0.0.1", 9001)
|
|
61
|
+
await radio.send(b"hello", group="news")
|
|
62
|
+
|
|
63
|
+
dish = await DishSocket().bind("0.0.0.0", 9001)
|
|
64
|
+
dish.connect("127.0.0.1", 9000)
|
|
65
|
+
dish.join("news")
|
|
66
|
+
group, payload = await dish.recv()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or skip manual wiring entirely:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from radish.autowire import autowired_radish
|
|
73
|
+
|
|
74
|
+
radish, discoverer, wirer = await autowired_radish(groups=["sensors"])
|
|
75
|
+
radish.broadcast("21.5C", group="sensors")
|
|
76
|
+
group, payload = await radish.recv()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
See [docs/index.md](docs/index.md) for the full walkthrough.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# radiodish
|
|
2
|
+
|
|
3
|
+
A standalone, dependency-free, **async** implementation of ZeroMQ's
|
|
4
|
+
[RADIO-DISH pattern](https://rfc.zeromq.org/spec/48/), built directly on
|
|
5
|
+
`asyncio`'s UDP transport rather than on libzmq/pyzmq — plus automatic
|
|
6
|
+
port assignment, peer discovery, and autowiring on top.
|
|
7
|
+
|
|
8
|
+
- **Standalone**: pure Python stdlib (`asyncio`, `struct`, `collections`,
|
|
9
|
+
`socket`). No libzmq, no third-party dependencies at all.
|
|
10
|
+
- **UDP**: each RADIO and DISH is a plain UDP endpoint. A UDP datagram is
|
|
11
|
+
already an atomic, non-multipart unit, which lines up naturally with
|
|
12
|
+
RFC 48's "no multipart messages" requirement for the thread-safe
|
|
13
|
+
socket family.
|
|
14
|
+
- **Async**: `RadioSocket.send()` never blocks; `DishSocket.recv()` is an
|
|
15
|
+
`await`-able coroutine fed by `asyncio`'s event loop — no threads.
|
|
16
|
+
|
|
17
|
+
Looking for the actor-model layer built on top of this? That's a separate
|
|
18
|
+
package, [`radiodish-actor`](../radiodish-actor/), which depends on this
|
|
19
|
+
one.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install radiodish
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For local/editable development against this repo specifically, see the
|
|
28
|
+
workspace root README — this package is a member of a `uv` workspace.
|
|
29
|
+
|
|
30
|
+
## Documentation
|
|
31
|
+
|
|
32
|
+
Full documentation, with the reasoning behind the design decisions (not
|
|
33
|
+
just the API surface), lives in [`docs/`](docs/index.md):
|
|
34
|
+
|
|
35
|
+
- **[docs/index.md](docs/index.md)** — quick start, API sketch, package layout
|
|
36
|
+
- **[docs/protocol.md](docs/protocol.md)** — the RFC 48 wire format and how this implementation maps to it
|
|
37
|
+
- **[docs/autowire.md](docs/autowire.md)** — automatic port assignment, peer discovery (two tiers: same-machine and LAN), and autowiring; the real bugs found while building it and how they were fixed
|
|
38
|
+
- **[docs/examples/](docs/examples/)** — runnable examples, each documented with what it demonstrates and why
|
|
39
|
+
|
|
40
|
+
## 30-second version
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from radish import RadioSocket, DishSocket
|
|
44
|
+
|
|
45
|
+
radio = await RadioSocket().bind("0.0.0.0", 9000)
|
|
46
|
+
radio.connect("127.0.0.1", 9001)
|
|
47
|
+
await radio.send(b"hello", group="news")
|
|
48
|
+
|
|
49
|
+
dish = await DishSocket().bind("0.0.0.0", 9001)
|
|
50
|
+
dish.connect("127.0.0.1", 9000)
|
|
51
|
+
dish.join("news")
|
|
52
|
+
group, payload = await dish.recv()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or skip manual wiring entirely:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from radish.autowire import autowired_radish
|
|
59
|
+
|
|
60
|
+
radish, discoverer, wirer = await autowired_radish(groups=["sensors"])
|
|
61
|
+
radish.broadcast("21.5C", group="sensors")
|
|
62
|
+
group, payload = await radish.recv()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
See [docs/index.md](docs/index.md) for the full walkthrough.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "radiodish"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "A standalone, async RADIO-DISH implementation over UDP (ZeroMQ RFC 48), with automatic peer discovery and autowiring."
|
|
5
|
+
requires-python = ">=3.9"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = []
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
"Topic :: System :: Networking",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/pow4you/radiodish"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["setuptools>=77.0"]
|
|
21
|
+
build-backend = "setuptools.build_meta"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["radish", "radish._internal", "radish.autowire"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-dir]
|
|
27
|
+
radish = "src"
|
|
28
|
+
"radish._internal" = "src/_internal"
|
|
29
|
+
"radish.autowire" = "src/autowire"
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
35
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: radiodish
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A standalone, async RADIO-DISH implementation over UDP (ZeroMQ RFC 48), with automatic peer discovery and autowiring.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/pow4you/radiodish
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Topic :: System :: Networking
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# radiodish
|
|
16
|
+
|
|
17
|
+
A standalone, dependency-free, **async** implementation of ZeroMQ's
|
|
18
|
+
[RADIO-DISH pattern](https://rfc.zeromq.org/spec/48/), built directly on
|
|
19
|
+
`asyncio`'s UDP transport rather than on libzmq/pyzmq — plus automatic
|
|
20
|
+
port assignment, peer discovery, and autowiring on top.
|
|
21
|
+
|
|
22
|
+
- **Standalone**: pure Python stdlib (`asyncio`, `struct`, `collections`,
|
|
23
|
+
`socket`). No libzmq, no third-party dependencies at all.
|
|
24
|
+
- **UDP**: each RADIO and DISH is a plain UDP endpoint. A UDP datagram is
|
|
25
|
+
already an atomic, non-multipart unit, which lines up naturally with
|
|
26
|
+
RFC 48's "no multipart messages" requirement for the thread-safe
|
|
27
|
+
socket family.
|
|
28
|
+
- **Async**: `RadioSocket.send()` never blocks; `DishSocket.recv()` is an
|
|
29
|
+
`await`-able coroutine fed by `asyncio`'s event loop — no threads.
|
|
30
|
+
|
|
31
|
+
Looking for the actor-model layer built on top of this? That's a separate
|
|
32
|
+
package, [`radiodish-actor`](../radiodish-actor/), which depends on this
|
|
33
|
+
one.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install radiodish
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For local/editable development against this repo specifically, see the
|
|
42
|
+
workspace root README — this package is a member of a `uv` workspace.
|
|
43
|
+
|
|
44
|
+
## Documentation
|
|
45
|
+
|
|
46
|
+
Full documentation, with the reasoning behind the design decisions (not
|
|
47
|
+
just the API surface), lives in [`docs/`](docs/index.md):
|
|
48
|
+
|
|
49
|
+
- **[docs/index.md](docs/index.md)** — quick start, API sketch, package layout
|
|
50
|
+
- **[docs/protocol.md](docs/protocol.md)** — the RFC 48 wire format and how this implementation maps to it
|
|
51
|
+
- **[docs/autowire.md](docs/autowire.md)** — automatic port assignment, peer discovery (two tiers: same-machine and LAN), and autowiring; the real bugs found while building it and how they were fixed
|
|
52
|
+
- **[docs/examples/](docs/examples/)** — runnable examples, each documented with what it demonstrates and why
|
|
53
|
+
|
|
54
|
+
## 30-second version
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from radish import RadioSocket, DishSocket
|
|
58
|
+
|
|
59
|
+
radio = await RadioSocket().bind("0.0.0.0", 9000)
|
|
60
|
+
radio.connect("127.0.0.1", 9001)
|
|
61
|
+
await radio.send(b"hello", group="news")
|
|
62
|
+
|
|
63
|
+
dish = await DishSocket().bind("0.0.0.0", 9001)
|
|
64
|
+
dish.connect("127.0.0.1", 9000)
|
|
65
|
+
dish.join("news")
|
|
66
|
+
group, payload = await dish.recv()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or skip manual wiring entirely:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from radish.autowire import autowired_radish
|
|
73
|
+
|
|
74
|
+
radish, discoverer, wirer = await autowired_radish(groups=["sensors"])
|
|
75
|
+
radish.broadcast("21.5C", group="sensors")
|
|
76
|
+
group, payload = await radish.recv()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
See [docs/index.md](docs/index.md) for the full walkthrough.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
radiodish.egg-info/PKG-INFO
|
|
5
|
+
radiodish.egg-info/SOURCES.txt
|
|
6
|
+
radiodish.egg-info/dependency_links.txt
|
|
7
|
+
radiodish.egg-info/top_level.txt
|
|
8
|
+
src/__init__.py
|
|
9
|
+
src/dish.py
|
|
10
|
+
src/errors.py
|
|
11
|
+
src/protocol.py
|
|
12
|
+
src/radio.py
|
|
13
|
+
src/transport.py
|
|
14
|
+
src/unified.py
|
|
15
|
+
src/_internal/__init__.py
|
|
16
|
+
src/_internal/errors.py
|
|
17
|
+
src/_internal/groups.py
|
|
18
|
+
src/autowire/__init__.py
|
|
19
|
+
src/autowire/compose.py
|
|
20
|
+
src/autowire/discovery.py
|
|
21
|
+
src/autowire/local_discovery.py
|
|
22
|
+
src/autowire/network.py
|
|
23
|
+
src/autowire/wiring.py
|
|
24
|
+
tests/test_autowire.py
|
|
25
|
+
tests/test_network_resilience.py
|
|
26
|
+
tests/test_radiodish.py
|
|
27
|
+
tests/test_radish_socket.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
radish
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
radish — a standalone, asyncio-native RADIO/DISH implementation over UDP.
|
|
3
|
+
|
|
4
|
+
This is an independent implementation of the pattern described in ZeroMQ
|
|
5
|
+
RFC 48 (https://rfc.zeromq.org/spec/48/). It does not depend on libzmq or
|
|
6
|
+
pyzmq at all — it's built directly on asyncio's UDP transport.
|
|
7
|
+
|
|
8
|
+
Package layout:
|
|
9
|
+
radish.errors RadioDishError, InvalidGroupError (re-exported from radish._internal.errors)
|
|
10
|
+
radish.protocol wire format: encode_frame/decode_frame, MSG_DATA/JOIN/LEAVE
|
|
11
|
+
radish.transport shared plumbing: _UDPProtocol, _BoundedDropQueue, _Peer
|
|
12
|
+
radish.radio RadioSocket (RFC 48's RADIO type)
|
|
13
|
+
radish.dish DishSocket (RFC 48's DISH type)
|
|
14
|
+
radish.unified RadishSocket (radio+dish combined, one object)
|
|
15
|
+
radish.autowire automatic port assignment, peer discovery, and
|
|
16
|
+
peer connection on top of RadishSocket
|
|
17
|
+
|
|
18
|
+
For most uses, everything below is enough:
|
|
19
|
+
|
|
20
|
+
from radish import RadioSocket, DishSocket, RadishSocket
|
|
21
|
+
from radish.autowire import autowired_radish
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from radish.errors import RadioDishError, InvalidGroupError
|
|
25
|
+
from radish.protocol import (
|
|
26
|
+
MSG_DATA, MSG_JOIN, MSG_LEAVE, MAX_GROUP_LEN, GroupLike, Address,
|
|
27
|
+
encode_frame, decode_frame,
|
|
28
|
+
)
|
|
29
|
+
from radish.radio import RadioSocket
|
|
30
|
+
from radish.dish import DishSocket
|
|
31
|
+
from radish.unified import RadishSocket
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"RadioSocket", "DishSocket", "RadishSocket",
|
|
35
|
+
"RadioDishError", "InvalidGroupError",
|
|
36
|
+
"MSG_DATA", "MSG_JOIN", "MSG_LEAVE", "MAX_GROUP_LEN", "GroupLike", "Address",
|
|
37
|
+
"encode_frame", "decode_frame",
|
|
38
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
radish._internal — private implementation details of the radish package.
|
|
3
|
+
Leading underscore means exactly that: not public API, no stability
|
|
4
|
+
promise, nothing outside radish itself should import from here (and
|
|
5
|
+
nothing does -- radioactor only ever reaches RadioDishError via
|
|
6
|
+
`from radish import RadioDishError`, the public re-export).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from radish._internal.errors import RadioDishError, InvalidGroupError
|
|
10
|
+
from radish._internal.groups import GroupLike, MAX_GROUP_LEN, normalize_group, groups_as_str
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"RadioDishError",
|
|
14
|
+
"InvalidGroupError",
|
|
15
|
+
"GroupLike",
|
|
16
|
+
"MAX_GROUP_LEN",
|
|
17
|
+
"normalize_group",
|
|
18
|
+
"groups_as_str",
|
|
19
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
radish/_internal/errors.py — the exception hierarchy for the radish
|
|
3
|
+
package. Private subpackage (leading underscore) -- not part of the
|
|
4
|
+
public API, and nothing outside radish itself needs it, including
|
|
5
|
+
radioactor, which only ever imports RadioDishError via `from radish
|
|
6
|
+
import RadioDishError` (radish.errors re-exports these under the public
|
|
7
|
+
namespace for that purpose).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RadioDishError(Exception):
|
|
12
|
+
"""Base error for the whole project."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvalidGroupError(RadioDishError):
|
|
16
|
+
"""Raised when a group name violates RFC 48's length constraint (0-255 bytes)."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
radish/_internal/groups.py — group-name handling shared between two
|
|
3
|
+
different representations of the same concept within radish itself:
|
|
4
|
+
|
|
5
|
+
- radish.protocol needs a STRICT, wire-format validator: a group is
|
|
6
|
+
0-255 raw bytes, full stop, because that's what RFC 48's ABNF requires
|
|
7
|
+
and what the frame header's single length byte can represent.
|
|
8
|
+
- radish.autowire.discovery needs a LOOSE, JSON-safe converter: group
|
|
9
|
+
names have to survive being embedded in a discovery announcement
|
|
10
|
+
payload, so bytes get decoded to str (JSON can't carry raw bytes) and
|
|
11
|
+
anything already a plain string passes through untouched.
|
|
12
|
+
|
|
13
|
+
Both directions live here instead of being duplicated (or, worse, subtly
|
|
14
|
+
diverging) between the two modules that need them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Iterable, Union
|
|
20
|
+
|
|
21
|
+
from radish._internal.errors import InvalidGroupError
|
|
22
|
+
|
|
23
|
+
GroupLike = Union[str, bytes]
|
|
24
|
+
MAX_GROUP_LEN = 255
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_group(group: GroupLike) -> bytes:
|
|
28
|
+
"""Strict validator for the wire protocol: str or bytes in, exactly
|
|
29
|
+
validated bytes out. Raises InvalidGroupError for anything else or
|
|
30
|
+
anything over RFC 48's 255-byte limit."""
|
|
31
|
+
if isinstance(group, str):
|
|
32
|
+
group = group.encode("utf-8")
|
|
33
|
+
if not isinstance(group, (bytes, bytearray)):
|
|
34
|
+
raise InvalidGroupError(f"group must be str or bytes, got {type(group)!r}")
|
|
35
|
+
if len(group) > MAX_GROUP_LEN:
|
|
36
|
+
raise InvalidGroupError(
|
|
37
|
+
f"group is {len(group)} bytes, RFC 48 allows at most {MAX_GROUP_LEN}"
|
|
38
|
+
)
|
|
39
|
+
return bytes(group)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def groups_as_str(groups: Iterable) -> tuple[str, ...]:
|
|
43
|
+
"""Loose converter for discovery announcements: decodes any bytes
|
|
44
|
+
entries to str (JSON can't carry raw bytes), passes plain strings
|
|
45
|
+
through untouched. Used for the groups/interest lists that go into a
|
|
46
|
+
Discoverer's JSON payload, not for wire-protocol frame encoding."""
|
|
47
|
+
out = []
|
|
48
|
+
for g in groups:
|
|
49
|
+
out.append(g.decode("utf-8") if isinstance(g, (bytes, bytearray)) else g)
|
|
50
|
+
return tuple(out)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
radish.autowire — automatic port assignment, peer discovery, and peer
|
|
3
|
+
connection on top of radish.RadishSocket.
|
|
4
|
+
|
|
5
|
+
radish.autowire.network find_free_port_pair, detect_local_addresses
|
|
6
|
+
radish.autowire.local_discovery the "my machine" file-based tier
|
|
7
|
+
radish.autowire.discovery the "my LAN" multicast tier, and Discoverer
|
|
8
|
+
radish.autowire.wiring AutoWirer -- the actual autowiring
|
|
9
|
+
radish.autowire.compose autowired_radish() -- one-call entrypoint
|
|
10
|
+
|
|
11
|
+
For most uses:
|
|
12
|
+
|
|
13
|
+
from radish.autowire import autowired_radish
|
|
14
|
+
radish, discoverer, wirer = await autowired_radish(groups=["sensors"])
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from radish.autowire.network import find_free_port_pair, detect_local_addresses
|
|
18
|
+
from radish.autowire.discovery import Discoverer, PeerInfo, MULTICAST_GROUP, DEFAULT_DISCOVERY_PORT
|
|
19
|
+
from radish.autowire.wiring import AutoWirer
|
|
20
|
+
from radish.autowire.compose import autowired_radish
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"autowired_radish",
|
|
24
|
+
"Discoverer",
|
|
25
|
+
"AutoWirer",
|
|
26
|
+
"PeerInfo",
|
|
27
|
+
"find_free_port_pair",
|
|
28
|
+
"detect_local_addresses",
|
|
29
|
+
"MULTICAST_GROUP",
|
|
30
|
+
"DEFAULT_DISCOVERY_PORT",
|
|
31
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
radish/autowire/compose.py — autowired_radish(): "on the creation of a
|
|
3
|
+
radish, autowire its peers." One call that finds ports, binds, joins
|
|
4
|
+
groups, and starts discovery + autowiring together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Iterable, Optional
|
|
11
|
+
|
|
12
|
+
from radish.unified import RadishSocket
|
|
13
|
+
from radish.autowire.network import find_free_port_pair, detect_local_addresses
|
|
14
|
+
from radish.autowire.discovery import Discoverer, DEFAULT_DISCOVERY_PORT
|
|
15
|
+
from radish.autowire.wiring import AutoWirer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def autowired_radish(
|
|
19
|
+
host: Optional[str] = None,
|
|
20
|
+
groups: Iterable = (),
|
|
21
|
+
interest_groups: Optional[Iterable] = None,
|
|
22
|
+
port_range: tuple[int, int] = (20000, 40000),
|
|
23
|
+
discovery_port: int = DEFAULT_DISCOVERY_PORT,
|
|
24
|
+
seeds: Iterable[tuple[str, int]] = (),
|
|
25
|
+
node_id: Optional[str] = None,
|
|
26
|
+
autowire_poll_interval: float = 0.3,
|
|
27
|
+
) -> tuple[RadishSocket, Discoverer, AutoWirer]:
|
|
28
|
+
"""
|
|
29
|
+
One call, "on the creation of a radish": finds a free port pair, binds a
|
|
30
|
+
RadishSocket on it, joins `groups`, starts announcing/listening for
|
|
31
|
+
peers, and continuously connects to any discovered peer whose groups
|
|
32
|
+
overlap `interest_groups` (defaults to the same as `groups` — "only wire
|
|
33
|
+
me to peers who care about what I care about").
|
|
34
|
+
|
|
35
|
+
Leave `host` unset for the normal case: binds on "0.0.0.0" (every local
|
|
36
|
+
interface at once, loopback included -- always bindable, no guessing
|
|
37
|
+
needed) and announces every address this machine actually has
|
|
38
|
+
(detect_local_addresses(): loopback first, then whatever LAN-facing
|
|
39
|
+
addresses this machine's own hostname resolves to). Peers connect back
|
|
40
|
+
over whichever of those is actually reachable from where they sit --
|
|
41
|
+
loopback for a same-machine peer, a LAN address for one elsewhere on
|
|
42
|
+
the network. Pass an explicit `host` only to override both bind and
|
|
43
|
+
announce with one specific address (e.g. pairing a container-internal
|
|
44
|
+
address with `seeds=` on the other end).
|
|
45
|
+
|
|
46
|
+
Returns (radish, discoverer, autowirer). Hold onto discoverer/autowirer
|
|
47
|
+
if you want to inspect known/wired peers later; to tear everything down,
|
|
48
|
+
stop() the autowirer and discoverer, then close() the radish (see
|
|
49
|
+
example_autowire.py).
|
|
50
|
+
"""
|
|
51
|
+
if host is not None:
|
|
52
|
+
bind_host = host
|
|
53
|
+
announce_addresses = [host]
|
|
54
|
+
try:
|
|
55
|
+
port, radio_sock, dish_sock = find_free_port_pair(bind_host, *port_range)
|
|
56
|
+
except RuntimeError:
|
|
57
|
+
if bind_host == "127.0.0.1":
|
|
58
|
+
raise
|
|
59
|
+
# an explicitly-given host turned out not to be bindable --
|
|
60
|
+
# fall back to localhost rather than failing outright.
|
|
61
|
+
bind_host = "127.0.0.1"
|
|
62
|
+
announce_addresses = [bind_host]
|
|
63
|
+
port, radio_sock, dish_sock = find_free_port_pair(bind_host, *port_range)
|
|
64
|
+
else:
|
|
65
|
+
bind_host = "0.0.0.0"
|
|
66
|
+
announce_addresses = detect_local_addresses()
|
|
67
|
+
port, radio_sock, dish_sock = find_free_port_pair(bind_host, *port_range)
|
|
68
|
+
|
|
69
|
+
radish = RadishSocket()
|
|
70
|
+
await radish.bind(bind_host, port, radio_sock=radio_sock, dish_sock=dish_sock)
|
|
71
|
+
for g in groups:
|
|
72
|
+
radish.join(g)
|
|
73
|
+
|
|
74
|
+
node_id = node_id or uuid.uuid4().hex[:12]
|
|
75
|
+
interest = groups if interest_groups is None else interest_groups
|
|
76
|
+
discoverer = await Discoverer(
|
|
77
|
+
node_id=node_id,
|
|
78
|
+
addresses=announce_addresses,
|
|
79
|
+
port=port,
|
|
80
|
+
groups=groups,
|
|
81
|
+
interest=interest,
|
|
82
|
+
discovery_port=discovery_port,
|
|
83
|
+
seeds=seeds,
|
|
84
|
+
auto_refresh_addresses=(host is None),
|
|
85
|
+
).start()
|
|
86
|
+
|
|
87
|
+
wirer = await AutoWirer(radish, discoverer, interest_groups=interest).start(
|
|
88
|
+
poll_interval=autowire_poll_interval
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return radish, discoverer, wirer
|