intersect-sdk-common 0.9.2__tar.gz → 0.9.4__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.
Files changed (29) hide show
  1. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/PKG-INFO +2 -2
  2. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/README.md +1 -1
  3. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/pyproject.toml +5 -3
  4. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/brokers/amqp_client.py +39 -29
  5. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/brokers/broker_client.py +2 -1
  6. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/brokers/mqtt_client.py +18 -15
  7. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/control_plane_manager.py +31 -19
  8. intersect_sdk_common-0.9.4/src/intersect_sdk_common/control_plane/custom_binary_protocol.py +77 -0
  9. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/topic_handler.py +2 -2
  10. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/__init__.py +0 -0
  11. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/config.py +0 -0
  12. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/constants.py +0 -0
  13. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/__init__.py +0 -0
  14. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/brokers/__init__.py +0 -0
  15. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/definitions.py +0 -0
  16. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/messages/__init__.py +0 -0
  17. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/messages/event.py +0 -0
  18. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/messages/lifecycle.py +0 -0
  19. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/control_plane/messages/userspace.py +0 -0
  20. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/core_definitions.py +0 -0
  21. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/data_plane/__init__.py +0 -0
  22. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/data_plane/data_plane_manager.py +0 -0
  23. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/data_plane/minio_utils.py +0 -0
  24. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/exceptions.py +0 -0
  25. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/logger.py +0 -0
  26. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/py.typed +0 -0
  27. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/utils/__init__.py +0 -0
  28. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/utils/multi_flag_thread_event.py +0 -0
  29. {intersect_sdk_common-0.9.2 → intersect_sdk_common-0.9.4}/src/intersect_sdk_common/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: intersect-sdk-common
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Python SDK to interact with INTERSECT
5
5
  Keywords: intersect
6
6
  Author: Lance Drane, Marshall McDonnell, Seth Hitefield, Andrew Ayres, Gregory Cage, Jesse McGaha, Robert Smith, Gavin Wiggins, Michael Brim, Rick Archibald, Addi Malviya Thakur
@@ -48,5 +48,5 @@ The INTERSECT-SDK MUST follow semantic versioning, and MUST update its semantic
48
48
  ```bash
49
49
  uv venv .venv
50
50
  source .venv/bin/activate
51
- uv sync --locked --all-extras --all-groups
51
+ uv sync --all-extras --all-groups
52
52
  ```
@@ -28,5 +28,5 @@ The INTERSECT-SDK MUST follow semantic versioning, and MUST update its semantic
28
28
  ```bash
29
29
  uv venv .venv
30
30
  source .venv/bin/activate
31
- uv sync --locked --all-extras --all-groups
31
+ uv sync --all-extras --all-groups
32
32
  ```
@@ -18,7 +18,7 @@ readme = "README.md"
18
18
  license = { text = "BSD-3-Clause" }
19
19
  requires-python = ">=3.10,<4.0"
20
20
  keywords = ["intersect"]
21
- version = "0.9.2"
21
+ version = "0.9.4"
22
22
  classifiers = [
23
23
  "Programming Language :: Python :: 3",
24
24
  "Programming Language :: Python :: 3.10",
@@ -34,13 +34,15 @@ dependencies = [
34
34
  ]
35
35
 
36
36
  [dependency-groups]
37
- lint = [
37
+ dev = [
38
38
  "pre-commit>=3.3.1",
39
39
  "ruff==0.12.7",
40
40
  "mypy>=1.10.0",
41
41
  "codespell>=2.3.0",
42
+ "pytest>=7.3.2",
43
+ "pytest-cov>=4.1.0",
44
+ "httpretty>=1.1.4",
42
45
  ]
43
- test = ["pytest>=7.3.2", "pytest-cov>=4.1.0", "httpretty>=1.1.4"]
44
46
 
45
47
  [build-system]
46
48
  requires = ["uv_build>=0.10.2,<0.11.0"]
@@ -23,14 +23,12 @@ from ...utils.multi_flag_thread_event import MultiFlagThreadEvent
23
23
  from .broker_client import BrokerClient
24
24
 
25
25
  if TYPE_CHECKING:
26
- from collections.abc import Callable
27
-
28
26
  from pika.channel import Channel
29
27
  from pika.frame import Frame
30
28
  from pika.spec import Basic, BasicProperties
31
29
 
30
+ from ..control_plane_manager import ControlPlaneManager
32
31
  from ..definitions import MessageCallback
33
- from ..topic_handler import TopicHandler
34
32
 
35
33
 
36
34
  _AMQP_MAX_RETRIES = 10
@@ -95,8 +93,7 @@ class AMQPClient(BrokerClient):
95
93
  port: int,
96
94
  username: str,
97
95
  password: str,
98
- topics_to_handlers: Callable[[], dict[str, TopicHandler]],
99
- find_wildcard_handler: Callable[[str], TopicHandler | None],
96
+ control_plane_manager: ControlPlaneManager,
100
97
  is_root: bool,
101
98
  ) -> None:
102
99
  """The default constructor.
@@ -106,8 +103,7 @@ class AMQPClient(BrokerClient):
106
103
  port: port number of AMQP broker
107
104
  username: username credentials for AMQP broker
108
105
  password: password credentials for AMQP broker
109
- topics_to_handlers: callback function which gets the topic to handler map from the channel manager
110
- find_wildcard_handler: callback function which gets the wildcard topic handler from the channel manager
106
+ control_plane_manager: reference to the ControlPlaneManager instance, remember to ONLY use functions which do not mutate state
111
107
  is_root: Whether or not the client can configure exchanges and queues themselves (core services), or if this must be delegated to a Core Service (SDK Clients/Services)
112
108
  """
113
109
  self._connection_params = pika.ConnectionParameters(
@@ -132,11 +128,11 @@ class AMQPClient(BrokerClient):
132
128
  self._thread: threading.Thread | None = None
133
129
 
134
130
  # Callback to the topics_to_handler list inside of
135
- self._topics_to_handlers = topics_to_handlers
136
- self._find_wildcard_handler = find_wildcard_handler
131
+ self._control_plane_manager = control_plane_manager
137
132
 
138
133
  # mapping of topics to callables which can unsubscribe from the topic
139
134
  self._topics_to_consumer_tags: dict[str, _ConsumerTagInfo] = {}
135
+ """NOTE: separators for topics in this object is '.' , not '/' """
140
136
  self._consumer_tags_to_threads: dict[str, threading.Thread] = {}
141
137
 
142
138
  self._should_disconnect = False
@@ -227,17 +223,22 @@ class AMQPClient(BrokerClient):
227
223
  else:
228
224
  logger.error('Unable to publish message %s on topic %s', payload, topic)
229
225
 
230
- def subscribe(self, topic: str, persist: bool) -> None:
226
+ def subscribe(self, topic: str, persist: bool, queue_name: str) -> None:
231
227
  """Subscribe to a topic.
232
228
 
233
229
  topic: system-of-system hierarchy. In AMQP parlance this gets translated to the routing key.
234
230
  persist: If True, we will create an idempotent queue name which should persist
235
231
  even on broker or application shutdown. If False, we will allow the server to create a unique
236
232
  queue name, and the queue will be destroyed once the associated channel is closed.
233
+ queue_name: The name of the queue to subscribe to.
237
234
  """
238
235
  topic = _hierarchy_2_amqp(topic)
239
236
  cb = functools.partial(
240
- self._create_queue, channel=self._channel_in, topic=topic, persist=persist
237
+ self._create_queue,
238
+ channel=self._channel_in,
239
+ topic=topic,
240
+ persist=persist,
241
+ queue_name=queue_name,
241
242
  )
242
243
  self._connection.ioloop.add_callback_threadsafe(cb)
243
244
 
@@ -390,7 +391,7 @@ class AMQPClient(BrokerClient):
390
391
  def _on_input_channel_open(self, channel: Channel) -> None:
391
392
  channel_num = 1
392
393
  self._channel_in = channel
393
- # consumer channel flag can be set immediately
394
+ # consumer channel flag can be set immediately (NOTE: this means that code which simultaneously creates Clients and Services will need to manually wait, to ensure that each application has made the basic_consume call to the broker before messages are published)
394
395
  self._channel_flags.set_nth_flag(channel_num)
395
396
  cb_1 = functools.partial(self._on_channel_closed, channel_num=channel_num)
396
397
  self._channel_in.add_on_close_callback(cb_1)
@@ -403,17 +404,7 @@ class AMQPClient(BrokerClient):
403
404
  callback=cb_2,
404
405
  )
405
406
  else:
406
- for topic, topic_handler in self._topics_to_handlers().items():
407
- amqp_topic = _hierarchy_2_amqp(topic)
408
- cb = functools.partial(
409
- self._setup_basic_qos,
410
- None,
411
- channel=channel,
412
- topic=amqp_topic,
413
- persist=topic_handler.topic_persist,
414
- queue_name=topic_handler.queue_name,
415
- )
416
- self._connection.ioloop.add_callback_threadsafe(cb)
407
+ self._create_all_queues(channel)
417
408
 
418
409
  def _on_exchange_declareok(self, _unused_frame: Frame, channel: Channel) -> None:
419
410
  """Create a queue on the broker (called from AMQP).
@@ -425,7 +416,17 @@ class AMQPClient(BrokerClient):
425
416
  _unused_frame: response from declaring the exchange on the broker (irrelevant).
426
417
  channel: The Channel being instantiated.
427
418
  """
428
- for topic, topic_handler in self._topics_to_handlers().items():
419
+ self._create_all_queues(channel)
420
+
421
+ def _create_all_queues(self, channel: Channel) -> None:
422
+ """Create queues for all subscription channels on the broker.
423
+
424
+ This should only be called once we've verified that the exchange exists.
425
+
426
+ Args:
427
+ channel: The Channel being instantiated.
428
+ """
429
+ for topic, topic_handler in self._control_plane_manager.get_all_subscription_channels():
429
430
  amqp_topic = _hierarchy_2_amqp(topic)
430
431
  cb = functools.partial(
431
432
  self._create_queue,
@@ -456,6 +457,7 @@ class AMQPClient(BrokerClient):
456
457
  queue=queue_name
457
458
  if persist
458
459
  else '', # if we're transient, let the broker generate a name for us
460
+ passive=not self._is_root,
459
461
  durable=persist,
460
462
  exclusive=not persist, # transient queues can be exclusive
461
463
  callback=cb,
@@ -597,17 +599,25 @@ class AMQPClient(BrokerClient):
597
599
  channel.basic_ack(basic_deliver.delivery_tag)
598
600
  return
599
601
 
600
- tth_key = _amqp_2_hierarchy(basic_deliver.routing_key)
601
- topic_handler = self._topics_to_handlers().get(tth_key)
602
+ amqp_routing_key_rep = basic_deliver.routing_key
603
+ manager_routing_key_rep = _amqp_2_hierarchy(basic_deliver.routing_key)
604
+ topic_handler = self._control_plane_manager.get_non_wildcard_subscription_channels().get(
605
+ manager_routing_key_rep
606
+ )
602
607
  if not topic_handler:
603
608
  # we may have included the topic in one of our wildcard handlers
604
- topic_handler = self._find_wildcard_handler(tth_key)
609
+ wildcard_result = self._control_plane_manager.get_wildcard_topic_and_topic_handler(
610
+ manager_routing_key_rep
611
+ )
612
+ if wildcard_result:
613
+ manager_routing_key_rep, topic_handler = wildcard_result
614
+ amqp_routing_key_rep = _hierarchy_2_amqp(manager_routing_key_rep)
605
615
  if topic_handler:
606
- consumer_tag_info = self._topics_to_consumer_tags.get(basic_deliver.routing_key)
616
+ consumer_tag_info = self._topics_to_consumer_tags.get(amqp_routing_key_rep)
607
617
  if not consumer_tag_info:
608
618
  logger.error(
609
619
  'Could not fetch consumer tag for topic %s, please inform an INTERSECT-SDK developer that you saw this message',
610
- tth_key,
620
+ amqp_routing_key_rep,
611
621
  )
612
622
  return
613
623
  while not consumer_tag_info.consumer_tag_obtained():
@@ -55,7 +55,7 @@ class BrokerClient(Protocol):
55
55
  """
56
56
  ...
57
57
 
58
- def subscribe(self, topic: str, persist: bool) -> None:
58
+ def subscribe(self, topic: str, persist: bool, queue_name: str) -> None:
59
59
  """Subscribe to a topic over the pre-existing connection (via connect()).
60
60
 
61
61
  This function should ALSO be called by reconnect handlers, and not just directly.
@@ -64,6 +64,7 @@ class BrokerClient(Protocol):
64
64
  Args:
65
65
  topic: Topic to subscribe to.
66
66
  persist: Whether or not the queue subscribed to is intended to be long-lived.
67
+ queue_name: The name of the queue to subscribe to. Used or ignored depending on the protocol.
67
68
  """
68
69
  ...
69
70
 
@@ -22,12 +22,10 @@ from ...logger import logger
22
22
  from .broker_client import BrokerClient
23
23
 
24
24
  if TYPE_CHECKING:
25
- from collections.abc import Callable
26
-
27
25
  from paho.mqtt.client import DisconnectFlags
28
26
  from paho.mqtt.reasoncodes import ReasonCode
29
27
 
30
- from ..topic_handler import TopicHandler
28
+ from ..control_plane_manager import ControlPlaneManager
31
29
 
32
30
 
33
31
  _MQTT_MAX_RETRIES = 10
@@ -37,7 +35,7 @@ _MQTT_MAX_RETRIES = 10
37
35
  # this will be a breaking change, so only add it when ready to break
38
36
  def _hierarchy_2_mqtt(hierarchy: str) -> str:
39
37
  """Take the hierarchy string format saved in the Service and map it to the MQTT topic format. Currently just covers wildcards."""
40
- return hierarchy.replace('#', '+')
38
+ return hierarchy.replace('*', '+')
41
39
 
42
40
 
43
41
  class MQTTClient(BrokerClient):
@@ -61,8 +59,7 @@ class MQTTClient(BrokerClient):
61
59
  port: int,
62
60
  username: str,
63
61
  password: str,
64
- topics_to_handlers: Callable[[], dict[str, TopicHandler]],
65
- find_wildcard_handler: Callable[[str], TopicHandler | None],
62
+ control_plane_manager: ControlPlaneManager,
66
63
  uid: str | None = None,
67
64
  ) -> None:
68
65
  """The default constructor.
@@ -72,8 +69,7 @@ class MQTTClient(BrokerClient):
72
69
  port: port number of MQTT broker
73
70
  username: username credentials for MQTT broker
74
71
  password: password credentials for MQTT broker
75
- topics_to_handlers: callback function which gets the topic to handler map from the channel manager
76
- find_wildcard_handler: callback function which gets the wildcard topic handler from the channel manager
72
+ control_plane_manager: reference to the ControlPlaneManager instance, remember to ONLY use functions which do not mutate state
77
73
  uid: A string representing the unique id to identify the client.
78
74
  """
79
75
  # Unique id for the MQTT broker to associate this client with
@@ -97,8 +93,7 @@ class MQTTClient(BrokerClient):
97
93
  self._connected_flag = threading.Event()
98
94
 
99
95
  # ConnectionManager callable state
100
- self._topics_to_handlers = topics_to_handlers
101
- self._find_wildcard_handler = find_wildcard_handler
96
+ self._control_plane_manager = control_plane_manager
102
97
 
103
98
  # MQTT v3.1.1 automatically downgrades a QOS which is too high (good), but MQTT v5 will terminate the connection (bad)
104
99
  # see https://github.com/rabbitmq/rabbitmq-server/discussions/11842
@@ -170,12 +165,13 @@ class MQTTClient(BrokerClient):
170
165
  resolved_topic, payload, qos=self._max_supported_qos if persist else 0, properties=props
171
166
  )
172
167
 
173
- def subscribe(self, topic: str, persist: bool) -> None:
168
+ def subscribe(self, topic: str, persist: bool, queue_name: str) -> None: # noqa: ARG002
174
169
  """Subscribe to a topic over the pre-existing connection (via connect()).
175
170
 
176
171
  Args:
177
172
  topic: Topic to subscribe to.
178
173
  persist: Determine if the associated message queue of the topic is long-lived (True) or not (False)
174
+ queue_name: The name of the queue to subscribe to. Ignored with current MQTT implementation.
179
175
  """
180
176
  resolved_topic = _hierarchy_2_mqtt(topic)
181
177
  # NOTE: RabbitMQ only works with QOS of 1 and 0, and seems to convert QOS2 to QOS1
@@ -204,10 +200,17 @@ class MQTTClient(BrokerClient):
204
200
  message: MQTT message
205
201
  """
206
202
  # NOTE: should not need to convert the MQTT topic to the protocol agnostic representation, as we only change the wildcard format (which won't show up as the message topic)
207
- topic_handler = self._topics_to_handlers().get(message.topic)
203
+ inmem_topic = message.topic
204
+ topic_handler = self._control_plane_manager.get_non_wildcard_subscription_channels().get(
205
+ inmem_topic
206
+ )
208
207
  if not topic_handler:
209
208
  # we may have included the topic in one of our wildcard handlers
210
- topic_handler = self._find_wildcard_handler(message.topic)
209
+ wildcard_result = self._control_plane_manager.get_wildcard_topic_and_topic_handler(
210
+ inmem_topic
211
+ )
212
+ if wildcard_result:
213
+ _, topic_handler = wildcard_result
211
214
  # Note that if we return prior to the callback, there will be no reply message
212
215
  if not topic_handler:
213
216
  logger.warning('Incompatible message topic %s, rejecting message', message.topic)
@@ -298,8 +301,8 @@ class MQTTClient(BrokerClient):
298
301
  self._max_supported_qos = properties.MaximumQoS
299
302
 
300
303
  self._connected_flag.set()
301
- for topic, topic_handler in self._topics_to_handlers().items():
302
- self.subscribe(topic, topic_handler.topic_persist)
304
+ for topic, topic_handler in self._control_plane_manager.get_all_subscription_channels():
305
+ self.subscribe(topic, topic_handler.topic_persist, topic_handler.queue_name)
303
306
  else:
304
307
  # This will generally suggest a misconfiguration
305
308
  self._connected = False
@@ -1,7 +1,7 @@
1
1
  """Wrapper around interacting with the control plane, which may be multiple brokers."""
2
2
 
3
+ import itertools
3
4
  import re
4
- from collections.abc import Callable
5
5
 
6
6
  from ..config import ControlPlaneConfig
7
7
  from ..exceptions import IntersectSetupError
@@ -23,8 +23,7 @@ _CHANNEL_REGEX = re.compile(r'^[a-zA-Z0-9*/-]+[a-zA-Z0-9*#/-]$')
23
23
 
24
24
  def _create_control_provider(
25
25
  config: ControlPlaneConfig,
26
- topic_handler_callback: Callable[[], dict[str, TopicHandler]],
27
- find_wildcard_handler: Callable[[str], TopicHandler | None],
26
+ control_plane_manager: 'ControlPlaneManager',
28
27
  ) -> BrokerClient:
29
28
  if config.protocol == 'amqp0.9.1':
30
29
  from .brokers.amqp_client import ( # noqa: PLC0415 (lazy load all AMQP modules)
@@ -36,8 +35,7 @@ def _create_control_provider(
36
35
  port=config.port or 5672,
37
36
  username=config.username,
38
37
  password=config.password,
39
- topics_to_handlers=topic_handler_callback,
40
- find_wildcard_handler=find_wildcard_handler,
38
+ control_plane_manager=control_plane_manager,
41
39
  is_root=config.is_root,
42
40
  )
43
41
 
@@ -49,8 +47,7 @@ def _create_control_provider(
49
47
  port=config.port or 1883,
50
48
  username=config.username,
51
49
  password=config.password,
52
- topics_to_handlers=topic_handler_callback,
53
- find_wildcard_handler=find_wildcard_handler,
50
+ control_plane_manager=control_plane_manager,
54
51
  )
55
52
 
56
53
 
@@ -68,10 +65,7 @@ class ControlPlaneManager:
68
65
  queue_name_generator should be a hardcoded value for Core Services, the SDK should provide its own function to generate queue names.
69
66
  """
70
67
  self._control_providers = [
71
- _create_control_provider(
72
- config, self.get_subscription_channels, self.get_wildcard_topic_handler
73
- )
74
- for config in control_configs
68
+ _create_control_provider(config, self) for config in control_configs
75
69
  ]
76
70
 
77
71
  # flag which indicates if we SHOULD be connected.
@@ -121,7 +115,7 @@ class ControlPlaneManager:
121
115
  topic_handler.queue_name = queue_name
122
116
  if self.is_connected():
123
117
  for provider in self._control_providers:
124
- provider.subscribe(channel, persist)
118
+ provider.subscribe(channel, persist, queue_name)
125
119
 
126
120
  def remove_subscription_channel(self, channel: str) -> bool:
127
121
  """Stop subscribing to a channel on all configured brokers.
@@ -142,29 +136,47 @@ class ControlPlaneManager:
142
136
  provider.unsubscribe(channel)
143
137
  return True
144
138
 
145
- def get_subscription_channels(self) -> dict[str, TopicHandler]:
146
- """Get the subscription channels.
139
+ def get_non_wildcard_subscription_channels(self) -> dict[str, TopicHandler]:
140
+ """Get primary subscription channels.
147
141
 
148
- These channels cannot be wildcards, and incoming topics must match the channel topics exactly. Note that this function gets accessed as a callback from the direct broker implementations.
142
+ These channels cannot be wildcards, and incoming topics must match the channel topics exactly.
143
+
144
+ This function is safe to call from the broker clients, as it does not mutate state.
149
145
 
150
146
  Returns:
151
147
  the dictionary of topics to topic information
152
148
  """
153
149
  return self._topics_to_handlers
154
150
 
155
- def get_wildcard_topic_handler(self, topic: str) -> TopicHandler | None:
151
+ def get_wildcard_topic_and_topic_handler(self, topic: str) -> tuple[str, TopicHandler] | None:
156
152
  """Get the wildcard topic handler for a given topic, if it exists.
157
153
 
158
154
  This is an inefficient lookup, so should only be used if we fail to find a non-wildcard match for an incoming topic. Note that this function gets accessed as a callback from the direct broker implementations.
159
155
 
156
+ This function is safe to call from the broker clients, as it does not mutate state.
157
+
158
+ Params:
159
+ topic: the topic for an incoming message (this will not have any wildcards in it)
160
+
160
161
  Returns:
161
- the topic handler associated with the topic, if it exists; None otherwise
162
+ if a match found: the wildcard string configured application-side to match the topic parameter, and the topic handler associated with the topic
163
+ if no match found: None
162
164
  """
163
- for topic_handler in self._wildcards.values():
165
+ for wildcard, topic_handler in self._wildcards.items():
164
166
  if topic_handler.does_topic_match(topic):
165
- return topic_handler
167
+ return wildcard, topic_handler
166
168
  return None
167
169
 
170
+ def get_all_subscription_channels(self) -> itertools.chain[tuple[str, TopicHandler]]:
171
+ """Get all subscription channels, including wildcard channels.
172
+
173
+ This function is safe to call from the broker clients, as it does not mutate state.
174
+
175
+ Returns:
176
+ an iterator which yields tuples of (channel, topic handler) for all channels, including wildcards. Note that wildcard channels will be returned in no particular order, and may be interspersed with non-wildcard channels.
177
+ """
178
+ return itertools.chain(self._topics_to_handlers.items(), self._wildcards.items())
179
+
168
180
  def connect(self) -> None:
169
181
  """Connect to all configured brokers.
170
182
 
@@ -0,0 +1,77 @@
1
+ """This module contains the binary specification for an INTERSECT message IF the protocol does not provide any built-in header support. If it does, use the built-in functionality of the protocol.
2
+
3
+ Some protocols do not have any built-in header support, leaving it up to us to define the binary structure of the message. We want to avoid "chunking" messages into multiple parts, so a message should be guaranteed to include all metadata.
4
+ """
5
+
6
+ from intersect_sdk_common import IntersectApplicationError
7
+
8
+ _HEADER_KV_SEPARATOR = b'\x02'
9
+ """Indicates end of a header key and start of a header value.
10
+
11
+ Used because applications should generally assign no special meaning to this byte, and this byte has no reason to appear in header keys or values.
12
+ """
13
+ _HEADER_VALUE_SEPARATOR = b'\x03'
14
+ """Indicates end of a header value and start of the next header key.
15
+
16
+ Used because applications should generally assign no special meaning to this byte, and this byte has no reason to appear in header keys or values.
17
+ """
18
+ _PAYLOAD_SEPARATOR = b'\x01'
19
+ """Indicates end of headers and start of the payload.
20
+
21
+ Used because applications should generally assign no special meaning to this byte, and this byte has no reason to appear in header keys or values.
22
+ """
23
+
24
+ _TOTAL_REASONABLE_HEADER_BYTES = 131072
25
+ """The total number of bytes that should be reasonably expected to be used for header keys, values, and header separators combined. This provides applications with some level of DOS protection.
26
+
27
+ It is rare in practice for ANY application to use this number of bytes for the total amount of headers, for example http2_max_header_size in NGINX is rarely set above 128KB
28
+ """
29
+
30
+ CONTENT_TYPE_HEADER_KEY = 'content_type'
31
+ CONTENT_TYPE_HEADER_KEY_BYTES = CONTENT_TYPE_HEADER_KEY.encode()
32
+
33
+
34
+ def create_binary_message(body: bytes, content_type: str, headers: dict[str, str]) -> bytes:
35
+ """Create a binary message from the body, headers, and content type."""
36
+ return b''.join(
37
+ [
38
+ # content-type 'header' first (this is generally handled separately from other headers in many protocols)
39
+ CONTENT_TYPE_HEADER_KEY_BYTES,
40
+ _HEADER_KV_SEPARATOR,
41
+ content_type.encode(),
42
+ _HEADER_VALUE_SEPARATOR if len(headers) else b'',
43
+ # headers
44
+ _HEADER_VALUE_SEPARATOR.join(
45
+ _HEADER_KV_SEPARATOR.join([key.encode(), value.encode()])
46
+ for key, value in headers.items()
47
+ ),
48
+ # end of headers
49
+ _PAYLOAD_SEPARATOR,
50
+ # body
51
+ body,
52
+ ]
53
+ )
54
+
55
+
56
+ def parse_binary_message(message: bytes) -> tuple[bytes, str, dict[str, str]]:
57
+ """Parse a binary message into its body, content type, and headers."""
58
+ # IMPORTANT!!! ----- Total length of header keys and values combined should be limited to first several bytes, terminate header search early if headers aren't a reasonable length.
59
+ payload_sep_location = message.find(_PAYLOAD_SEPARATOR, 0, _TOTAL_REASONABLE_HEADER_BYTES)
60
+ if payload_sep_location == -1:
61
+ msg = 'Probable malformed message: no payload separator found in first expected bytes, .'
62
+ raise IntersectApplicationError(msg)
63
+ header_string = message[:payload_sep_location]
64
+ headers = {
65
+ key.decode(): value.decode()
66
+ for key, value in (
67
+ header.split(_HEADER_KV_SEPARATOR)
68
+ for header in header_string.split(_HEADER_VALUE_SEPARATOR)
69
+ )
70
+ }
71
+ try:
72
+ content_type = headers.pop(CONTENT_TYPE_HEADER_KEY)
73
+ except KeyError as e:
74
+ msg = 'Probable malformed message: no content_type header found in message, discarding it.'
75
+ raise IntersectApplicationError(msg) from e
76
+
77
+ return message[payload_sep_location + 1 :], content_type, headers
@@ -46,8 +46,8 @@ class TopicHandler:
46
46
  # match any sequence of 0 or more words (make sure to allow for the separator)
47
47
  regex_builder.append('[a-zA-Z0-9/-]*')
48
48
  elif char == '/':
49
- # in-memory topics use '/' as the separator, but on the broker '.' is used
50
- regex_builder.append('\\.')
49
+ # in-memory topics use '/' as the separator, it is the protocol handler's responsibility to convert topics to use '/' as the separator
50
+ regex_builder.append('/')
51
51
  else:
52
52
  # ordinary character
53
53
  regex_builder.append(char)