commlink 0.1.5__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.
- commlink-0.1.5/LICENSE +21 -0
- commlink-0.1.5/MANIFEST.in +1 -0
- commlink-0.1.5/PKG-INFO +158 -0
- commlink-0.1.5/README.md +113 -0
- commlink-0.1.5/pyproject.toml +46 -0
- commlink-0.1.5/setup.cfg +4 -0
- commlink-0.1.5/src/commlink/__init__.py +16 -0
- commlink-0.1.5/src/commlink/publisher.py +54 -0
- commlink-0.1.5/src/commlink/py.typed +0 -0
- commlink-0.1.5/src/commlink/rpc_client.py +140 -0
- commlink-0.1.5/src/commlink/rpc_server.py +153 -0
- commlink-0.1.5/src/commlink/subscriber.py +120 -0
- commlink-0.1.5/src/commlink.egg-info/PKG-INFO +158 -0
- commlink-0.1.5/src/commlink.egg-info/SOURCES.txt +17 -0
- commlink-0.1.5/src/commlink.egg-info/dependency_links.txt +1 -0
- commlink-0.1.5/src/commlink.egg-info/requires.txt +4 -0
- commlink-0.1.5/src/commlink.egg-info/top_level.txt +1 -0
- commlink-0.1.5/tests/test_pubsub.py +167 -0
- commlink-0.1.5/tests/test_rpc.py +107 -0
commlink-0.1.5/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Commlink Maintainers
|
|
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 @@
|
|
|
1
|
+
include LICENSE
|
commlink-0.1.5/PKG-INFO
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commlink
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: ZeroMQ-based publisher/subscriber and RPC utilities
|
|
5
|
+
Author: Commlink Maintainers
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024 Commlink Maintainers
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Keywords: zeromq,messaging,rpc,pubsub
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Topic :: Communications
|
|
37
|
+
Classifier: Topic :: System :: Networking
|
|
38
|
+
Requires-Python: >=3.9
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
License-File: LICENSE
|
|
41
|
+
Requires-Dist: pyzmq>=25.1
|
|
42
|
+
Provides-Extra: test
|
|
43
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# Commlink
|
|
47
|
+
|
|
48
|
+
Commlink exposes a lightweight Remote Procedure Call (RPC) layer on top of [ZeroMQ](https://zeromq.org/) that lets you interact
|
|
49
|
+
with objects running in a different process or host as if they were local. You can wrap any existing object with a single line
|
|
50
|
+
and obtain a client-side proxy that transparently mirrors attribute access, mutation, and callable invocation for anything that
|
|
51
|
+
can be pickled. Simple publisher/subscriber helpers are also available for broadcast-style messaging when you need them.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install commlink
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## RPC quickstart
|
|
60
|
+
|
|
61
|
+
### Server
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import numpy as np
|
|
65
|
+
from commlink import RPCServer
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Robot:
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self.name = "robot_arm"
|
|
71
|
+
self.target = np.zeros(7)
|
|
72
|
+
self.joint_angles = np.zeros(7)
|
|
73
|
+
|
|
74
|
+
def move_to(self, target):
|
|
75
|
+
"""Pretend to command the arm and update internal state."""
|
|
76
|
+
self.target = target
|
|
77
|
+
self.joint_angles = target # Pretend we reached the target.
|
|
78
|
+
return f"moving to {target}"
|
|
79
|
+
|
|
80
|
+
def start_background_planner(self):
|
|
81
|
+
"""Threads inside the object are fine; RPC will just call into them."""
|
|
82
|
+
# Launch your own planner thread here; omitted for brevity.
|
|
83
|
+
return "planner started"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
# Wrap the robot with a one-line RPC server. The server runs in a background thread by default.
|
|
88
|
+
server = RPCServer(Robot(), port=6000)
|
|
89
|
+
server.start()
|
|
90
|
+
server.thread.join() # Optional: keep the process alive while the server thread runs.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Client
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from commlink import RPCClient
|
|
97
|
+
|
|
98
|
+
# Pretend the robot is local – all attribute access and calls proxy over the wire.
|
|
99
|
+
robot = RPCClient("localhost", port=6000)
|
|
100
|
+
|
|
101
|
+
print(robot.name)
|
|
102
|
+
robot.name = "something_else"
|
|
103
|
+
print(robot.name) # "something_else"
|
|
104
|
+
|
|
105
|
+
robot.move_to(np.ones(7))
|
|
106
|
+
print(robot.joint_angles)
|
|
107
|
+
|
|
108
|
+
# Kick off threaded work that lives inside the remote object.
|
|
109
|
+
print(robot.start_background_planner())
|
|
110
|
+
|
|
111
|
+
# When you're finished, politely stop the remote server.
|
|
112
|
+
robot.stop_server()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### RPC capabilities
|
|
116
|
+
|
|
117
|
+
* **Transparent calls** – Functions and methods execute remotely with arbitrary pickle-able arguments and return values.
|
|
118
|
+
* **Attribute access** – Reading or setting attributes forwards the operation to the remote object.
|
|
119
|
+
* **Drop-in adoption** – Wrap any pre-existing object with `RPCServer(obj, ...)` and obtain a live proxy by instantiating
|
|
120
|
+
`RPCClient(host, port)`.
|
|
121
|
+
* **Thread-friendly** – `RPCServer` can run in a background thread, and the wrapped object can manage its own worker threads or
|
|
122
|
+
loops without special handling.
|
|
123
|
+
|
|
124
|
+
## Publisher/subscriber helpers
|
|
125
|
+
|
|
126
|
+
If you also need broadcast-style messaging (images, poses, strings), Commlink ships with simple ZeroMQ publishers and subscribers:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import numpy as np
|
|
130
|
+
from commlink import Publisher, Subscriber
|
|
131
|
+
|
|
132
|
+
pub = Publisher("*", port=5555)
|
|
133
|
+
sub = Subscriber("localhost", port=5555, topics=["rgb_image", "depth_image", "camera_pose", "info"])
|
|
134
|
+
|
|
135
|
+
# Publish rich data using dict-style access.
|
|
136
|
+
pub["rgb_image"] = np.random.randn(3, 224, 224)
|
|
137
|
+
pub["depth_image"] = np.random.randn(1, 224, 224)
|
|
138
|
+
pub["camera_pose"] = np.random.randn(4, 4)
|
|
139
|
+
pub["info"] = "helloworld"
|
|
140
|
+
|
|
141
|
+
# Receive them on the subscriber side.
|
|
142
|
+
print(sub["rgb_image"].shape)
|
|
143
|
+
print(sub["depth_image"].shape)
|
|
144
|
+
print(sub["camera_pose"].shape)
|
|
145
|
+
print(sub["info"])
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
Run the automated test suite with:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pytest
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
Commlink is distributed under the terms of the [MIT License](./LICENSE).
|
commlink-0.1.5/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Commlink
|
|
2
|
+
|
|
3
|
+
Commlink exposes a lightweight Remote Procedure Call (RPC) layer on top of [ZeroMQ](https://zeromq.org/) that lets you interact
|
|
4
|
+
with objects running in a different process or host as if they were local. You can wrap any existing object with a single line
|
|
5
|
+
and obtain a client-side proxy that transparently mirrors attribute access, mutation, and callable invocation for anything that
|
|
6
|
+
can be pickled. Simple publisher/subscriber helpers are also available for broadcast-style messaging when you need them.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install commlink
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## RPC quickstart
|
|
15
|
+
|
|
16
|
+
### Server
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import numpy as np
|
|
20
|
+
from commlink import RPCServer
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Robot:
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.name = "robot_arm"
|
|
26
|
+
self.target = np.zeros(7)
|
|
27
|
+
self.joint_angles = np.zeros(7)
|
|
28
|
+
|
|
29
|
+
def move_to(self, target):
|
|
30
|
+
"""Pretend to command the arm and update internal state."""
|
|
31
|
+
self.target = target
|
|
32
|
+
self.joint_angles = target # Pretend we reached the target.
|
|
33
|
+
return f"moving to {target}"
|
|
34
|
+
|
|
35
|
+
def start_background_planner(self):
|
|
36
|
+
"""Threads inside the object are fine; RPC will just call into them."""
|
|
37
|
+
# Launch your own planner thread here; omitted for brevity.
|
|
38
|
+
return "planner started"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
# Wrap the robot with a one-line RPC server. The server runs in a background thread by default.
|
|
43
|
+
server = RPCServer(Robot(), port=6000)
|
|
44
|
+
server.start()
|
|
45
|
+
server.thread.join() # Optional: keep the process alive while the server thread runs.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Client
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from commlink import RPCClient
|
|
52
|
+
|
|
53
|
+
# Pretend the robot is local – all attribute access and calls proxy over the wire.
|
|
54
|
+
robot = RPCClient("localhost", port=6000)
|
|
55
|
+
|
|
56
|
+
print(robot.name)
|
|
57
|
+
robot.name = "something_else"
|
|
58
|
+
print(robot.name) # "something_else"
|
|
59
|
+
|
|
60
|
+
robot.move_to(np.ones(7))
|
|
61
|
+
print(robot.joint_angles)
|
|
62
|
+
|
|
63
|
+
# Kick off threaded work that lives inside the remote object.
|
|
64
|
+
print(robot.start_background_planner())
|
|
65
|
+
|
|
66
|
+
# When you're finished, politely stop the remote server.
|
|
67
|
+
robot.stop_server()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### RPC capabilities
|
|
71
|
+
|
|
72
|
+
* **Transparent calls** – Functions and methods execute remotely with arbitrary pickle-able arguments and return values.
|
|
73
|
+
* **Attribute access** – Reading or setting attributes forwards the operation to the remote object.
|
|
74
|
+
* **Drop-in adoption** – Wrap any pre-existing object with `RPCServer(obj, ...)` and obtain a live proxy by instantiating
|
|
75
|
+
`RPCClient(host, port)`.
|
|
76
|
+
* **Thread-friendly** – `RPCServer` can run in a background thread, and the wrapped object can manage its own worker threads or
|
|
77
|
+
loops without special handling.
|
|
78
|
+
|
|
79
|
+
## Publisher/subscriber helpers
|
|
80
|
+
|
|
81
|
+
If you also need broadcast-style messaging (images, poses, strings), Commlink ships with simple ZeroMQ publishers and subscribers:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import numpy as np
|
|
85
|
+
from commlink import Publisher, Subscriber
|
|
86
|
+
|
|
87
|
+
pub = Publisher("*", port=5555)
|
|
88
|
+
sub = Subscriber("localhost", port=5555, topics=["rgb_image", "depth_image", "camera_pose", "info"])
|
|
89
|
+
|
|
90
|
+
# Publish rich data using dict-style access.
|
|
91
|
+
pub["rgb_image"] = np.random.randn(3, 224, 224)
|
|
92
|
+
pub["depth_image"] = np.random.randn(1, 224, 224)
|
|
93
|
+
pub["camera_pose"] = np.random.randn(4, 4)
|
|
94
|
+
pub["info"] = "helloworld"
|
|
95
|
+
|
|
96
|
+
# Receive them on the subscriber side.
|
|
97
|
+
print(sub["rgb_image"].shape)
|
|
98
|
+
print(sub["depth_image"].shape)
|
|
99
|
+
print(sub["camera_pose"].shape)
|
|
100
|
+
print(sub["info"])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
Run the automated test suite with:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pytest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
Commlink is distributed under the terms of the [MIT License](./LICENSE).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=67", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "commlink"
|
|
7
|
+
version = "0.1.5"
|
|
8
|
+
description = "ZeroMQ-based publisher/subscriber and RPC utilities"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Commlink Maintainers" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["zeromq", "messaging", "rpc", "pubsub"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Topic :: Communications",
|
|
25
|
+
"Topic :: System :: Networking",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"pyzmq>=25.1",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
test = [
|
|
33
|
+
"pytest>=7",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools]
|
|
37
|
+
package-dir = {"" = "src"}
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
commlink = ["py.typed"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
pythonpath = ["src"]
|
commlink-0.1.5/setup.cfg
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Commlink package exposing ZeroMQ-based publisher, subscriber, and RPC helpers."""
|
|
2
|
+
|
|
3
|
+
from .publisher import Publisher
|
|
4
|
+
from .subscriber import Subscriber
|
|
5
|
+
from .rpc_client import RPCClient, RPCException
|
|
6
|
+
from .rpc_server import RPCServer
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Publisher",
|
|
10
|
+
"Subscriber",
|
|
11
|
+
"RPCClient",
|
|
12
|
+
"RPCException",
|
|
13
|
+
"RPCServer",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import zmq
|
|
2
|
+
import pickle
|
|
3
|
+
from typing import Optional, Callable, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Publisher:
|
|
7
|
+
def __init__(self, host: str, port: int = 5000):
|
|
8
|
+
"""
|
|
9
|
+
host: host to connect to
|
|
10
|
+
port: port to connect to
|
|
11
|
+
"""
|
|
12
|
+
self.context = zmq.Context()
|
|
13
|
+
self.socket = self.context.socket(zmq.PUB)
|
|
14
|
+
self.socket.bind(f"tcp://{host}:{port}")
|
|
15
|
+
self.serializer = pickle.dumps
|
|
16
|
+
|
|
17
|
+
def set_serializer(self, serializer: Optional[Callable[[Any], bytes]]):
|
|
18
|
+
self.serializer = serializer or pickle.dumps
|
|
19
|
+
|
|
20
|
+
def publish(self, topic: str, data: Any):
|
|
21
|
+
"""
|
|
22
|
+
Publish a dictionary of {
|
|
23
|
+
"topic": str,
|
|
24
|
+
"data": object,
|
|
25
|
+
}
|
|
26
|
+
where the data is serialized using self.serializer (default: pickle.dumps)
|
|
27
|
+
"""
|
|
28
|
+
if " " in topic:
|
|
29
|
+
raise ValueError("topic cannot contain spaces")
|
|
30
|
+
topic = topic.encode("utf-8")
|
|
31
|
+
data = self.serializer(data)
|
|
32
|
+
msg = topic + b" " + data
|
|
33
|
+
self.socket.send(msg)
|
|
34
|
+
|
|
35
|
+
def __setitem__(self, topic: str, data: Any):
|
|
36
|
+
"""
|
|
37
|
+
Allow dict-style publishing via publisher[topic] = data.
|
|
38
|
+
"""
|
|
39
|
+
self.publish(topic, data)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
# Example usage:
|
|
44
|
+
import numpy as np
|
|
45
|
+
|
|
46
|
+
def np_array_serializer(arr):
|
|
47
|
+
return arr.tobytes()
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
pub = Publisher("*", port=1234)
|
|
51
|
+
pub.set_serializer(np_array_serializer)
|
|
52
|
+
|
|
53
|
+
while True:
|
|
54
|
+
pub.publish("test", np.random.rand(100, 100))
|
|
File without changes
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import zmq
|
|
2
|
+
import pickle
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RPCException(Exception):
|
|
6
|
+
def __init__(self, exception_type: str, message: str, traceback: str):
|
|
7
|
+
self.exception_type = exception_type
|
|
8
|
+
self.message = message
|
|
9
|
+
self.traceback = traceback
|
|
10
|
+
|
|
11
|
+
def __str__(self):
|
|
12
|
+
return f"{self.exception_type}: {self.message}\n{self.traceback}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RPCClient:
|
|
16
|
+
def __init__(self, host: str, port: int = 5000):
|
|
17
|
+
"""
|
|
18
|
+
host: host to connect to
|
|
19
|
+
port: port to connect to
|
|
20
|
+
"""
|
|
21
|
+
self.__dict__["context"] = zmq.Context()
|
|
22
|
+
self.__dict__["socket"] = self.context.socket(zmq.REQ)
|
|
23
|
+
self.socket.connect(f"tcp://{host}:{port}")
|
|
24
|
+
self.__dict__["_is_callable_cache"] = {}
|
|
25
|
+
|
|
26
|
+
def __setattr__(self, attr: str, value):
|
|
27
|
+
"""
|
|
28
|
+
Set the attribute of the same name.
|
|
29
|
+
Attribute must not be callable on remote.
|
|
30
|
+
"""
|
|
31
|
+
if self._is_callable(attr):
|
|
32
|
+
raise AttributeError(f"Overwriting a callable attribute: {attr}")
|
|
33
|
+
self._send_set(attr, value)
|
|
34
|
+
|
|
35
|
+
def _send_get(self, attr: str, args: list, kwargs: dict):
|
|
36
|
+
"""
|
|
37
|
+
Send a get request over the socket.
|
|
38
|
+
"""
|
|
39
|
+
req = {"req": "get", "attr": attr, "args": args, "kwargs": kwargs}
|
|
40
|
+
self.socket.send(pickle.dumps(req))
|
|
41
|
+
return self._recv_result()
|
|
42
|
+
|
|
43
|
+
def _send_set(self, attr: str, value):
|
|
44
|
+
"""
|
|
45
|
+
Send a set request over the socket.
|
|
46
|
+
"""
|
|
47
|
+
req = {"req": "set", "attr": attr, "value": value}
|
|
48
|
+
self.socket.send(pickle.dumps(req))
|
|
49
|
+
return self._recv_result()
|
|
50
|
+
|
|
51
|
+
def _recv_result(self):
|
|
52
|
+
"""
|
|
53
|
+
Receive a dictionary of {
|
|
54
|
+
"type": str,
|
|
55
|
+
"content": object,
|
|
56
|
+
}
|
|
57
|
+
if type == "exception", content is a dictionary of {
|
|
58
|
+
"exception": str,
|
|
59
|
+
"message": str,
|
|
60
|
+
"traceback": str,
|
|
61
|
+
}; re-raise the exception on the client side
|
|
62
|
+
if type == "result", content is the result
|
|
63
|
+
"""
|
|
64
|
+
result = self.socket.recv()
|
|
65
|
+
result = pickle.loads(result)
|
|
66
|
+
if result["type"] == "exception":
|
|
67
|
+
raise RPCException(
|
|
68
|
+
result["content"]["exception"],
|
|
69
|
+
result["content"]["message"],
|
|
70
|
+
result["content"]["traceback"],
|
|
71
|
+
)
|
|
72
|
+
return result["content"]
|
|
73
|
+
|
|
74
|
+
def _is_callable(self, attr: str) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
Send a request to check if the attribute is callable.
|
|
77
|
+
Returns False if the attribute is not found.
|
|
78
|
+
"""
|
|
79
|
+
if attr not in self._is_callable_cache:
|
|
80
|
+
req = {"req": "is_callable", "attr": attr}
|
|
81
|
+
self.socket.send(pickle.dumps(req))
|
|
82
|
+
result = self._recv_result()
|
|
83
|
+
self._is_callable_cache[attr] = result
|
|
84
|
+
return self._is_callable_cache[attr]
|
|
85
|
+
|
|
86
|
+
def __getattr__(self, attr: str):
|
|
87
|
+
"""
|
|
88
|
+
Return the attribute of the same name.
|
|
89
|
+
If the attribute is a callable, return a function that sends the call over the socket.
|
|
90
|
+
Else, return the attribute value.
|
|
91
|
+
"""
|
|
92
|
+
if self._is_callable(attr):
|
|
93
|
+
return lambda *args, **kwargs: self._send_get(attr, args, kwargs)
|
|
94
|
+
else:
|
|
95
|
+
return self._send_get(attr, [], {})
|
|
96
|
+
|
|
97
|
+
def __dir__(self):
|
|
98
|
+
"""
|
|
99
|
+
Return a list of attributes.
|
|
100
|
+
"""
|
|
101
|
+
req = {"req": "dir"}
|
|
102
|
+
self.socket.send(pickle.dumps(req))
|
|
103
|
+
result = self._recv_result()
|
|
104
|
+
return result + ["stop_server"]
|
|
105
|
+
|
|
106
|
+
def stop_server(self) -> bool:
|
|
107
|
+
"""
|
|
108
|
+
Send a stop request to the server.
|
|
109
|
+
If the server is stopped, close the socket and terminate the context.
|
|
110
|
+
Returns a bool for success.
|
|
111
|
+
"""
|
|
112
|
+
req = {"req": "stop"}
|
|
113
|
+
self.socket.send(pickle.dumps(req))
|
|
114
|
+
stopped = self._recv_result()
|
|
115
|
+
if stopped:
|
|
116
|
+
self.socket.close()
|
|
117
|
+
self.context.term()
|
|
118
|
+
else:
|
|
119
|
+
raise RuntimeError("Could not stop the server.")
|
|
120
|
+
return stopped
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
import time
|
|
125
|
+
import numpy as np
|
|
126
|
+
|
|
127
|
+
Hello = RPCClient("localhost", port=1234)
|
|
128
|
+
arr = []
|
|
129
|
+
for i in range(100):
|
|
130
|
+
start = time.time()
|
|
131
|
+
print(Hello.hello())
|
|
132
|
+
arr.append(time.time() - start)
|
|
133
|
+
print("Total time:", sum(arr))
|
|
134
|
+
print(sum(arr) / len(arr))
|
|
135
|
+
print(len(arr) / sum(arr))
|
|
136
|
+
print(Hello.abc)
|
|
137
|
+
Hello.new_attr = 456
|
|
138
|
+
print(Hello.new_attr)
|
|
139
|
+
print(dir(Hello))
|
|
140
|
+
print(Hello.bad())
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import zmq
|
|
2
|
+
import time
|
|
3
|
+
import pickle
|
|
4
|
+
import threading
|
|
5
|
+
import traceback
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RPCServer:
|
|
9
|
+
def __init__(self, obj, port: int = 5000, threaded: bool = True):
|
|
10
|
+
"""
|
|
11
|
+
obj: object with methods to expose
|
|
12
|
+
port: port to listen on
|
|
13
|
+
"""
|
|
14
|
+
self.obj = obj
|
|
15
|
+
self.context = zmq.Context()
|
|
16
|
+
self.socket: zmq.socket.Socket = self.context.socket(zmq.REP)
|
|
17
|
+
self.socket.bind(f"tcp://*:{port}")
|
|
18
|
+
self.threaded = threaded
|
|
19
|
+
self.thread = None
|
|
20
|
+
if threaded:
|
|
21
|
+
self.stop_event = threading.Event()
|
|
22
|
+
else:
|
|
23
|
+
self.stop_event = False
|
|
24
|
+
|
|
25
|
+
def _send_exception(self, e):
|
|
26
|
+
"""
|
|
27
|
+
Serialize an exception and send it over the socket.
|
|
28
|
+
Only the exception type, message, and traceback are sent.
|
|
29
|
+
"""
|
|
30
|
+
exception = {
|
|
31
|
+
"type": "exception",
|
|
32
|
+
"content": {
|
|
33
|
+
"exception": str(type(e)),
|
|
34
|
+
"message": str(e),
|
|
35
|
+
"traceback": traceback.format_exc(),
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
self.socket.send(pickle.dumps(exception))
|
|
39
|
+
|
|
40
|
+
def _send_result(self, result):
|
|
41
|
+
"""
|
|
42
|
+
Serialize a result and send it over the socket.
|
|
43
|
+
"""
|
|
44
|
+
result = {"type": "result", "content": result}
|
|
45
|
+
self.socket.send(pickle.dumps(result))
|
|
46
|
+
|
|
47
|
+
def run(self):
|
|
48
|
+
"""
|
|
49
|
+
Run the server.
|
|
50
|
+
"""
|
|
51
|
+
if self.threaded:
|
|
52
|
+
while not self.stop_event.is_set():
|
|
53
|
+
try:
|
|
54
|
+
message = self.socket.recv()
|
|
55
|
+
message = pickle.loads(message)
|
|
56
|
+
self._handle_message(message)
|
|
57
|
+
except zmq.ContextTerminated:
|
|
58
|
+
break
|
|
59
|
+
else:
|
|
60
|
+
while not self.stop_event:
|
|
61
|
+
try:
|
|
62
|
+
message = self.socket.recv(flags=zmq.NOBLOCK)
|
|
63
|
+
message = pickle.loads(message)
|
|
64
|
+
except zmq.Again:
|
|
65
|
+
time.sleep(0.001)
|
|
66
|
+
continue
|
|
67
|
+
self._handle_message(message)
|
|
68
|
+
|
|
69
|
+
def _is_callable(self, attr):
|
|
70
|
+
return hasattr(self.obj, attr) and callable(getattr(self.obj, attr))
|
|
71
|
+
|
|
72
|
+
def _handle_message(self, message):
|
|
73
|
+
"""
|
|
74
|
+
Handles a dictionary of {
|
|
75
|
+
"req": str, # request type
|
|
76
|
+
"attr": str,
|
|
77
|
+
"args": list,
|
|
78
|
+
"kwargs": dict,
|
|
79
|
+
}
|
|
80
|
+
from the socket.
|
|
81
|
+
If req == "is_callable", return whether the attribute is callable.
|
|
82
|
+
If req == "get", return the attribute.
|
|
83
|
+
If the attribute is not found, return an error message.
|
|
84
|
+
If the attribute is callable, call with args and kwargs.
|
|
85
|
+
If there are any errors in the callable, return the pickled error
|
|
86
|
+
If the callable is found and there are no errors, return the pickled result.
|
|
87
|
+
If the attribute is not callable, return the attribute.
|
|
88
|
+
If req == "set", set the attribute to the value.
|
|
89
|
+
If req == "dir", return a list of attributes.
|
|
90
|
+
If req == "stop", stop the server.
|
|
91
|
+
"""
|
|
92
|
+
if message["req"] == "is_callable":
|
|
93
|
+
result = self._is_callable(message["attr"])
|
|
94
|
+
self._send_result(result)
|
|
95
|
+
elif message["req"] == "get":
|
|
96
|
+
try:
|
|
97
|
+
attribute = getattr(self.obj, message["attr"])
|
|
98
|
+
args = message["args"]
|
|
99
|
+
kwargs = message["kwargs"]
|
|
100
|
+
if not callable(attribute):
|
|
101
|
+
self._send_result(attribute)
|
|
102
|
+
else:
|
|
103
|
+
result = attribute(*args, **kwargs)
|
|
104
|
+
self._send_result(result)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
self._send_exception(e)
|
|
107
|
+
elif message["req"] == "set":
|
|
108
|
+
try:
|
|
109
|
+
setattr(self.obj, message["attr"], message["value"])
|
|
110
|
+
self._send_result(None)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
self._send_exception(e)
|
|
113
|
+
elif message["req"] == "dir":
|
|
114
|
+
result = dir(self.obj)
|
|
115
|
+
self._send_result(result)
|
|
116
|
+
elif message["req"] == "stop":
|
|
117
|
+
self._send_result(True)
|
|
118
|
+
self.stop()
|
|
119
|
+
|
|
120
|
+
def start(self):
|
|
121
|
+
if self.threaded:
|
|
122
|
+
self.stop_event.clear()
|
|
123
|
+
self.thread = threading.Thread(target=self.run)
|
|
124
|
+
self.thread.start()
|
|
125
|
+
else:
|
|
126
|
+
self.run()
|
|
127
|
+
|
|
128
|
+
def stop(self):
|
|
129
|
+
self.socket.close()
|
|
130
|
+
self.context.term()
|
|
131
|
+
if self.threaded:
|
|
132
|
+
self.stop_event.set()
|
|
133
|
+
if self.thread and threading.current_thread() is not self.thread:
|
|
134
|
+
self.thread.join()
|
|
135
|
+
self.thread = None
|
|
136
|
+
else:
|
|
137
|
+
self.stop_event = True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
import numpy as np
|
|
142
|
+
class HelloWorld:
|
|
143
|
+
def __init__(self):
|
|
144
|
+
self.abc = 123
|
|
145
|
+
|
|
146
|
+
def hello(self):
|
|
147
|
+
return np.random.randn(3, 224, 224)
|
|
148
|
+
|
|
149
|
+
def bad(self):
|
|
150
|
+
return 1 / 0
|
|
151
|
+
|
|
152
|
+
server = RPCServer(HelloWorld(), port=1234)
|
|
153
|
+
server.start()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import pickle
|
|
2
|
+
from typing import Iterable, Optional, Callable, Any
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
import zmq
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Subscriber:
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
host: str,
|
|
12
|
+
port: int = 5000,
|
|
13
|
+
topics: Optional[Iterable[str]] = [],
|
|
14
|
+
buffer: bool = False,
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
host: host to connect to
|
|
18
|
+
port: port to connect to
|
|
19
|
+
topics: optional list of topics to subscribe to.
|
|
20
|
+
If not supplied, subscribe to all topics.
|
|
21
|
+
buffer: whether to keep old messages in the buffer (no conflation).
|
|
22
|
+
Default False (only keep latest for each topic).
|
|
23
|
+
"""
|
|
24
|
+
self.buffer = buffer
|
|
25
|
+
self.context = zmq.Context()
|
|
26
|
+
self.deserializer = pickle.loads
|
|
27
|
+
self._endpoint = f"tcp://{host}:{port}"
|
|
28
|
+
self._topic_sockets: dict[Optional[str], zmq.Socket] = {}
|
|
29
|
+
|
|
30
|
+
if isinstance(topics, str):
|
|
31
|
+
raise TypeError("topics must be an iterable of strings, not a single string")
|
|
32
|
+
else:
|
|
33
|
+
topics = list(topics)
|
|
34
|
+
if any(not isinstance(t, str) for t in topics):
|
|
35
|
+
raise TypeError("topics must be an iterable of strings")
|
|
36
|
+
if not topics and not buffer:
|
|
37
|
+
warnings.warn(
|
|
38
|
+
"Subscribing to all topics with buffer=False keeps only the latest message across all topics."
|
|
39
|
+
"Specify topics or set buffer=True to avoid this warning.",
|
|
40
|
+
RuntimeWarning,
|
|
41
|
+
stacklevel=2,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self._global_socket = self._new_socket()
|
|
45
|
+
self._global_socket.setsockopt_string(zmq.SUBSCRIBE, "")
|
|
46
|
+
self._global_socket.connect(self._endpoint)
|
|
47
|
+
self._topic_sockets[None] = self._global_socket
|
|
48
|
+
|
|
49
|
+
for topic in topics:
|
|
50
|
+
self._topic_sockets[topic] = self._create_topic_socket(topic)
|
|
51
|
+
|
|
52
|
+
def set_deserializer(self, deserializer: Optional[Callable[[bytes], Any]] = None):
|
|
53
|
+
self.deserializer = deserializer or pickle.loads
|
|
54
|
+
|
|
55
|
+
def get(self, topic: Optional[str] = None) -> Any:
|
|
56
|
+
"""
|
|
57
|
+
Get data for a topic.
|
|
58
|
+
- sub.get() returns (topic, data) from the global subscriber.
|
|
59
|
+
- sub.get(topic) returns the deserialized data from that topic's dedicated subscriber.
|
|
60
|
+
"""
|
|
61
|
+
if topic not in self._topic_sockets:
|
|
62
|
+
raise KeyError(f"Topic '{topic}' was not subscribed.")
|
|
63
|
+
msg = self._topic_sockets[topic].recv()
|
|
64
|
+
topic_str, data_obj = self._deserialize(msg)
|
|
65
|
+
return (topic_str, data_obj) if topic is None else data_obj
|
|
66
|
+
|
|
67
|
+
def __getitem__(self, topic: str) -> Any:
|
|
68
|
+
"""
|
|
69
|
+
Retrieve data for a specific topic via sub[topic].
|
|
70
|
+
"""
|
|
71
|
+
return self.get(topic)
|
|
72
|
+
|
|
73
|
+
def stop(self):
|
|
74
|
+
"""
|
|
75
|
+
Safely terminate the subscription and clean up the resources.
|
|
76
|
+
"""
|
|
77
|
+
for socket in self._topic_sockets.values():
|
|
78
|
+
socket.close()
|
|
79
|
+
self.context.term()
|
|
80
|
+
|
|
81
|
+
def _new_socket(self) -> zmq.Socket:
|
|
82
|
+
socket = self.context.socket(zmq.SUB)
|
|
83
|
+
if not self.buffer:
|
|
84
|
+
socket.setsockopt(zmq.CONFLATE, 1)
|
|
85
|
+
return socket
|
|
86
|
+
|
|
87
|
+
def _create_topic_socket(self, topic: str) -> zmq.Socket:
|
|
88
|
+
self._validate_topic(topic)
|
|
89
|
+
socket = self._new_socket()
|
|
90
|
+
socket.setsockopt_string(zmq.SUBSCRIBE, topic)
|
|
91
|
+
socket.connect(self._endpoint)
|
|
92
|
+
return socket
|
|
93
|
+
|
|
94
|
+
def _validate_topic(self, topic: str):
|
|
95
|
+
if " " in topic:
|
|
96
|
+
raise ValueError("topic cannot contain spaces")
|
|
97
|
+
|
|
98
|
+
def _deserialize(self, msg: bytes) -> tuple[str, Any]:
|
|
99
|
+
topic = msg.split(b" ")[0].decode("utf-8")
|
|
100
|
+
data = msg[len(topic) + 1 :]
|
|
101
|
+
data_obj = self.deserializer(data)
|
|
102
|
+
return topic, data_obj
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
# Example usage:
|
|
107
|
+
import cv2
|
|
108
|
+
import numpy as np
|
|
109
|
+
|
|
110
|
+
def np_array_deserializer(arr):
|
|
111
|
+
return np.frombuffer(arr, dtype=np.float64).reshape((100, 100))
|
|
112
|
+
|
|
113
|
+
sub = Subscriber("localhost", port=1234, topics=["test"])
|
|
114
|
+
sub.set_deserializer(np_array_deserializer)
|
|
115
|
+
|
|
116
|
+
while True:
|
|
117
|
+
topic, data = sub.get()
|
|
118
|
+
print(topic)
|
|
119
|
+
cv2.imshow("test", data)
|
|
120
|
+
cv2.waitKey(1)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commlink
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: ZeroMQ-based publisher/subscriber and RPC utilities
|
|
5
|
+
Author: Commlink Maintainers
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024 Commlink Maintainers
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Keywords: zeromq,messaging,rpc,pubsub
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Topic :: Communications
|
|
37
|
+
Classifier: Topic :: System :: Networking
|
|
38
|
+
Requires-Python: >=3.9
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
License-File: LICENSE
|
|
41
|
+
Requires-Dist: pyzmq>=25.1
|
|
42
|
+
Provides-Extra: test
|
|
43
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# Commlink
|
|
47
|
+
|
|
48
|
+
Commlink exposes a lightweight Remote Procedure Call (RPC) layer on top of [ZeroMQ](https://zeromq.org/) that lets you interact
|
|
49
|
+
with objects running in a different process or host as if they were local. You can wrap any existing object with a single line
|
|
50
|
+
and obtain a client-side proxy that transparently mirrors attribute access, mutation, and callable invocation for anything that
|
|
51
|
+
can be pickled. Simple publisher/subscriber helpers are also available for broadcast-style messaging when you need them.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install commlink
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## RPC quickstart
|
|
60
|
+
|
|
61
|
+
### Server
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import numpy as np
|
|
65
|
+
from commlink import RPCServer
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Robot:
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self.name = "robot_arm"
|
|
71
|
+
self.target = np.zeros(7)
|
|
72
|
+
self.joint_angles = np.zeros(7)
|
|
73
|
+
|
|
74
|
+
def move_to(self, target):
|
|
75
|
+
"""Pretend to command the arm and update internal state."""
|
|
76
|
+
self.target = target
|
|
77
|
+
self.joint_angles = target # Pretend we reached the target.
|
|
78
|
+
return f"moving to {target}"
|
|
79
|
+
|
|
80
|
+
def start_background_planner(self):
|
|
81
|
+
"""Threads inside the object are fine; RPC will just call into them."""
|
|
82
|
+
# Launch your own planner thread here; omitted for brevity.
|
|
83
|
+
return "planner started"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
# Wrap the robot with a one-line RPC server. The server runs in a background thread by default.
|
|
88
|
+
server = RPCServer(Robot(), port=6000)
|
|
89
|
+
server.start()
|
|
90
|
+
server.thread.join() # Optional: keep the process alive while the server thread runs.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Client
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from commlink import RPCClient
|
|
97
|
+
|
|
98
|
+
# Pretend the robot is local – all attribute access and calls proxy over the wire.
|
|
99
|
+
robot = RPCClient("localhost", port=6000)
|
|
100
|
+
|
|
101
|
+
print(robot.name)
|
|
102
|
+
robot.name = "something_else"
|
|
103
|
+
print(robot.name) # "something_else"
|
|
104
|
+
|
|
105
|
+
robot.move_to(np.ones(7))
|
|
106
|
+
print(robot.joint_angles)
|
|
107
|
+
|
|
108
|
+
# Kick off threaded work that lives inside the remote object.
|
|
109
|
+
print(robot.start_background_planner())
|
|
110
|
+
|
|
111
|
+
# When you're finished, politely stop the remote server.
|
|
112
|
+
robot.stop_server()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### RPC capabilities
|
|
116
|
+
|
|
117
|
+
* **Transparent calls** – Functions and methods execute remotely with arbitrary pickle-able arguments and return values.
|
|
118
|
+
* **Attribute access** – Reading or setting attributes forwards the operation to the remote object.
|
|
119
|
+
* **Drop-in adoption** – Wrap any pre-existing object with `RPCServer(obj, ...)` and obtain a live proxy by instantiating
|
|
120
|
+
`RPCClient(host, port)`.
|
|
121
|
+
* **Thread-friendly** – `RPCServer` can run in a background thread, and the wrapped object can manage its own worker threads or
|
|
122
|
+
loops without special handling.
|
|
123
|
+
|
|
124
|
+
## Publisher/subscriber helpers
|
|
125
|
+
|
|
126
|
+
If you also need broadcast-style messaging (images, poses, strings), Commlink ships with simple ZeroMQ publishers and subscribers:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import numpy as np
|
|
130
|
+
from commlink import Publisher, Subscriber
|
|
131
|
+
|
|
132
|
+
pub = Publisher("*", port=5555)
|
|
133
|
+
sub = Subscriber("localhost", port=5555, topics=["rgb_image", "depth_image", "camera_pose", "info"])
|
|
134
|
+
|
|
135
|
+
# Publish rich data using dict-style access.
|
|
136
|
+
pub["rgb_image"] = np.random.randn(3, 224, 224)
|
|
137
|
+
pub["depth_image"] = np.random.randn(1, 224, 224)
|
|
138
|
+
pub["camera_pose"] = np.random.randn(4, 4)
|
|
139
|
+
pub["info"] = "helloworld"
|
|
140
|
+
|
|
141
|
+
# Receive them on the subscriber side.
|
|
142
|
+
print(sub["rgb_image"].shape)
|
|
143
|
+
print(sub["depth_image"].shape)
|
|
144
|
+
print(sub["camera_pose"].shape)
|
|
145
|
+
print(sub["info"])
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
Run the automated test suite with:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pytest
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
Commlink is distributed under the terms of the [MIT License](./LICENSE).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
src/commlink/__init__.py
|
|
6
|
+
src/commlink/publisher.py
|
|
7
|
+
src/commlink/py.typed
|
|
8
|
+
src/commlink/rpc_client.py
|
|
9
|
+
src/commlink/rpc_server.py
|
|
10
|
+
src/commlink/subscriber.py
|
|
11
|
+
src/commlink.egg-info/PKG-INFO
|
|
12
|
+
src/commlink.egg-info/SOURCES.txt
|
|
13
|
+
src/commlink.egg-info/dependency_links.txt
|
|
14
|
+
src/commlink.egg-info/requires.txt
|
|
15
|
+
src/commlink.egg-info/top_level.txt
|
|
16
|
+
tests/test_pubsub.py
|
|
17
|
+
tests/test_rpc.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
commlink
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import zmq
|
|
6
|
+
|
|
7
|
+
from commlink.publisher import Publisher
|
|
8
|
+
from commlink.subscriber import Subscriber
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_free_port() -> int:
|
|
12
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
13
|
+
sock.bind(("127.0.0.1", 0))
|
|
14
|
+
return sock.getsockname()[1]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_receive_timeouts(subscriber: Subscriber, timeout_ms: int = 1000) -> None:
|
|
18
|
+
subscriber._global_socket.setsockopt(zmq.RCVTIMEO, timeout_ms)
|
|
19
|
+
for socket in subscriber._topic_sockets.values():
|
|
20
|
+
socket.setsockopt(zmq.RCVTIMEO, timeout_ms)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_multi_topic_specific_sockets_keep_latest_message():
|
|
24
|
+
port = get_free_port()
|
|
25
|
+
publisher = Publisher("*", port=port)
|
|
26
|
+
subscriber = Subscriber("127.0.0.1", port=port, topics=["alpha", "beta"], buffer=False)
|
|
27
|
+
set_receive_timeouts(subscriber)
|
|
28
|
+
|
|
29
|
+
time.sleep(0.05)
|
|
30
|
+
publisher.publish("alpha", "first-alpha")
|
|
31
|
+
publisher.publish("beta", "first-beta")
|
|
32
|
+
publisher.publish("alpha", "second-alpha")
|
|
33
|
+
publisher.publish("beta", "second-beta")
|
|
34
|
+
time.sleep(0.05)
|
|
35
|
+
|
|
36
|
+
data_a = subscriber.get("alpha")
|
|
37
|
+
assert data_a == "second-alpha"
|
|
38
|
+
|
|
39
|
+
data_b = subscriber.get("beta")
|
|
40
|
+
assert data_b == "second-beta"
|
|
41
|
+
|
|
42
|
+
subscriber.stop()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_global_get_receives_messages_from_all_topics():
|
|
46
|
+
port = get_free_port()
|
|
47
|
+
publisher = Publisher("*", port=port)
|
|
48
|
+
subscriber = Subscriber("127.0.0.1", port=port, topics=["one", "two"], buffer=True)
|
|
49
|
+
set_receive_timeouts(subscriber)
|
|
50
|
+
|
|
51
|
+
time.sleep(0.05)
|
|
52
|
+
publisher.publish("one", 1)
|
|
53
|
+
publisher.publish("two", 2)
|
|
54
|
+
|
|
55
|
+
received_topics = []
|
|
56
|
+
for _ in range(2):
|
|
57
|
+
topic, data = subscriber.get()
|
|
58
|
+
received_topics.append(topic)
|
|
59
|
+
assert data in (1, 2)
|
|
60
|
+
|
|
61
|
+
assert set(received_topics) == {"one", "two"}
|
|
62
|
+
|
|
63
|
+
subscriber.stop()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_getitem_reads_specific_topic_socket():
|
|
67
|
+
port = get_free_port()
|
|
68
|
+
publisher = Publisher("*", port=port)
|
|
69
|
+
subscriber = Subscriber("127.0.0.1", port=port, topics=["red", "blue"], buffer=False)
|
|
70
|
+
set_receive_timeouts(subscriber)
|
|
71
|
+
|
|
72
|
+
time.sleep(0.05)
|
|
73
|
+
publisher.publish("blue", "other")
|
|
74
|
+
publisher.publish("red", "stale")
|
|
75
|
+
publisher.publish("red", "fresh")
|
|
76
|
+
time.sleep(0.05)
|
|
77
|
+
|
|
78
|
+
data = subscriber["red"]
|
|
79
|
+
assert data == "fresh"
|
|
80
|
+
|
|
81
|
+
subscriber.stop()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_setitem_publishes():
|
|
85
|
+
port = get_free_port()
|
|
86
|
+
publisher = Publisher("*", port=port)
|
|
87
|
+
subscriber = Subscriber("127.0.0.1", port=port, topics=["alpha"], buffer=False)
|
|
88
|
+
set_receive_timeouts(subscriber)
|
|
89
|
+
|
|
90
|
+
time.sleep(0.05)
|
|
91
|
+
publisher["alpha"] = "published via setitem"
|
|
92
|
+
time.sleep(0.05)
|
|
93
|
+
|
|
94
|
+
assert subscriber["alpha"] == "published via setitem"
|
|
95
|
+
|
|
96
|
+
subscriber.stop()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_get_raises_for_unsubscribed_topic():
|
|
100
|
+
port = get_free_port()
|
|
101
|
+
publisher = Publisher("*", port=port)
|
|
102
|
+
subscriber = Subscriber("127.0.0.1", port=port, topics=["alpha"], buffer=False)
|
|
103
|
+
set_receive_timeouts(subscriber)
|
|
104
|
+
time.sleep(0.05)
|
|
105
|
+
|
|
106
|
+
with pytest.raises(KeyError):
|
|
107
|
+
subscriber.get("beta")
|
|
108
|
+
|
|
109
|
+
publisher.publish("alpha", "value")
|
|
110
|
+
time.sleep(0.05)
|
|
111
|
+
assert subscriber.get("alpha") == "value"
|
|
112
|
+
|
|
113
|
+
subscriber.stop()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_global_subscription_with_empty_topics_list():
|
|
117
|
+
port = get_free_port()
|
|
118
|
+
publisher = Publisher("*", port=port)
|
|
119
|
+
subscriber = Subscriber("127.0.0.1", port=port, topics=[], buffer=True)
|
|
120
|
+
set_receive_timeouts(subscriber)
|
|
121
|
+
|
|
122
|
+
time.sleep(0.05)
|
|
123
|
+
publisher.publish("x", "first")
|
|
124
|
+
publisher.publish("y", "second")
|
|
125
|
+
|
|
126
|
+
received = {subscriber.get()[0], subscriber.get()[0]}
|
|
127
|
+
assert received == {"x", "y"}
|
|
128
|
+
|
|
129
|
+
subscriber.stop()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_topic_validation_rejects_spaces():
|
|
133
|
+
port = get_free_port()
|
|
134
|
+
with pytest.raises(ValueError):
|
|
135
|
+
Subscriber("127.0.0.1", port=port, topics=["bad topic"], buffer=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_buffer_true_preserves_order_on_topic_socket():
|
|
139
|
+
port = get_free_port()
|
|
140
|
+
publisher = Publisher("*", port=port)
|
|
141
|
+
subscriber = Subscriber("127.0.0.1", port=port, topics=["seq"], buffer=True)
|
|
142
|
+
set_receive_timeouts(subscriber)
|
|
143
|
+
|
|
144
|
+
time.sleep(0.05)
|
|
145
|
+
publisher.publish("seq", 1)
|
|
146
|
+
publisher.publish("seq", 2)
|
|
147
|
+
time.sleep(0.05)
|
|
148
|
+
|
|
149
|
+
first = subscriber.get("seq")
|
|
150
|
+
second = subscriber.get("seq")
|
|
151
|
+
assert first == 1
|
|
152
|
+
assert second == 2
|
|
153
|
+
|
|
154
|
+
subscriber.stop()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_conflate_global_subscription_rejected():
|
|
158
|
+
port = get_free_port()
|
|
159
|
+
with pytest.warns(RuntimeWarning):
|
|
160
|
+
sub_empty = Subscriber("127.0.0.1", port=port, topics=[], buffer=False)
|
|
161
|
+
sub_empty.stop()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_topics_str_is_normalized_to_list():
|
|
165
|
+
port = get_free_port()
|
|
166
|
+
with pytest.raises(TypeError):
|
|
167
|
+
Subscriber("127.0.0.1", port=port, topics="solo", buffer=False)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from commlink.rpc_client import RPCClient, RPCException
|
|
8
|
+
from commlink.rpc_server import RPCServer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExampleService:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.value = 0
|
|
14
|
+
|
|
15
|
+
def increment(self, amount: int = 1) -> int:
|
|
16
|
+
self.value += amount
|
|
17
|
+
return self.value
|
|
18
|
+
|
|
19
|
+
def raise_error(self) -> None:
|
|
20
|
+
raise ValueError("boom")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_free_port() -> int:
|
|
24
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
25
|
+
sock.bind(("127.0.0.1", 0))
|
|
26
|
+
return sock.getsockname()[1]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def start_server(service: ExampleService, port: int, threaded: bool):
|
|
30
|
+
server = RPCServer(service, port=port, threaded=threaded)
|
|
31
|
+
if threaded:
|
|
32
|
+
server.start()
|
|
33
|
+
server_thread = server.thread
|
|
34
|
+
else:
|
|
35
|
+
server_thread = threading.Thread(target=server.start, daemon=True)
|
|
36
|
+
server_thread.start()
|
|
37
|
+
time.sleep(0.05)
|
|
38
|
+
return server, server_thread
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize("threaded", [True, False])
|
|
42
|
+
def test_rpc_server_start_stop(threaded):
|
|
43
|
+
port = get_free_port()
|
|
44
|
+
service = ExampleService()
|
|
45
|
+
server, server_thread = start_server(service, port, threaded)
|
|
46
|
+
try:
|
|
47
|
+
if threaded:
|
|
48
|
+
assert server.thread is not None
|
|
49
|
+
assert server.thread.is_alive()
|
|
50
|
+
server.stop()
|
|
51
|
+
assert server.thread is None
|
|
52
|
+
else:
|
|
53
|
+
assert server_thread is not None
|
|
54
|
+
server.stop()
|
|
55
|
+
server_thread.join(timeout=1)
|
|
56
|
+
assert not server_thread.is_alive()
|
|
57
|
+
finally:
|
|
58
|
+
if threaded:
|
|
59
|
+
if server.thread is not None:
|
|
60
|
+
server.stop()
|
|
61
|
+
else:
|
|
62
|
+
if server_thread is not None and server_thread.is_alive():
|
|
63
|
+
server.stop()
|
|
64
|
+
server_thread.join(timeout=1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_rpc_client_connect():
|
|
68
|
+
port = get_free_port()
|
|
69
|
+
service = ExampleService()
|
|
70
|
+
server, server_thread = start_server(service, port, threaded=True)
|
|
71
|
+
try:
|
|
72
|
+
client = RPCClient("127.0.0.1", port=port)
|
|
73
|
+
assert "increment" in dir(client)
|
|
74
|
+
finally:
|
|
75
|
+
if server.thread is not None:
|
|
76
|
+
server.stop()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("threaded", [True, False])
|
|
80
|
+
def test_rpc_server_client_integration(threaded):
|
|
81
|
+
port = get_free_port()
|
|
82
|
+
service = ExampleService()
|
|
83
|
+
server, server_thread = start_server(service, port, threaded)
|
|
84
|
+
client = RPCClient("127.0.0.1", port=port)
|
|
85
|
+
try:
|
|
86
|
+
assert client.increment(5) == 5
|
|
87
|
+
assert client.value == 5
|
|
88
|
+
|
|
89
|
+
client.value = 42
|
|
90
|
+
assert client.value == 42
|
|
91
|
+
|
|
92
|
+
with pytest.raises(RPCException) as exc:
|
|
93
|
+
client.raise_error()
|
|
94
|
+
assert "ValueError" in str(exc.value)
|
|
95
|
+
|
|
96
|
+
stop_result = client.stop_server()
|
|
97
|
+
assert stop_result is True
|
|
98
|
+
finally:
|
|
99
|
+
if server_thread is not None:
|
|
100
|
+
server_thread.join(timeout=1)
|
|
101
|
+
if threaded:
|
|
102
|
+
if server.thread is not None:
|
|
103
|
+
server.stop()
|
|
104
|
+
else:
|
|
105
|
+
if server_thread is not None and server_thread.is_alive():
|
|
106
|
+
server.stop()
|
|
107
|
+
server_thread.join(timeout=1)
|