radiodish-actor 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.
@@ -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.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: radiodish-actor
3
+ Version: 1.0.0
4
+ Summary: An Erlang-actor-model-flavored layer on top of radiodish -- group-addressed message dispatch over an autowired RADIO-DISH mesh.
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
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: radiodish>=1.0.0
13
+ Dynamic: license-file
14
+
15
+ # radiodish-actor
16
+
17
+ An Erlang-actor-model-flavored layer on top of
18
+ [`radiodish`](../radiodish/), running actors over an autowired RADIO-DISH
19
+ mesh instead of a custom VM. The point of doing it this way: any process,
20
+ in any language, that speaks radiodish's wire protocol can participate as
21
+ a peer actor — interoperability doesn't depend on sharing a runtime the
22
+ way BEAM's process/PID model does.
23
+
24
+ ```python
25
+ from radioactor import Actor, expose
26
+
27
+ class Greeter(Actor):
28
+ id = "greeter"
29
+
30
+ @expose()
31
+ async def ping(self, payload):
32
+ return {"pong": payload}
33
+
34
+ class Caller(Actor):
35
+ pass
36
+
37
+ provider = await Greeter().register()
38
+ caller = await Caller().register()
39
+ result = await caller.call("ping", {"seq": 1}, timeout=2.0)
40
+ ```
41
+
42
+ No manual wiring, no pre-declared dependencies required — `caller`
43
+ discovers `provider` and figures out how to reach `"ping"` entirely on
44
+ its own.
45
+
46
+ ## Install
47
+
48
+ ```
49
+ pip install radiodish-actor
50
+ ```
51
+
52
+ This pulls in `radiodish` automatically. For local/editable development
53
+ against this repo, see the workspace root README — this package is a
54
+ member of a `uv` workspace, developed alongside `radiodish` itself.
55
+
56
+ ## Documentation
57
+
58
+ - **[docs/index.md](docs/index.md)** — quick start, install
59
+ - **[docs/actor-model.md](docs/actor-model.md)** — the group-addressing
60
+ model in depth (there's no "call actor X's method Y" — only "send to a
61
+ group"), dynamic dependency discovery, why direct `Actor()`
62
+ instantiation is discouraged, and the real bugs found while building it
63
+ - **[docs/examples/](docs/examples/)** — a runnable provider + caller demo
64
+
65
+ ## Why not just RPC?
66
+
67
+ Because the transport underneath (RADIO-DISH) is fundamentally a
68
+ broadcast/multicast primitive, not a point-to-point one — trying to force
69
+ point-to-point RPC semantics on top of it fights the transport instead of
70
+ using it. This package leans fully into that instead: addressing is
71
+ "send to a group," and a group can legitimately have more than one
72
+ listener. See [docs/actor-model.md](docs/actor-model.md) for what that
73
+ means in practice, including the real trade-offs (at-least-once
74
+ delivery, no built-in "method not found" error) that come with embracing
75
+ it honestly rather than papering over it.
@@ -0,0 +1,61 @@
1
+ # radiodish-actor
2
+
3
+ An Erlang-actor-model-flavored layer on top of
4
+ [`radiodish`](../radiodish/), running actors over an autowired RADIO-DISH
5
+ mesh instead of a custom VM. The point of doing it this way: any process,
6
+ in any language, that speaks radiodish's wire protocol can participate as
7
+ a peer actor — interoperability doesn't depend on sharing a runtime the
8
+ way BEAM's process/PID model does.
9
+
10
+ ```python
11
+ from radioactor import Actor, expose
12
+
13
+ class Greeter(Actor):
14
+ id = "greeter"
15
+
16
+ @expose()
17
+ async def ping(self, payload):
18
+ return {"pong": payload}
19
+
20
+ class Caller(Actor):
21
+ pass
22
+
23
+ provider = await Greeter().register()
24
+ caller = await Caller().register()
25
+ result = await caller.call("ping", {"seq": 1}, timeout=2.0)
26
+ ```
27
+
28
+ No manual wiring, no pre-declared dependencies required — `caller`
29
+ discovers `provider` and figures out how to reach `"ping"` entirely on
30
+ its own.
31
+
32
+ ## Install
33
+
34
+ ```
35
+ pip install radiodish-actor
36
+ ```
37
+
38
+ This pulls in `radiodish` automatically. For local/editable development
39
+ against this repo, see the workspace root README — this package is a
40
+ member of a `uv` workspace, developed alongside `radiodish` itself.
41
+
42
+ ## Documentation
43
+
44
+ - **[docs/index.md](docs/index.md)** — quick start, install
45
+ - **[docs/actor-model.md](docs/actor-model.md)** — the group-addressing
46
+ model in depth (there's no "call actor X's method Y" — only "send to a
47
+ group"), dynamic dependency discovery, why direct `Actor()`
48
+ instantiation is discouraged, and the real bugs found while building it
49
+ - **[docs/examples/](docs/examples/)** — a runnable provider + caller demo
50
+
51
+ ## Why not just RPC?
52
+
53
+ Because the transport underneath (RADIO-DISH) is fundamentally a
54
+ broadcast/multicast primitive, not a point-to-point one — trying to force
55
+ point-to-point RPC semantics on top of it fights the transport instead of
56
+ using it. This package leans fully into that instead: addressing is
57
+ "send to a group," and a group can legitimately have more than one
58
+ listener. See [docs/actor-model.md](docs/actor-model.md) for what that
59
+ means in practice, including the real trade-offs (at-least-once
60
+ delivery, no built-in "method not found" error) that come with embracing
61
+ it honestly rather than papering over it.
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "radiodish-actor"
3
+ version = "1.0.0"
4
+ description = "An Erlang-actor-model-flavored layer on top of radiodish -- group-addressed message dispatch over an autowired RADIO-DISH mesh."
5
+ requires-python = ">=3.9"
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ dependencies = ["radiodish>=1.0.0"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Operating System :: OS Independent",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/pow4you/radiodish"
17
+
18
+ [tool.uv.sources]
19
+ radiodish = { workspace = true }
20
+
21
+ [build-system]
22
+ requires = ["setuptools>=77.0"]
23
+ build-backend = "setuptools.build_meta"
24
+
25
+ [tool.setuptools]
26
+ packages = ["radioactor"]
27
+
28
+ [tool.setuptools.package-dir]
29
+ radioactor = "src"
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ asyncio_mode = "auto"
34
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: radiodish-actor
3
+ Version: 1.0.0
4
+ Summary: An Erlang-actor-model-flavored layer on top of radiodish -- group-addressed message dispatch over an autowired RADIO-DISH mesh.
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
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: radiodish>=1.0.0
13
+ Dynamic: license-file
14
+
15
+ # radiodish-actor
16
+
17
+ An Erlang-actor-model-flavored layer on top of
18
+ [`radiodish`](../radiodish/), running actors over an autowired RADIO-DISH
19
+ mesh instead of a custom VM. The point of doing it this way: any process,
20
+ in any language, that speaks radiodish's wire protocol can participate as
21
+ a peer actor — interoperability doesn't depend on sharing a runtime the
22
+ way BEAM's process/PID model does.
23
+
24
+ ```python
25
+ from radioactor import Actor, expose
26
+
27
+ class Greeter(Actor):
28
+ id = "greeter"
29
+
30
+ @expose()
31
+ async def ping(self, payload):
32
+ return {"pong": payload}
33
+
34
+ class Caller(Actor):
35
+ pass
36
+
37
+ provider = await Greeter().register()
38
+ caller = await Caller().register()
39
+ result = await caller.call("ping", {"seq": 1}, timeout=2.0)
40
+ ```
41
+
42
+ No manual wiring, no pre-declared dependencies required — `caller`
43
+ discovers `provider` and figures out how to reach `"ping"` entirely on
44
+ its own.
45
+
46
+ ## Install
47
+
48
+ ```
49
+ pip install radiodish-actor
50
+ ```
51
+
52
+ This pulls in `radiodish` automatically. For local/editable development
53
+ against this repo, see the workspace root README — this package is a
54
+ member of a `uv` workspace, developed alongside `radiodish` itself.
55
+
56
+ ## Documentation
57
+
58
+ - **[docs/index.md](docs/index.md)** — quick start, install
59
+ - **[docs/actor-model.md](docs/actor-model.md)** — the group-addressing
60
+ model in depth (there's no "call actor X's method Y" — only "send to a
61
+ group"), dynamic dependency discovery, why direct `Actor()`
62
+ instantiation is discouraged, and the real bugs found while building it
63
+ - **[docs/examples/](docs/examples/)** — a runnable provider + caller demo
64
+
65
+ ## Why not just RPC?
66
+
67
+ Because the transport underneath (RADIO-DISH) is fundamentally a
68
+ broadcast/multicast primitive, not a point-to-point one — trying to force
69
+ point-to-point RPC semantics on top of it fights the transport instead of
70
+ using it. This package leans fully into that instead: addressing is
71
+ "send to a group," and a group can legitimately have more than one
72
+ listener. See [docs/actor-model.md](docs/actor-model.md) for what that
73
+ means in practice, including the real trade-offs (at-least-once
74
+ delivery, no built-in "method not found" error) that come with embracing
75
+ it honestly rather than papering over it.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ radiodish_actor.egg-info/PKG-INFO
5
+ radiodish_actor.egg-info/SOURCES.txt
6
+ radiodish_actor.egg-info/dependency_links.txt
7
+ radiodish_actor.egg-info/requires.txt
8
+ radiodish_actor.egg-info/top_level.txt
9
+ src/__init__.py
10
+ src/base.py
11
+ src/decorators.py
12
+ tests/test_actor.py
@@ -0,0 +1 @@
1
+ radiodish>=1.0.0
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ """
2
+ radioactor — an Erlang-actor-model-flavored layer on top of radish. Not
3
+ RPC bolted onto pub/sub; it leans fully into RADIO-DISH's native broadcast
4
+ nature. There's no "call actor X's method Y" — there's only "send to a
5
+ group." A handler's trigger-set, computed by @expose(groups=...):
6
+
7
+ your explicit groups ∪ {the function's own name} ∪ {""}
8
+
9
+ Every exposed function joins "" by default, so an unaddressed message
10
+ reaches every exposed function on every actor you're connected to — a
11
+ genuine broadcast-to-everyone channel, not accidental. Function names
12
+ aren't actor-namespaced (@expose runs before any actor instance exists,
13
+ so it can't see self.actor_id()) — two actor classes both exposing
14
+ `login` both receive anything sent to bare group "login"; bake your own
15
+ namespace into an explicit group string if you want to avoid that.
16
+
17
+ from radioactor import Actor, expose
18
+
19
+ class Greeter(Actor):
20
+ id = "greeter"
21
+
22
+ @expose()
23
+ async def ping(self, payload):
24
+ return {"pong": payload}
25
+
26
+ class Caller(Actor):
27
+ pass
28
+
29
+ provider = await Greeter().register()
30
+ caller = await Caller().register()
31
+ result = await caller.call("ping", {"seq": 1}, timeout=2.0)
32
+
33
+ Depends on the `radiodish` package (imported as `radish`) for its
34
+ transport, discovery, and autowiring -- see that package's docs for how
35
+ the underlying mesh actually forms.
36
+ """
37
+
38
+ from radioactor.decorators import expose
39
+ from radioactor.base import Actor
40
+
41
+ __all__ = ["Actor", "expose"]
@@ -0,0 +1,335 @@
1
+ """
2
+ radioactor/base.py — Actor: an Erlang-actor-model-flavored base class running
3
+ over autowired RadishSockets. See radioactor/__init__.py's docstring for the
4
+ addressing model (groups, not "call actor X's method Y").
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import inspect
11
+ import json
12
+ import uuid
13
+ import warnings
14
+ from typing import Callable, Iterable, Optional
15
+
16
+ from radish import RadioDishError
17
+ from radish.autowire import autowired_radish, DEFAULT_DISCOVERY_PORT
18
+
19
+
20
+ class Actor:
21
+ """
22
+ Base class for radish-backed actors. Subclass it and tag methods with
23
+ @expose, then `await register()`.
24
+
25
+ `depends_on` is optional now, not required: send()/call() register
26
+ interest in whatever group they're asked to reach the first time they
27
+ try, so an actor discovers what it needs on the fly instead of you
28
+ having to predict it up front. Pass `depends_on` only if you want to
29
+ pre-warm that wiring before your first call, to skip the latency of
30
+ waiting for AutoWirer's next poll -- not because anything requires it.
31
+ """
32
+
33
+ def __init__(self, depends_on: Iterable[str] = ()):
34
+ if type(self) is Actor:
35
+ warnings.warn(
36
+ "Instantiating Actor directly is discouraged -- define a "
37
+ "subclass instead, even an empty `class MyCaller(Actor): "
38
+ "pass`. actor_id() defaults to the class name, so every "
39
+ "bare Actor() instance identifies itself as just 'Actor' "
40
+ "in logs, discovery announcements, and instance_id() -- a "
41
+ "named subclass makes that identity actually mean "
42
+ "something, and gives you a natural place to grow into "
43
+ "(exposed methods, on_register(), etc.) later.",
44
+ stacklevel=2,
45
+ )
46
+ self._instance_id = f"{self.actor_id()}#{uuid.uuid4().hex[:8]}"
47
+ self._depends_on: set = set(depends_on)
48
+ self._groups_to_handlers: dict[str, list[Callable]] = self._discover_exposed_methods()
49
+ self._pending: dict[str, asyncio.Future] = {}
50
+ self.radish = None
51
+ self.discoverer = None
52
+ self.wirer = None
53
+ self._run_task: Optional[asyncio.Task] = None
54
+
55
+ # ---- identity ---------------------------------------------------------
56
+
57
+ def actor_id(self) -> str:
58
+ """Logical, human-readable identity -- shared on purpose if you run
59
+ several replicas under the same class/id. Not used for routing at
60
+ all in this design (see module docstring); it's here for logging,
61
+ discovery bookkeeping, and as a building block if you want to
62
+ construct your own namespaced group strings."""
63
+ return getattr(self, "id", self.__class__.__name__)
64
+
65
+ def instance_id(self) -> str:
66
+ """Always unique to this running process. Used only to build the
67
+ private reply group -- never appears in capability routing."""
68
+ return self._instance_id
69
+
70
+ def _reply_group(self) -> str:
71
+ return f"{self._instance_id}.replies"
72
+
73
+ # ---- setup --------------------------------------------------------------
74
+
75
+ def _discover_exposed_methods(self) -> dict[str, list[Callable]]:
76
+ groups_to_handlers: dict[str, list[Callable]] = {}
77
+ for _, method in inspect.getmembers(self, predicate=inspect.ismethod):
78
+ if getattr(method, "_exposed", False):
79
+ for group in method._exposed_groups:
80
+ groups_to_handlers.setdefault(group, []).append(method)
81
+ return groups_to_handlers
82
+
83
+ def _own_groups(self) -> list[str]:
84
+ return list(self._groups_to_handlers.keys()) + [self._reply_group()]
85
+
86
+ async def register(
87
+ self,
88
+ *,
89
+ host: Optional[str] = None,
90
+ discovery_port: int = DEFAULT_DISCOVERY_PORT,
91
+ seeds: Iterable[tuple[str, int]] = (),
92
+ port_range: tuple[int, int] = (20000, 40000),
93
+ ) -> "Actor":
94
+ """
95
+ Finds ports, binds a RadishSocket, joins every exposed group plus
96
+ this instance's private reply group, starts discovery/autowiring
97
+ (using `depends_on`, from __init__, as extra connect-worthy
98
+ interest beyond what this actor itself exposes), then starts the
99
+ dispatch loop. Returns self so `x = await Foo().register()` works.
100
+ """
101
+ own_groups = self._own_groups()
102
+ interest = set(own_groups) | self._depends_on
103
+ self.radish, self.discoverer, self.wirer = await autowired_radish(
104
+ host=host,
105
+ groups=own_groups,
106
+ interest_groups=interest,
107
+ discovery_port=discovery_port,
108
+ seeds=seeds,
109
+ port_range=port_range,
110
+ node_id=self._instance_id,
111
+ )
112
+ if hasattr(self, "on_register"):
113
+ await self.on_register()
114
+ self._run_task = asyncio.ensure_future(self._run())
115
+ return self
116
+
117
+ def add_endpoint(self, method: Callable) -> None:
118
+ """
119
+ Expose a new @expose'd method after registration. No disconnect
120
+ needed for existing peers to pick it up: DishSocket.join() was
121
+ already safe to call any time, and radiodish's periodic rejoin
122
+ announces it to every already-connected peer automatically. What
123
+ DOES need an explicit push is the Discoverer's advertised group
124
+ list, so *new* peers (ones not connected yet) can find this
125
+ capability during discovery -- that's the one line that's genuinely
126
+ new here, not a disconnect/reconnect.
127
+ """
128
+ groups = getattr(method, "_exposed_groups", None)
129
+ if groups is None:
130
+ raise RadioDishError("add_endpoint() expects an @expose'd method")
131
+ new_groups = [g for g in groups if g not in self._groups_to_handlers]
132
+ for g in groups:
133
+ self._groups_to_handlers.setdefault(g, []).append(method)
134
+ if self.radish is not None:
135
+ for g in new_groups:
136
+ self.radish.join(g)
137
+ self.discoverer.update_groups(self._own_groups())
138
+
139
+ def add_dependency(self, group: str) -> None:
140
+ """Declare interest in reaching `group` even though this actor
141
+ doesn't expose it -- lets AutoWirer connect out to a provider once
142
+ one is discovered, AND (since connecting is decided from both
143
+ sides -- see autowire.Discoverer's docstring) lets a provider who's
144
+ already running connect back to us once it hears our updated
145
+ interest in our next announcement."""
146
+ self._depends_on.add(group)
147
+ if self.wirer is not None:
148
+ self.wirer.add_interest(group)
149
+ if self.discoverer is not None:
150
+ self.discoverer.update_interest(set(self._own_groups()) | self._depends_on)
151
+
152
+ def _ensure_interest(self, group: str) -> None:
153
+ """The dynamic half of dependency discovery: called by send()/call()
154
+ so an actor registers interest in a group automatically, the first
155
+ time it actually tries to reach it, instead of requiring that to be
156
+ predicted upfront via depends_on. Skips the (cheap but not free --
157
+ it triggers a re-announce) work entirely for groups already covered,
158
+ either because add_dependency() already ran for this one, or
159
+ because it's something this actor exposes itself already."""
160
+ if group in self._depends_on or group in self._groups_to_handlers:
161
+ return
162
+ self.add_dependency(group)
163
+
164
+ # ---- dispatch loop ---------------------------------------------------
165
+
166
+ async def _run(self):
167
+ try:
168
+ while True:
169
+ group, raw = await self.radish.recv()
170
+ try:
171
+ group_str = group.decode("utf-8")
172
+ except UnicodeDecodeError:
173
+ continue
174
+ if group_str == self._reply_group():
175
+ self._handle_reply(raw)
176
+ continue
177
+ try:
178
+ msg = json.loads(raw.decode("utf-8"))
179
+ except (ValueError, UnicodeDecodeError):
180
+ continue
181
+ for handler in self._groups_to_handlers.get(group_str, ()):
182
+ await self._dispatch_one(handler, msg)
183
+ except asyncio.CancelledError:
184
+ pass
185
+
186
+ async def _dispatch_one(self, handler: Callable, msg: dict) -> None:
187
+ await self._send_status(msg, status=202) # "accepted" ack -- UDP
188
+ # gives no delivery confirmation of its own, so this is the only
189
+ # signal a caller gets that the message actually arrived at all.
190
+ try:
191
+ result = await handler(msg.get("payload"))
192
+ await self._send_result(msg, result=result)
193
+ except Exception as e:
194
+ await self._send_result(msg, error=str(e))
195
+
196
+ def _handle_reply(self, raw: bytes) -> None:
197
+ try:
198
+ msg = json.loads(raw.decode("utf-8"))
199
+ except (ValueError, UnicodeDecodeError):
200
+ return
201
+ fut = self._pending.get(msg.get("correlation_id"))
202
+ if fut is None or fut.done():
203
+ return # nobody waiting -- timed out already, a dupe, or a
204
+ # second handler's reply to the same correlation_id (see
205
+ # call()'s docstring: first reply wins, rest are dropped)
206
+ if msg.get("status") == 202:
207
+ return # just the accept-ack; keep waiting for the real result
208
+ if msg.get("error") is not None:
209
+ fut.set_exception(RadioDishError(msg["error"]))
210
+ else:
211
+ fut.set_result(msg.get("result"))
212
+
213
+ async def _send_status(self, msg: dict, status: int) -> None:
214
+ target = msg.get("callback_target")
215
+ if not target:
216
+ return
217
+ self.radish.broadcast(
218
+ json.dumps({"correlation_id": msg.get("correlation_id"), "status": status}).encode(),
219
+ group=target,
220
+ )
221
+
222
+ async def _send_result(self, msg: dict, result=None, error=None) -> None:
223
+ target = msg.get("callback_target")
224
+ if not target:
225
+ return
226
+ self.radish.broadcast(
227
+ json.dumps(
228
+ {"correlation_id": msg.get("correlation_id"), "result": result, "error": error}
229
+ ).encode(),
230
+ group=target,
231
+ )
232
+
233
+ # ---- sending to other actors ------------------------------------------
234
+
235
+ def send(self, group: str, payload=None, correlation_id: Optional[str] = None) -> str:
236
+ """Fire a message at `group`. Fans out to every actor (and every
237
+ locally-registered handler on each) currently wired and listening
238
+ on that exact group. Returns the correlation_id immediately without
239
+ waiting for any reply -- use call() to await one.
240
+
241
+ Registers interest in `group` automatically if this is the first
242
+ time it's been reached (see _ensure_interest) -- no depends_on
243
+ needed up front. This send itself may still miss its target if the
244
+ resulting wiring hasn't finished yet; call() handles that with
245
+ retries, but send() is fire-and-forget by design, so a caller that
246
+ cares whether THIS particular send lands has to resend it
247
+ themselves, the same as always."""
248
+ self._ensure_interest(group)
249
+ correlation_id = correlation_id or uuid.uuid4().hex
250
+ self.radish.broadcast(
251
+ json.dumps(
252
+ {
253
+ "payload": payload,
254
+ "callback_target": self._reply_group(),
255
+ "correlation_id": correlation_id,
256
+ }
257
+ ).encode(),
258
+ group=group,
259
+ )
260
+ return correlation_id
261
+
262
+ async def call(
263
+ self,
264
+ group: str,
265
+ payload=None,
266
+ timeout: Optional[float] = None,
267
+ retry_interval: Optional[float] = 0.5,
268
+ ):
269
+ """
270
+ send() and await the matching reply, matched by correlation_id. If
271
+ more than one handler matched `group`, resolves to whichever
272
+ replies first; later ones are silently dropped. Raises
273
+ asyncio.TimeoutError if nothing comes back in time.
274
+
275
+ Retries by default: a single UDP datagram can genuinely go missing
276
+ even in normal operation -- not just from packet loss, but because
277
+ it can legitimately be sent before a peer's reciprocal wiring has
278
+ finished (two AutoWirers converging on each other isn't
279
+ instantaneous or synchronized), and nothing resends a dropped
280
+ datagram on its own. call() resends the same message, same
281
+ correlation_id, every `retry_interval` seconds until a reply
282
+ arrives or `timeout` elapses. Pass retry_interval=None to send
283
+ exactly once instead.
284
+
285
+ This makes call() "at least once," not "exactly once": there's no
286
+ receiver-side deduplication of correlation_ids, so if a resend
287
+ arrives after the first attempt already reached the handler (its
288
+ reply was just what got lost, not the request), the handler runs
289
+ twice. Fine for idempotent handlers (most reads, "ping", cache
290
+ invalidation); if you're calling something that must not run twice
291
+ (charging a card), pass retry_interval=None and handle the
292
+ possible loss yourself, or ensure your handler is idempotent on
293
+ correlation_id — that dedup layer doesn't exist here yet.
294
+
295
+ Also registers interest in `group` automatically if this is the
296
+ first time it's been reached (see _ensure_interest) -- combined
297
+ with the retry above, this is what makes depends_on genuinely
298
+ optional: the first call() to a brand new group declares interest
299
+ on the fly, then just keeps resending while AutoWirer catches up
300
+ on its own poll cycle, arriving once wiring completes.
301
+ """
302
+ self._ensure_interest(group)
303
+ loop = asyncio.get_running_loop()
304
+ correlation_id = uuid.uuid4().hex
305
+ fut = loop.create_future()
306
+ self._pending[correlation_id] = fut
307
+ self.send(group, payload, correlation_id=correlation_id)
308
+
309
+ async def _resend_loop():
310
+ try:
311
+ while True:
312
+ await asyncio.sleep(retry_interval)
313
+ if fut.done():
314
+ return
315
+ self.send(group, payload, correlation_id=correlation_id)
316
+ except asyncio.CancelledError:
317
+ pass
318
+
319
+ resend_task = asyncio.ensure_future(_resend_loop()) if retry_interval is not None else None
320
+ try:
321
+ return await asyncio.wait_for(fut, timeout)
322
+ finally:
323
+ if resend_task:
324
+ resend_task.cancel()
325
+ self._pending.pop(correlation_id, None)
326
+
327
+ async def close(self) -> None:
328
+ if self._run_task:
329
+ self._run_task.cancel()
330
+ if self.wirer:
331
+ await self.wirer.stop()
332
+ if self.discoverer:
333
+ await self.discoverer.stop()
334
+ if self.radish:
335
+ await self.radish.close()
@@ -0,0 +1,36 @@
1
+ """
2
+ radioactor/decorators.py — @expose: marks a method as network-callable. Pure
3
+ metadata; see radioactor.base.Actor for how the tags get read and dispatched.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Iterable, Optional, Union
9
+
10
+
11
+ def expose(groups: Optional[Union[str, Iterable[str]]] = None):
12
+ """
13
+ Mark a method as network-callable. `groups` (a string, an iterable of
14
+ strings, or omitted) lists extra topics this handler should ALSO fire
15
+ on, on top of its two automatic defaults: its own function name, and
16
+ the empty group "" (see module docstring for what each implies).
17
+ Pure metadata -- nothing happens at decoration or call time beyond
18
+ tagging the function; Actor._discover_exposed_methods() reads these
19
+ tags via reflection when an instance registers.
20
+ """
21
+ if groups is None:
22
+ extra: tuple = ()
23
+ elif isinstance(groups, str):
24
+ extra = (groups,)
25
+ else:
26
+ extra = tuple(groups)
27
+
28
+ def decorator(func):
29
+ func._exposed = True
30
+ # de-dupe while preserving order, in case someone passes their own
31
+ # name or "" explicitly too
32
+ func._exposed_groups = tuple(dict.fromkeys((*extra, func.__name__, "")))
33
+ return func
34
+
35
+ return decorator
36
+
@@ -0,0 +1,196 @@
1
+ import asyncio
2
+ import warnings
3
+
4
+ from radioactor import Actor, expose
5
+
6
+
7
+ class Echo(Actor):
8
+ id = "echo"
9
+
10
+ @expose()
11
+ async def ping(self, payload):
12
+ return {"pong": payload}
13
+
14
+ @expose(groups=["shout_topic"])
15
+ async def shout(self, payload):
16
+ return str(payload).upper()
17
+
18
+
19
+ class Caller(Actor):
20
+ """A pure caller, no @expose'd methods -- the recommended way to write
21
+ one now: a named subclass, even an empty one, rather than instantiating
22
+ Actor directly (see test_direct_instantiation_warns)."""
23
+ pass
24
+
25
+
26
+ async def _wait_until(predicate, timeout=5.0, interval=0.1):
27
+ deadline = asyncio.get_event_loop().time() + timeout
28
+ while asyncio.get_event_loop().time() < deadline:
29
+ if predicate():
30
+ return True
31
+ await asyncio.sleep(interval)
32
+ return predicate()
33
+
34
+
35
+ async def test_call_by_function_name():
36
+ """No depends_on anywhere here -- call() discovers 'ping' on its own,
37
+ the first time it's asked to reach it, and its built-in retry carries
38
+ the caller through the wiring latency. This is the default path now,
39
+ not a special case."""
40
+ echo = await Echo().register(port_range=(25000, 25100), discovery_port=27001)
41
+ caller = await Caller().register(port_range=(25000, 25100), discovery_port=27001)
42
+
43
+ assert caller._depends_on == set(), "should start with zero pre-declared dependencies"
44
+ result = await caller.call("ping", {"x": 1}, timeout=3.0)
45
+ assert result == {"pong": {"x": 1}}, result
46
+ assert "ping" in caller._depends_on, "call() should have auto-registered interest"
47
+
48
+ await caller.close()
49
+ await echo.close()
50
+ print("test_call_by_function_name: PASS")
51
+
52
+
53
+ async def test_explicit_group_alias():
54
+ echo = await Echo().register(port_range=(25100, 25200), discovery_port=27002)
55
+ caller = await Caller().register(port_range=(25100, 25200), discovery_port=27002)
56
+
57
+ # addressed via the *explicit* group, not the function name "shout" --
58
+ # again with zero depends_on, relying purely on dynamic discovery
59
+ result = await caller.call("shout_topic", "hi", timeout=3.0)
60
+ assert result == "HI", result
61
+
62
+ await caller.close()
63
+ await echo.close()
64
+ print("test_explicit_group_alias: PASS")
65
+
66
+
67
+ async def test_explicit_depends_on_still_works():
68
+ """depends_on at construction is optional now, not required -- but
69
+ still works, as a way to pre-warm the wiring before the first call
70
+ rather than paying that latency on it. Confirms it the direct way:
71
+ wiring should already be in progress before any call() is made."""
72
+ echo = await Echo().register(port_range=(25150, 25180), discovery_port=27009)
73
+ caller = await Caller(depends_on=["ping"]).register(port_range=(25150, 25180), discovery_port=27009)
74
+
75
+ ok = await _wait_until(lambda: len(caller.wirer.wired_peers) >= 1)
76
+ assert ok, "pre-declared depends_on should wire before any call() happens"
77
+
78
+ result = await caller.call("ping", {"y": 2}, timeout=3.0)
79
+ assert result == {"pong": {"y": 2}}, result
80
+
81
+ await caller.close()
82
+ await echo.close()
83
+ print("test_explicit_depends_on_still_works: PASS")
84
+
85
+
86
+ async def test_empty_group_broadcasts_to_every_handler():
87
+ echo = await Echo().register(port_range=(25200, 25300), discovery_port=27003)
88
+ caller = await Caller().register(port_range=(25200, 25300), discovery_port=27003)
89
+
90
+ # send() (not call()) since BOTH ping and shout will independently
91
+ # reply to the same correlation_id -- we just want to see that both
92
+ # handlers actually ran, not race for "whichever reply wins".
93
+ seen = []
94
+ orig_ping, orig_shout = echo.ping, echo.shout
95
+
96
+ async def tracking_ping(payload):
97
+ seen.append("ping")
98
+ return await orig_ping(payload)
99
+
100
+ async def tracking_shout(payload):
101
+ seen.append("shout")
102
+ return await orig_shout(payload)
103
+
104
+ echo._groups_to_handlers[""] = [tracking_ping, tracking_shout]
105
+
106
+ # send() has no reply to retry against (unlike call(), which resends
107
+ # internally), so a caller who cares whether a fire-and-forget
108
+ # broadcast actually landed resends it themselves. The FIRST send()
109
+ # call also auto-registers interest in "" (see _ensure_interest) --
110
+ # zero depends_on needed here either, same as everywhere else in this
111
+ # file now.
112
+ deadline = asyncio.get_event_loop().time() + 3.0
113
+ while asyncio.get_event_loop().time() < deadline:
114
+ if set(seen) == {"ping", "shout"}:
115
+ break
116
+ caller.send("", payload="hello")
117
+ await asyncio.sleep(0.2)
118
+ ok = set(seen) == {"ping", "shout"}
119
+ assert ok, f"expected both handlers to fire on the empty group, got {seen}"
120
+
121
+ await caller.close()
122
+ await echo.close()
123
+ print("test_empty_group_broadcasts_to_every_handler: PASS")
124
+
125
+
126
+ async def test_call_times_out_when_nobody_listens():
127
+ caller = await Caller().register(port_range=(25300, 25400), discovery_port=27004)
128
+ try:
129
+ await caller.call("nobody_home", None, timeout=0.5)
130
+ raised = False
131
+ except asyncio.TimeoutError:
132
+ raised = True
133
+ assert raised, "expected a timeout calling a group with no subscribers"
134
+ await caller.close()
135
+ print("test_call_times_out_when_nobody_listens: PASS")
136
+
137
+
138
+ async def test_dynamic_expose_and_dependency():
139
+ echo = await Echo().register(port_range=(25400, 25500), discovery_port=27005)
140
+
141
+ class Bare(Actor):
142
+ pass
143
+
144
+ caller = await Bare().register(port_range=(25400, 25500), discovery_port=27005)
145
+
146
+ await asyncio.sleep(1.0)
147
+ assert len(caller.wirer.wired_peers) == 0, "shouldn't be wired without any shared interest"
148
+
149
+ @expose(groups=["whisper_topic"])
150
+ async def whisper(self, payload):
151
+ return f"...{payload}..."
152
+
153
+ echo.add_endpoint(whisper.__get__(echo))
154
+ caller.add_dependency("whisper_topic")
155
+
156
+ ok = await _wait_until(lambda: len(caller.wirer.wired_peers) >= 1)
157
+ assert ok, "caller never wired after dynamic dependency + dynamic expose"
158
+
159
+ result = await caller.call("whisper_topic", "secret", timeout=3.0)
160
+ assert result == "...secret...", result
161
+
162
+ await caller.close()
163
+ await echo.close()
164
+ print("test_dynamic_expose_and_dependency: PASS")
165
+
166
+
167
+ async def test_direct_instantiation_warns():
168
+ """Instantiating Actor directly should warn, nudging toward a named
169
+ subclass instead; a subclass -- even an empty one -- should not."""
170
+ with warnings.catch_warnings(record=True) as caught:
171
+ warnings.simplefilter("always")
172
+ Actor()
173
+ assert len(caught) == 1, f"expected exactly one warning, got {len(caught)}"
174
+ assert issubclass(caught[0].category, UserWarning)
175
+ assert "subclass" in str(caught[0].message)
176
+
177
+ with warnings.catch_warnings(record=True) as caught:
178
+ warnings.simplefilter("always")
179
+ Caller()
180
+ assert len(caught) == 0, f"a named subclass should not warn, got {caught}"
181
+
182
+ print("test_direct_instantiation_warns: PASS")
183
+
184
+
185
+ async def main():
186
+ await test_call_by_function_name()
187
+ await test_explicit_group_alias()
188
+ await test_explicit_depends_on_still_works()
189
+ await test_empty_group_broadcasts_to_every_handler()
190
+ await test_call_times_out_when_nobody_listens()
191
+ await test_dynamic_expose_and_dependency()
192
+ await test_direct_instantiation_warns()
193
+
194
+
195
+ if __name__ == "__main__":
196
+ asyncio.run(main())