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 +16 -0
- commlink/publisher.py +48 -0
- commlink/py.typed +0 -0
- commlink/rpc_client.py +138 -0
- commlink/rpc_server.py +149 -0
- commlink/subscriber.py +70 -0
- commlink-0.1.1.dist-info/METADATA +138 -0
- commlink-0.1.1.dist-info/RECORD +11 -0
- commlink-0.1.1.dist-info/WHEEL +5 -0
- commlink-0.1.1.dist-info/licenses/LICENSE +21 -0
- commlink-0.1.1.dist-info/top_level.txt +1 -0
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,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
|