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 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
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ pyzmq>=25.1
2
+
3
+ [test]
4
+ pytest>=7
@@ -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)