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.
- radiodish_actor-1.0.0/LICENSE +21 -0
- radiodish_actor-1.0.0/PKG-INFO +75 -0
- radiodish_actor-1.0.0/README.md +61 -0
- radiodish_actor-1.0.0/pyproject.toml +34 -0
- radiodish_actor-1.0.0/radiodish_actor.egg-info/PKG-INFO +75 -0
- radiodish_actor-1.0.0/radiodish_actor.egg-info/SOURCES.txt +12 -0
- radiodish_actor-1.0.0/radiodish_actor.egg-info/dependency_links.txt +1 -0
- radiodish_actor-1.0.0/radiodish_actor.egg-info/requires.txt +1 -0
- radiodish_actor-1.0.0/radiodish_actor.egg-info/top_level.txt +1 -0
- radiodish_actor-1.0.0/setup.cfg +4 -0
- radiodish_actor-1.0.0/src/__init__.py +41 -0
- radiodish_actor-1.0.0/src/base.py +335 -0
- radiodish_actor-1.0.0/src/decorators.py +36 -0
- radiodish_actor-1.0.0/tests/test_actor.py +196 -0
|
@@ -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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
radiodish>=1.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
radioactor
|
|
@@ -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())
|