intersect-sdk 0.6.1a1__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.
- intersect_sdk/__init__.py +64 -0
- intersect_sdk/_internal/__init__.py +4 -0
- intersect_sdk/_internal/compression.py.tmp +38 -0
- intersect_sdk/_internal/constants.py +10 -0
- intersect_sdk/_internal/control_plane/__init__.py +0 -0
- intersect_sdk/_internal/control_plane/brokers/__init__.py +0 -0
- intersect_sdk/_internal/control_plane/brokers/amqp_client.py +280 -0
- intersect_sdk/_internal/control_plane/brokers/broker_client.py +58 -0
- intersect_sdk/_internal/control_plane/brokers/mqtt_client.py +154 -0
- intersect_sdk/_internal/control_plane/control_plane_manager.py +147 -0
- intersect_sdk/_internal/control_plane/discovery_service.py +40 -0
- intersect_sdk/_internal/data_plane/__init__.py +0 -0
- intersect_sdk/_internal/data_plane/data_plane_manager.py +102 -0
- intersect_sdk/_internal/data_plane/minio_utils.py +149 -0
- intersect_sdk/_internal/event_metadata.py +60 -0
- intersect_sdk/_internal/exceptions.py +17 -0
- intersect_sdk/_internal/function_metadata.py +27 -0
- intersect_sdk/_internal/interfaces.py +20 -0
- intersect_sdk/_internal/logger.py +3 -0
- intersect_sdk/_internal/messages/__init__.py +0 -0
- intersect_sdk/_internal/messages/event.py +157 -0
- intersect_sdk/_internal/messages/lifecycle.py +174 -0
- intersect_sdk/_internal/messages/userspace.py +171 -0
- intersect_sdk/_internal/pydantic_schema_generator.py +504 -0
- intersect_sdk/_internal/schema.py +558 -0
- intersect_sdk/_internal/stoppable_thread.py +19 -0
- intersect_sdk/_internal/utils.py +32 -0
- intersect_sdk/_internal/version_resolver.py +58 -0
- intersect_sdk/app_lifecycle.py +147 -0
- intersect_sdk/capability/__init__.py +14 -0
- intersect_sdk/capability/base.py +101 -0
- intersect_sdk/client.py +443 -0
- intersect_sdk/client_callback_definitions.py +154 -0
- intersect_sdk/config/__init__.py +20 -0
- intersect_sdk/config/client.py +52 -0
- intersect_sdk/config/service.py +42 -0
- intersect_sdk/config/shared.py +168 -0
- intersect_sdk/constants.py +9 -0
- intersect_sdk/core_definitions.py +37 -0
- intersect_sdk/py.typed +0 -0
- intersect_sdk/schema.py +77 -0
- intersect_sdk/service.py +607 -0
- intersect_sdk/service_definitions.py +262 -0
- intersect_sdk/version.py +15 -0
- intersect_sdk-0.6.1a1.dist-info/METADATA +39 -0
- intersect_sdk-0.6.1a1.dist-info/RECORD +47 -0
- intersect_sdk-0.6.1a1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The root module contains the intended public API for users of the INTERSECT-SDK.
|
|
2
|
+
|
|
3
|
+
Users should not need to import anything outside of the root.
|
|
4
|
+
|
|
5
|
+
In general, most breaking changes on version updates will relate to:
|
|
6
|
+
- Configuration classes (both adding and removing new config models). These configuration classes are relevant to the next point.
|
|
7
|
+
- When a new data service is integrated into INTERSECT, ALL adapters will need to update to support this data service, which will include new dependencies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .app_lifecycle import default_intersect_lifecycle_loop
|
|
11
|
+
from .capability.base import IntersectBaseCapabilityImplementation
|
|
12
|
+
from .client import IntersectClient
|
|
13
|
+
from .client_callback_definitions import (
|
|
14
|
+
INTERSECT_CLIENT_EVENT_CALLBACK_TYPE,
|
|
15
|
+
INTERSECT_CLIENT_RESPONSE_CALLBACK_TYPE,
|
|
16
|
+
INTERSECT_JSON_VALUE,
|
|
17
|
+
IntersectClientCallback,
|
|
18
|
+
IntersectClientMessageParams,
|
|
19
|
+
)
|
|
20
|
+
from .config.client import IntersectClientConfig
|
|
21
|
+
from .config.service import IntersectServiceConfig
|
|
22
|
+
from .config.shared import (
|
|
23
|
+
ControlPlaneConfig,
|
|
24
|
+
DataStoreConfig,
|
|
25
|
+
DataStoreConfigMap,
|
|
26
|
+
HierarchyConfig,
|
|
27
|
+
)
|
|
28
|
+
from .core_definitions import IntersectDataHandler, IntersectMimeType
|
|
29
|
+
from .schema import get_schema_from_capability_implementation
|
|
30
|
+
from .service import IntersectService
|
|
31
|
+
from .service_definitions import (
|
|
32
|
+
IntersectEventDefinition,
|
|
33
|
+
intersect_event,
|
|
34
|
+
intersect_message,
|
|
35
|
+
intersect_status,
|
|
36
|
+
)
|
|
37
|
+
from .version import __version__, version_info
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
'IntersectDataHandler',
|
|
41
|
+
'IntersectEventDefinition',
|
|
42
|
+
'IntersectMimeType',
|
|
43
|
+
'intersect_event',
|
|
44
|
+
'intersect_message',
|
|
45
|
+
'intersect_status',
|
|
46
|
+
'get_schema_from_capability_implementation',
|
|
47
|
+
'IntersectService',
|
|
48
|
+
'IntersectClient',
|
|
49
|
+
'IntersectClientCallback',
|
|
50
|
+
'IntersectClientMessageParams',
|
|
51
|
+
'INTERSECT_CLIENT_RESPONSE_CALLBACK_TYPE',
|
|
52
|
+
'INTERSECT_CLIENT_EVENT_CALLBACK_TYPE',
|
|
53
|
+
'INTERSECT_JSON_VALUE',
|
|
54
|
+
'IntersectBaseCapabilityImplementation',
|
|
55
|
+
'default_intersect_lifecycle_loop',
|
|
56
|
+
'IntersectClientConfig',
|
|
57
|
+
'IntersectServiceConfig',
|
|
58
|
+
'HierarchyConfig',
|
|
59
|
+
'ControlPlaneConfig',
|
|
60
|
+
'DataStoreConfig',
|
|
61
|
+
'DataStoreConfigMap',
|
|
62
|
+
'__version__',
|
|
63
|
+
'version_info',
|
|
64
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Compression logic used inside INTERSECT.
|
|
2
|
+
|
|
3
|
+
Our current compression algorithm is Brotli - see https://stackoverflow.com/a/59255343 for a fairly comprehensive overview of serialization formats compatible with generic JSON.
|
|
4
|
+
Zstd is worth considering for instances where data isn't being persisted for long, as it is generally faster than Brotli (especially for decompression) - however, Zstd requires multithreading to maximize its potential.
|
|
5
|
+
|
|
6
|
+
Currently, we will ALWAYS compress the following:
|
|
7
|
+
- The schemas sent as part of the lifecycle message
|
|
8
|
+
- The PAYLOADS of UserspaceMessages *if the data itself is being sent in UserspaceMessages*
|
|
9
|
+
|
|
10
|
+
We do NOT want to compress message headers, though.
|
|
11
|
+
|
|
12
|
+
MINIO should always handle compression itself, see https://min.io/docs/minio/linux/administration/object-management/data-compression.html for details.
|
|
13
|
+
|
|
14
|
+
TODO - how should the data API handle compression?
|
|
15
|
+
|
|
16
|
+
TODO - We should consider NOT compressing audio, video, image, or any data which is already compressed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import brotli
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def intersect_compress(message: bytes) -> bytes:
|
|
23
|
+
"""Compress MESSAGE using the compression algorithm, and return compression result.
|
|
24
|
+
|
|
25
|
+
Current compression algorithm is Brotli with the highest level quality.
|
|
26
|
+
"""
|
|
27
|
+
return brotli.compress(message, quality=11) # type: ignore[no-any-return]
|
|
28
|
+
|
|
29
|
+
def intersect_decompress(compressed: bytes) -> bytes:
|
|
30
|
+
"""Decompress COMPRESSED using the decompression algorithm, and return decompressed message.
|
|
31
|
+
|
|
32
|
+
Current decompression algorithm is Brotli.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
return brotli.decompress(compressed) # type: ignore[no-any-return]
|
|
36
|
+
except brotli.error:
|
|
37
|
+
# if the parameters weren't compressed with Brotli, just return the input
|
|
38
|
+
return compressed
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
BASE_RESPONSE_ATTR = '__is_intersect_response__'
|
|
2
|
+
BASE_STATUS_ATTR = '__is_intersect_status__'
|
|
3
|
+
BASE_EVENT_ATTR = '__is_intersect_event__'
|
|
4
|
+
# in theory, as long as the next attributes are unique, they can be any string
|
|
5
|
+
REQUEST_CONTENT = '__request_content_type__'
|
|
6
|
+
RESPONSE_CONTENT = '__response_content_type__'
|
|
7
|
+
RESPONSE_DATA = '__response_data_transfer_handler__'
|
|
8
|
+
STRICT_VALIDATION = '__strict_validation__'
|
|
9
|
+
SHUTDOWN_KEYS = '__ignore_message__'
|
|
10
|
+
EVENT_ATTR_KEY = '__intersect_sdk_events__'
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import threading
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import TYPE_CHECKING, Callable
|
|
7
|
+
|
|
8
|
+
import pika
|
|
9
|
+
from retrying import retry
|
|
10
|
+
|
|
11
|
+
from .broker_client import BrokerClient
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
from pika.channel import Channel
|
|
17
|
+
from pika.frame import Frame
|
|
18
|
+
from pika.spec import Basic, BasicProperties
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AMQPClient(BrokerClient):
|
|
22
|
+
"""Client for performing broker actions backed by a AMQP broker.
|
|
23
|
+
|
|
24
|
+
NOTE: Currently, thread safety has been attempted, but may not be guaranteed
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
id: A string representation of the client's UUID.
|
|
28
|
+
_connection_params: connection information to the AMQP broker (includes credentials)
|
|
29
|
+
_publish_connection: AMQP connection dedicated to publishing messages
|
|
30
|
+
_consume_connection: AMQP connection dedicated to consuming messages
|
|
31
|
+
_topics_to_handlers: Dictionary of string topic names to lists of
|
|
32
|
+
Callables to invoke for messages on that topic.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
host: str,
|
|
38
|
+
port: int,
|
|
39
|
+
username: str,
|
|
40
|
+
password: str,
|
|
41
|
+
topics_to_handlers: Callable[[], defaultdict[str, set[Callable[[bytes], None]]]],
|
|
42
|
+
uid: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""The default constructor.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
host: String for hostname of AMQP broker
|
|
48
|
+
port: port number of AMQP broker
|
|
49
|
+
username: username credentials for AMQP broker
|
|
50
|
+
password: password credentials for AMQP broker
|
|
51
|
+
topics_to_handlers: callback function which gets the topic to handler map from the channel manager
|
|
52
|
+
uid: String for the client's UUID.
|
|
53
|
+
"""
|
|
54
|
+
self.uid = uid if uid else str(uuid.uuid4())
|
|
55
|
+
|
|
56
|
+
self._connection_params = pika.ConnectionParameters(
|
|
57
|
+
host=host,
|
|
58
|
+
port=port,
|
|
59
|
+
virtual_host='/',
|
|
60
|
+
credentials=pika.PlainCredentials(username, password),
|
|
61
|
+
blocked_connection_timeout=1,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# The pika connection to the broker
|
|
65
|
+
self._publish_connection: pika.adapters.BlockingConnection = None
|
|
66
|
+
self._consume_connection: pika.adapters.SelectConnection = None
|
|
67
|
+
self._consume_connection_ready_event = threading.Event()
|
|
68
|
+
self._consume_subscription_ready_event = threading.Event()
|
|
69
|
+
|
|
70
|
+
# Callback to the topics_to_handler list inside of
|
|
71
|
+
self._topics_to_handlers = topics_to_handlers
|
|
72
|
+
# mapping of topics to callables which can unsubscribe from the topic
|
|
73
|
+
self._topics_to_channel_cancel_callbacks: dict[str, Callable[[], None]] = {}
|
|
74
|
+
self._consumer_thread = None
|
|
75
|
+
|
|
76
|
+
@retry(
|
|
77
|
+
stop_max_attempt_number=5,
|
|
78
|
+
wait_exponential_multiplier=1000,
|
|
79
|
+
wait_exponential_max=60000,
|
|
80
|
+
)
|
|
81
|
+
def connect(self) -> None:
|
|
82
|
+
"""Connect to the defined broker.
|
|
83
|
+
|
|
84
|
+
Try to connect to the broker, performing exponential backoff if connection fails.
|
|
85
|
+
"""
|
|
86
|
+
# need deamon=True otherwise if tests fails it hangs trying to acquire lock
|
|
87
|
+
self.thread = threading.Thread(target=self._start_consuming, daemon=True)
|
|
88
|
+
self.thread.start()
|
|
89
|
+
self._publish_connection = pika.adapters.BlockingConnection(self._connection_params)
|
|
90
|
+
self._consume_connection_ready_event.wait(timeout=5)
|
|
91
|
+
|
|
92
|
+
def disconnect(self) -> None:
|
|
93
|
+
"""Close all connections.
|
|
94
|
+
|
|
95
|
+
Close both the public and consume connections and stop the consuming thread.
|
|
96
|
+
"""
|
|
97
|
+
self._publish_connection.close()
|
|
98
|
+
self._publish_connection = None
|
|
99
|
+
self._consume_connection.ioloop.add_callback_threadsafe(self._close_consume_connection)
|
|
100
|
+
# as soon as connection is closed, the ioloop.stop will be
|
|
101
|
+
# called which in turn will terminate the consuming thread
|
|
102
|
+
self.thread.join(5)
|
|
103
|
+
self._consume_connection = None
|
|
104
|
+
|
|
105
|
+
def is_connected(self) -> bool:
|
|
106
|
+
"""Check if there is an active connection to the broker.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
A boolean. True if there is a connection, False if not.
|
|
110
|
+
"""
|
|
111
|
+
# We are connected to the broker if either the publish or consume connections is open
|
|
112
|
+
return (self._publish_connection is not None and self._publish_connection.is_open) or (
|
|
113
|
+
self._consume_connection is not None and self._consume_connection.is_open
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def publish(self, topic: str, payload: bytes) -> None:
|
|
117
|
+
"""Publish the given message.
|
|
118
|
+
|
|
119
|
+
Publish payload with the pre-existing connection (via connect()) on topic.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
topic: The topic on which to publish the message as a string
|
|
123
|
+
payload: The message to publish, as raw bytes.
|
|
124
|
+
"""
|
|
125
|
+
channel = self._publish_connection.channel()
|
|
126
|
+
channel.exchange_declare(topic, exchange_type='fanout', durable=True)
|
|
127
|
+
# this will send the message to topic exchange and distribute to all
|
|
128
|
+
# queues that subscribed to it
|
|
129
|
+
channel.basic_publish(
|
|
130
|
+
exchange=topic,
|
|
131
|
+
routing_key=topic,
|
|
132
|
+
body=payload,
|
|
133
|
+
properties=pika.BasicProperties(content_type='text/plain'),
|
|
134
|
+
)
|
|
135
|
+
channel.close()
|
|
136
|
+
|
|
137
|
+
def subscribe(self, topic: str) -> None:
|
|
138
|
+
self._consume_subscription_ready_event.clear()
|
|
139
|
+
self._subscribe_to_queue(topic)
|
|
140
|
+
self._consume_subscription_ready_event.wait()
|
|
141
|
+
|
|
142
|
+
def unsubscribe(self, topic: str) -> None:
|
|
143
|
+
old_channel = self._topics_to_channel_cancel_callbacks.get(topic, None)
|
|
144
|
+
if old_channel:
|
|
145
|
+
old_channel()
|
|
146
|
+
del self._topics_to_channel_cancel_callbacks[topic]
|
|
147
|
+
|
|
148
|
+
def _start_consuming(self) -> None:
|
|
149
|
+
"""Start consuming messages from broker.
|
|
150
|
+
|
|
151
|
+
Open the consuming connection and start its io loop.
|
|
152
|
+
"""
|
|
153
|
+
self._consume_connection = pika.adapters.SelectConnection(
|
|
154
|
+
parameters=self._connection_params,
|
|
155
|
+
on_close_callback=(
|
|
156
|
+
lambda _connection, _exception: self._consume_connection.ioloop.stop()
|
|
157
|
+
),
|
|
158
|
+
on_open_callback=lambda _connection: self._consume_connection_ready_event.set(),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
self._consume_connection.ioloop.start()
|
|
162
|
+
|
|
163
|
+
def _subscribe_to_queue(self, topic: str) -> None:
|
|
164
|
+
"""Start consuming from the given topic.
|
|
165
|
+
|
|
166
|
+
Declares the correct exchange and queue on the broker if needed, then starts
|
|
167
|
+
consuming messages on that queue.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
topic: Name of the topic on the broker to subscribe to as a string.
|
|
171
|
+
"""
|
|
172
|
+
# in consumer thread: open channel-> declare queue (need that if we starte
|
|
173
|
+
# consuming before message is published (no queue yet)) -> start consuming
|
|
174
|
+
cb = functools.partial(self._open_channel, topic=topic)
|
|
175
|
+
self._consume_connection.ioloop.add_callback_threadsafe(cb)
|
|
176
|
+
|
|
177
|
+
def _open_channel(self, topic: str) -> None:
|
|
178
|
+
"""Open a channel for the given topic.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
topic: The topic to open a channel for as a string.
|
|
182
|
+
"""
|
|
183
|
+
cb = functools.partial(self._on_channel_open, topic=topic)
|
|
184
|
+
self._consume_connection.channel(on_open_callback=cb)
|
|
185
|
+
|
|
186
|
+
def _on_channel_open(self, channel: Channel, topic: str) -> None:
|
|
187
|
+
"""Create an exchange on the broker.
|
|
188
|
+
|
|
189
|
+
Used as a listener on channel open.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
channel: The Channel being instantiated.
|
|
193
|
+
topic: The string name for the Channel on the broker.
|
|
194
|
+
"""
|
|
195
|
+
cb = functools.partial(self._on_exchange_declareok, channel=channel, topic=topic)
|
|
196
|
+
channel.exchange_declare(exchange=topic, exchange_type='fanout', durable=True, callback=cb)
|
|
197
|
+
|
|
198
|
+
def _on_exchange_declareok(self, _unused_frame: Frame, channel: Channel, topic: str) -> None:
|
|
199
|
+
"""Create a queue on the broker.
|
|
200
|
+
|
|
201
|
+
Used as a listener on exchange declaration.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
_unused_frame: Object from pika. Ignored.
|
|
205
|
+
channel: The Channel being instantiated.
|
|
206
|
+
topic: The string name for the Channel on the broker.
|
|
207
|
+
"""
|
|
208
|
+
cb = functools.partial(self._on_queue_declareok, channel=channel, topic=topic)
|
|
209
|
+
channel.queue_declare(queue=topic + '.' + self.uid, durable=True, callback=cb)
|
|
210
|
+
|
|
211
|
+
def _on_queue_declareok(self, _unused_frame: Frame, channel: Channel, topic: str) -> None:
|
|
212
|
+
"""Begins listening on the given queue.
|
|
213
|
+
|
|
214
|
+
Used as a listener on queue declaration.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
_unused_frame: Object from pika. Ignored.
|
|
218
|
+
channel: The Channel being instantiated.
|
|
219
|
+
topic: The string name for the Channel on the broker.
|
|
220
|
+
"""
|
|
221
|
+
cb = functools.partial(self._on_queue_bindok, channel=channel, topic=topic)
|
|
222
|
+
channel.queue_bind(topic + '.' + self.uid, topic, routing_key=topic, callback=cb)
|
|
223
|
+
|
|
224
|
+
def _on_queue_bindok(self, _unused_frame: Frame, channel: Channel, topic: str) -> None:
|
|
225
|
+
"""Consumes a message from the given channel.
|
|
226
|
+
|
|
227
|
+
Used as a listener on queue binding.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
_unused_frame: Object from pika. Ignored.
|
|
231
|
+
channel: The Channel being instantiated.
|
|
232
|
+
topic: The string name for the Channel on the broker.
|
|
233
|
+
"""
|
|
234
|
+
cb = functools.partial(self._on_consume_ok)
|
|
235
|
+
consumer_tag = channel.basic_consume(
|
|
236
|
+
queue=topic + '.' + self.uid,
|
|
237
|
+
auto_ack=True,
|
|
238
|
+
on_message_callback=self._consume_message,
|
|
239
|
+
callback=cb,
|
|
240
|
+
)
|
|
241
|
+
self._topics_to_channel_cancel_callbacks[topic] = lambda: channel.basic_cancel(consumer_tag)
|
|
242
|
+
|
|
243
|
+
def _on_consume_ok(self, _unused_frame: Frame) -> None:
|
|
244
|
+
"""Sets the consume subscription ready even.
|
|
245
|
+
|
|
246
|
+
Used as a listener on consuming an initial message on a channel.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
_unused_frame: Object from pika. Ignored.
|
|
250
|
+
topic: The string name for the Channel on the broker.
|
|
251
|
+
"""
|
|
252
|
+
self._consume_subscription_ready_event.set()
|
|
253
|
+
|
|
254
|
+
def _consume_message(
|
|
255
|
+
self,
|
|
256
|
+
_unused_channel: Channel,
|
|
257
|
+
basic_deliver: Basic.Deliver,
|
|
258
|
+
_properties: BasicProperties,
|
|
259
|
+
body: bytes,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Handles incoming messages.
|
|
262
|
+
|
|
263
|
+
Looks up all handlers for the topic and delegates message handling to them.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
_unused_channel: The Pika channel the message was received on. Ignored
|
|
267
|
+
basic_deliver: Contains internal Pika delivery information - i.e. the routing key.
|
|
268
|
+
_properties: Object from the Pika call. Ignored.
|
|
269
|
+
body: the pika message to be handled.
|
|
270
|
+
"""
|
|
271
|
+
for handler in self._topics_to_handlers().get(basic_deliver.routing_key, []):
|
|
272
|
+
handler(body)
|
|
273
|
+
|
|
274
|
+
def _close_consume_connection(self) -> None:
|
|
275
|
+
"""Closes the consume connection.
|
|
276
|
+
|
|
277
|
+
Used as a listener on the connection loop to safely shutdown.
|
|
278
|
+
"""
|
|
279
|
+
self._consume_connection.close()
|
|
280
|
+
self._topics_to_channel_cancel_callbacks.clear()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BrokerClient(ABC):
|
|
5
|
+
"""Abstract definition of a Broker Client.
|
|
6
|
+
|
|
7
|
+
Will abstractly manage interaction between implementation specific message broker
|
|
8
|
+
calls and higher level pub/sub features.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def connect(self) -> None:
|
|
13
|
+
"""Connect to the defined broker. Credentials should be cached in the constructor."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def disconnect(self) -> None:
|
|
18
|
+
"""Disconnect from the defined broker."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def is_connected(self) -> bool:
|
|
23
|
+
"""Checks if there is an active connection to the broker.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A boolean. True if there is a connection, False if not.
|
|
27
|
+
"""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def publish(self, topic: str, payload: bytes) -> None:
|
|
32
|
+
"""Publishes the given message.
|
|
33
|
+
|
|
34
|
+
Publish payload with the pre-existing connection (via connect()) on topic.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
topic: The topic on which to publish the message as a string.
|
|
38
|
+
payload: The message to publish, as raw bytes.
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def subscribe(self, topic: str) -> None:
|
|
44
|
+
"""Subscribe to a topic over the pre-existing connection (via connect()).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
topic: Topic to subscribe to.
|
|
48
|
+
"""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def unsubscribe(self, topic: str) -> None:
|
|
53
|
+
"""Unsubscribe from a topic over the pre-existing connection (via connect()).
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
topic: Topic to unsubscribe from.
|
|
57
|
+
"""
|
|
58
|
+
...
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
5
|
+
|
|
6
|
+
import paho.mqtt.client as paho_client
|
|
7
|
+
from retrying import retry
|
|
8
|
+
|
|
9
|
+
from .broker_client import BrokerClient
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MQTTClient(BrokerClient):
|
|
16
|
+
"""Client for performing broker actions backed by a MQTT broker.
|
|
17
|
+
|
|
18
|
+
Note that this class may not be thread safe, see https://github.com/eclipse/paho.mqtt.python/issues/358#issuecomment-1880819505
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
uid: String defining this client's unique ID in the broker
|
|
22
|
+
host: hostname of MQTT broker
|
|
23
|
+
port: port of MQTT broker
|
|
24
|
+
_connection: Paho Client used to interact with the broker
|
|
25
|
+
_connected: current state of whether or not we're connected to the broker (boolean)
|
|
26
|
+
_topics_to_handlers: Dictionary of string topic names to lists of
|
|
27
|
+
Callables to invoke for messages on that topic.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
host: str,
|
|
33
|
+
port: int,
|
|
34
|
+
username: str,
|
|
35
|
+
password: str,
|
|
36
|
+
topics_to_handlers: Callable[[], defaultdict[str, set[Callable[[bytes], None]]]],
|
|
37
|
+
uid: str | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""The default constructor.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
host: String for hostname of MQTT broker
|
|
43
|
+
port: port number of MQTT broker
|
|
44
|
+
username: username credentials for MQTT broker
|
|
45
|
+
password: password credentials for MQTT broker
|
|
46
|
+
topics_to_handlers: callback function which gets the topic to handler map from the channel manager
|
|
47
|
+
uid: A string representing the unique id to identify the client.
|
|
48
|
+
"""
|
|
49
|
+
# Unique id for the MQTT broker to associate this client with
|
|
50
|
+
self.uid = uid if uid else str(uuid.uuid4())
|
|
51
|
+
self.host = host
|
|
52
|
+
self.port = port
|
|
53
|
+
|
|
54
|
+
# Create a client to connect to RabbitMQ
|
|
55
|
+
# TODO clean_session param is ONLY for MQTT v3 here
|
|
56
|
+
self._connection = paho_client.Client(client_id=self.uid, clean_session=False)
|
|
57
|
+
self._connection.username_pw_set(username=username, password=password)
|
|
58
|
+
|
|
59
|
+
# Whether the connection is currently active
|
|
60
|
+
self._connected = False
|
|
61
|
+
self._topics_to_handlers = topics_to_handlers
|
|
62
|
+
|
|
63
|
+
# MQTT callback functions
|
|
64
|
+
self._connection.on_connect = self._set_connection_status
|
|
65
|
+
self._connection.on_disconnect = self._handle_disconnect
|
|
66
|
+
self._connection.on_message = self._on_message
|
|
67
|
+
|
|
68
|
+
@retry(stop_max_attempt_number=5, wait_exponential_multiplier=1000, wait_exponential_max=60000)
|
|
69
|
+
def connect(self) -> None:
|
|
70
|
+
"""Connect to the defined broker."""
|
|
71
|
+
# Create a client to connect to RabbitMQ
|
|
72
|
+
# TODO MQTT v5 implementations should set clean_start to NEVER here
|
|
73
|
+
self._connection.connect(self.host, self.port, 60)
|
|
74
|
+
self._connection.loop_start()
|
|
75
|
+
|
|
76
|
+
def disconnect(self) -> None:
|
|
77
|
+
"""Disconnect from the broker."""
|
|
78
|
+
self._connection.disconnect()
|
|
79
|
+
self._connection.loop_stop()
|
|
80
|
+
|
|
81
|
+
def is_connected(self) -> bool:
|
|
82
|
+
"""Check if there is an active connection to the broker.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A boolean. True if there is a connection, False if not.
|
|
86
|
+
"""
|
|
87
|
+
return self._connected
|
|
88
|
+
|
|
89
|
+
def publish(self, topic: str, payload: bytes) -> None:
|
|
90
|
+
"""Publish the given message.
|
|
91
|
+
|
|
92
|
+
Publish payload with the pre-existing connection (via connect()) on topic.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
topic: The topic on which to publish the message as a string.
|
|
96
|
+
payload: The message to publish, as raw bytes.
|
|
97
|
+
"""
|
|
98
|
+
self._connection.publish(topic, payload, qos=2)
|
|
99
|
+
|
|
100
|
+
def subscribe(self, topic: str) -> None:
|
|
101
|
+
"""Subscribe to a topic over the pre-existing connection (via connect()).
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
topic: Topic to subscribe to.
|
|
105
|
+
"""
|
|
106
|
+
self._connection.subscribe(topic, qos=2)
|
|
107
|
+
|
|
108
|
+
def unsubscribe(self, topic: str) -> None:
|
|
109
|
+
"""Unsubscribe from a topic over the pre-existing connection.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
topic: Topic to unsubscribe from.
|
|
113
|
+
"""
|
|
114
|
+
self._connection.unsubscribe(topic)
|
|
115
|
+
|
|
116
|
+
def _on_message(
|
|
117
|
+
self, _client: paho_client.Client, _userdata: Any, message: paho_client.MQTTMessage
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Handle a message from the MQTT server.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
_client: the Paho client
|
|
123
|
+
_userdata: MQTT user data
|
|
124
|
+
message: MQTT message
|
|
125
|
+
"""
|
|
126
|
+
for handler in self._topics_to_handlers().get(message.topic, []):
|
|
127
|
+
handler(message.payload)
|
|
128
|
+
|
|
129
|
+
def _handle_disconnect(self, _client: paho_client.Client, _userdata: Any, _rc: int) -> None:
|
|
130
|
+
"""Handle a disconnection from the MQTT server.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
_client: The Paho client.
|
|
134
|
+
_userdata: MQTT user data.
|
|
135
|
+
rc: MQTT return code as an integer.
|
|
136
|
+
"""
|
|
137
|
+
self._connected = False
|
|
138
|
+
|
|
139
|
+
def _set_connection_status(
|
|
140
|
+
self, _client: paho_client.Client, _userdata: Any, _flags: dict[str, Any], rc: int
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Set the connection status in response to the result of a Paho connection attempt.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
client: The Paho MQTT client.
|
|
146
|
+
userdata: The MQTT userdata.
|
|
147
|
+
flags: List of MQTT connection flags.
|
|
148
|
+
rc: The MQTT return code as an int.
|
|
149
|
+
"""
|
|
150
|
+
# Return code 0 means connection was successful
|
|
151
|
+
if rc == 0:
|
|
152
|
+
self._connected = True
|
|
153
|
+
else:
|
|
154
|
+
self._connected = False
|