commlink 0.1.1__py3-none-any.whl

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/__init__.py 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"
commlink/publisher.py ADDED
@@ -0,0 +1,48 @@
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
+
36
+ if __name__ == "__main__":
37
+ # Example usage:
38
+ import numpy as np
39
+
40
+ def np_array_serializer(arr):
41
+ return arr.tobytes()
42
+
43
+ if __name__ == "__main__":
44
+ pub = Publisher("*", port=1234)
45
+ pub.set_serializer(np_array_serializer)
46
+
47
+ while True:
48
+ pub.publish("test", np.random.rand(100, 100))
commlink/py.typed ADDED
File without changes
commlink/rpc_client.py ADDED
@@ -0,0 +1,138 @@
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
+ return stopped
119
+
120
+
121
+ if __name__ == "__main__":
122
+ import time
123
+ import numpy as np
124
+
125
+ Hello = RPCClient("localhost", port=1234)
126
+ arr = []
127
+ for i in range(100):
128
+ start = time.time()
129
+ print(Hello.hello())
130
+ arr.append(time.time() - start)
131
+ print("Total time:", sum(arr))
132
+ print(sum(arr) / len(arr))
133
+ print(len(arr) / sum(arr))
134
+ print(Hello.abc)
135
+ Hello.new_attr = 456
136
+ print(Hello.new_attr)
137
+ print(dir(Hello))
138
+ print(Hello.bad())
commlink/rpc_server.py ADDED
@@ -0,0 +1,149 @@
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
+ if threaded:
20
+ self.thread = threading.Thread(target=self.run)
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
+ message = self.socket.recv()
54
+ message = pickle.loads(message)
55
+ self._handle_message(message)
56
+ else:
57
+ while not self.stop_event:
58
+ try:
59
+ message = self.socket.recv(flags=zmq.NOBLOCK)
60
+ message = pickle.loads(message)
61
+ except zmq.Again:
62
+ time.sleep(0.001)
63
+ continue
64
+ self._handle_message(message)
65
+
66
+ def _is_callable(self, attr):
67
+ return hasattr(self.obj, attr) and callable(getattr(self.obj, attr))
68
+
69
+ def _handle_message(self, message):
70
+ """
71
+ Handles a dictionary of {
72
+ "req": str, # request type
73
+ "attr": str,
74
+ "args": list,
75
+ "kwargs": dict,
76
+ }
77
+ from the socket.
78
+ If req == "is_callable", return whether the attribute is callable.
79
+ If req == "get", return the attribute.
80
+ If the attribute is not found, return an error message.
81
+ If the attribute is callable, call with args and kwargs.
82
+ If there are any errors in the callable, return the pickled error
83
+ If the callable is found and there are no errors, return the pickled result.
84
+ If the attribute is not callable, return the attribute.
85
+ If req == "set", set the attribute to the value.
86
+ If req == "dir", return a list of attributes.
87
+ If req == "stop", stop the server.
88
+ """
89
+ if message["req"] == "is_callable":
90
+ result = self._is_callable(message["attr"])
91
+ self._send_result(result)
92
+ elif message["req"] == "get":
93
+ try:
94
+ attribute = getattr(self.obj, message["attr"])
95
+ args = message["args"]
96
+ kwargs = message["kwargs"]
97
+ if not callable(attribute):
98
+ self._send_result(attribute)
99
+ else:
100
+ result = attribute(*args, **kwargs)
101
+ self._send_result(result)
102
+ except Exception as e:
103
+ self._send_exception(e)
104
+ elif message["req"] == "set":
105
+ try:
106
+ setattr(self.obj, message["attr"], message["value"])
107
+ self._send_result(None)
108
+ except Exception as e:
109
+ self._send_exception(e)
110
+ elif message["req"] == "dir":
111
+ result = dir(self.obj)
112
+ self._send_result(result)
113
+ elif message["req"] == "stop":
114
+ self.stop()
115
+
116
+ def close(self):
117
+ self.socket.close()
118
+ self.context.term()
119
+
120
+ def start(self):
121
+ if self.threaded:
122
+ self.stop_event.clear()
123
+ self.thread.start()
124
+ else:
125
+ self.run()
126
+
127
+ def stop(self):
128
+ if self.threaded:
129
+ self.stop_event.set()
130
+ self.thread.join()
131
+ else:
132
+ self.stop_event = True
133
+ self.close()
134
+
135
+
136
+ if __name__ == "__main__":
137
+ import numpy as np
138
+ class HelloWorld:
139
+ def __init__(self):
140
+ self.abc = 123
141
+
142
+ def hello(self):
143
+ return np.random.randn(3, 224, 224)
144
+
145
+ def bad(self):
146
+ return 1 / 0
147
+
148
+ server = RPCServer(HelloWorld(), port=1234)
149
+ server.start()
commlink/subscriber.py ADDED
@@ -0,0 +1,70 @@
1
+ import zmq
2
+ import pickle
3
+ from typing import Iterable, Optional, Callable, Any
4
+
5
+
6
+ class Subscriber:
7
+ def __init__(
8
+ self,
9
+ host: str,
10
+ port: int = 5000,
11
+ topics: Optional[Iterable[str]] = None,
12
+ keep_old: bool = False,
13
+ ):
14
+ """
15
+ host: host to connect to
16
+ port: port to connect to
17
+ topics: optional iterable of topics to subscribe to.
18
+ If ``None``, subscribe to all topics.
19
+ keep_old: whether to keep old messages in the buffer.
20
+ Default False (only keep the latest message).
21
+ """
22
+ self.context = zmq.Context()
23
+ self.socket = self.context.socket(zmq.SUB)
24
+ self.deserializer = pickle.loads
25
+ if topics is not None:
26
+ for topic in topics:
27
+ if " " in topic:
28
+ raise ValueError("topic cannot contain spaces")
29
+ self.socket.setsockopt_string(zmq.SUBSCRIBE, topic)
30
+ if not keep_old:
31
+ self.socket.setsockopt(zmq.CONFLATE, 1)
32
+ self.socket.connect(f"tcp://{host}:{port}")
33
+
34
+ def set_deserializer(self, deserializer: Optional[Callable[[bytes], Any]]):
35
+ self.deserializer = deserializer or pickle.loads
36
+
37
+ def get(self) -> tuple[str, Any]:
38
+ """
39
+ Get a tuple of (topic, data) where data is deserialized using self.deserializer (default: pickle.loads). If no message is available, this will block until one is available.
40
+ """
41
+ msg = self.socket.recv()
42
+ topic = msg.split(b" ")[0].decode("utf-8")
43
+ data = msg[len(topic) + 1 :]
44
+ data_obj = self.deserializer(data)
45
+ return topic, data_obj
46
+
47
+ def stop(self):
48
+ """
49
+ Safely terminate the subscription and clean up the resources.
50
+ """
51
+ self.socket.close()
52
+ self.context.term()
53
+
54
+
55
+ if __name__ == "__main__":
56
+ # Example usage:
57
+ import cv2
58
+ import numpy as np
59
+
60
+ def np_array_deserializer(arr):
61
+ return np.frombuffer(arr, dtype=np.float64).reshape((100, 100))
62
+
63
+ sub = Subscriber("localhost", port=1234, topics=["test"])
64
+ sub.set_deserializer(np_array_deserializer)
65
+
66
+ while True:
67
+ topic, data = sub.get()
68
+ print(topic)
69
+ cv2.imshow("test", data)
70
+ cv2.waitKey(1)
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: commlink
3
+ Version: 0.1.1
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
+ from commlink import RPCServer
65
+
66
+
67
+ class TemperatureController:
68
+ def __init__(self):
69
+ self.target_celsius = 20
70
+
71
+ def get_reading(self):
72
+ """Pretend to talk to a sensor and return the current reading."""
73
+ return self.target_celsius
74
+
75
+ def setpoint(self, value):
76
+ self.target_celsius = value
77
+ return f"Set target temperature to {value}°C"
78
+
79
+
80
+ if __name__ == "__main__":
81
+ # Wrap the object with a one-line RPC server. The server runs in a background thread by default.
82
+ server = RPCServer(TemperatureController(), port=6000)
83
+ server.start()
84
+ server.thread.join() # Optional: keep the process alive while the server thread runs.
85
+ ```
86
+
87
+ ### Client
88
+
89
+ ```python
90
+ from commlink import RPCClient
91
+
92
+ # Instantiate the remote object locally – attribute access, setters, and method calls all proxy to the server.
93
+ controller = RPCClient("localhost", port=6000)
94
+
95
+ print(controller.get_reading()) # Call remote methods with any pickle-able arguments or return values.
96
+ print(controller.setpoint(25))
97
+
98
+ controller.target_celsius = 18 # Mutate attributes on the remote instance.
99
+ print(controller.target_celsius)
100
+
101
+ # When you're finished, politely stop the remote server.
102
+ controller.stop_server()
103
+ ```
104
+
105
+ ### RPC capabilities
106
+
107
+ * **Transparent calls** – Functions and methods execute remotely with arbitrary pickle-able arguments and return values.
108
+ * **Attribute access** – Reading or setting attributes forwards the operation to the remote object.
109
+ * **Drop-in adoption** – Wrap any pre-existing object with `RPCServer(obj, ...)` and obtain a live proxy by instantiating
110
+ `RPCClient(host, port)`.
111
+ * **Threaded by default** – `RPCServer` starts in a background thread so your host application can continue doing work or cleanly
112
+ manage lifecycle events.
113
+
114
+ ## Publisher/subscriber helpers
115
+
116
+ If you also need broadcast-style messaging, Commlink ships with simple ZeroMQ publishers and subscribers:
117
+
118
+ ```python
119
+ from commlink import Publisher, Subscriber
120
+
121
+ publisher = Publisher("*", port=5555)
122
+ subscriber = Subscriber("localhost", port=5555, topics=["updates"])
123
+
124
+ publisher.publish("updates", {"message": "Hello, world!"})
125
+ print(subscriber.get())
126
+ ```
127
+
128
+ ## Development
129
+
130
+ Run the automated test suite with:
131
+
132
+ ```bash
133
+ pytest
134
+ ```
135
+
136
+ ## License
137
+
138
+ Commlink is distributed under the terms of the [MIT License](./LICENSE).
@@ -0,0 +1,11 @@
1
+ commlink/__init__.py,sha256=3tivTOl6cENhsqde-suWICfd5smKY7N9fKGykwll_mo,363
2
+ commlink/publisher.py,sha256=SCcd0PzDg01Venx9k_Gscv4_s6noj_oGKwRQ03q4i_Y,1348
3
+ commlink/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ commlink/rpc_client.py,sha256=HyWh8GBD5METuJtF0a6jRPVK7MZq9KlE91Bzw0iLYr4,4342
5
+ commlink/rpc_server.py,sha256=hwbWYcca0x9nlYoCAn3_Sx1-IK7d9foBpGbfw-bLR7Q,4752
6
+ commlink/subscriber.py,sha256=K67CQjBPVipYmppA5KwtLd5_KtJY-Bz-rmCdyizT23c,2256
7
+ commlink-0.1.1.dist-info/licenses/LICENSE,sha256=uQ1aP6bBL-De6FbtpF-zfd_Z3NAnrgPpZOqmLJTYL38,1077
8
+ commlink-0.1.1.dist-info/METADATA,sha256=SbcqVN3HdYRMwaxEODyQxDjpFKcLBgYdmW20It3dbDI,5013
9
+ commlink-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ commlink-0.1.1.dist-info/top_level.txt,sha256=BR-bDZjiEe9vW1Zy6qKGI0LNJoUyvfAEyi0QFzvEBYQ,9
11
+ commlink-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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
+ commlink