pyqttier 0.1.0__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.
pyqttier/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from pyqttier!"
pyqttier/connection.py ADDED
@@ -0,0 +1,200 @@
1
+ from concurrent.futures import Future
2
+ import logging
3
+ import threading
4
+ import uuid
5
+ from typing import Callable, Optional, Tuple, Any, Union, List, Dict
6
+ from paho.mqtt.client import Client as MqttClient, topic_matches_sub
7
+ from paho.mqtt.enums import MQTTProtocolVersion, CallbackAPIVersion
8
+ from paho.mqtt.properties import Properties as MqttProperties
9
+ from paho.mqtt.packettypes import PacketTypes
10
+ from queue import Queue, Empty
11
+ from .interface import IBrokerConnection, MessageCallback
12
+ from .transport import MqttTransport
13
+ from .message import Message
14
+ from .lwt import OnlinePresence
15
+ from dataclasses import dataclass
16
+
17
+ logging.basicConfig(level=logging.DEBUG)
18
+
19
+
20
+ class Mqtt5Connection(IBrokerConnection):
21
+
22
+ @dataclass
23
+ class PendingSubscription:
24
+ topic: str
25
+ subscription_id: int
26
+
27
+ @dataclass
28
+ class PendingPublish:
29
+ msg: Message
30
+ future: Future
31
+
32
+ def __init__(
33
+ self,
34
+ transport: MqttTransport,
35
+ client_id: Optional[str] = None,
36
+ lwt: Optional[OnlinePresence] = None,
37
+ ):
38
+ self._logger = logging.getLogger("MqttConnection")
39
+ self._transport = transport
40
+ self._client_id = client_id or str(uuid.uuid4())
41
+ self._queued_messages = Queue() # type: Queue[Mqtt5Connection.PendingPublish]
42
+ self._queued_subscriptions = (
43
+ Queue()
44
+ ) # type: Queue[Mqtt5Connection.PendingSubscription]
45
+ self._connected: bool = False
46
+
47
+ lwt_properties = MqttProperties(PacketTypes.PUBLISH)
48
+ lwt_properties.ContentType = "application/json"
49
+ lwt_properties.MessageExpiryInterval = 60 * 60 * 24 # 1 day
50
+ self._lwt = lwt or OnlinePresence.default(self._client_id)
51
+
52
+ self._connect_inner_mqtt_client()
53
+
54
+ self._message_handling_lock = threading.Lock()
55
+ with self._message_handling_lock:
56
+ self._subscription_callbacks = dict() # type: Dict[int, MessageCallback]
57
+ self._message_callbacks = [] # type: List[MessageCallback]
58
+
59
+ self._publishing_lock = threading.Lock()
60
+ with self._publishing_lock:
61
+ self._publish_futures = dict() # type: Dict[int, Future]
62
+
63
+ self._client.loop_start()
64
+ self._next_subscription_id = 10
65
+
66
+ def _connect_inner_mqtt_client(self):
67
+ """
68
+ Private method to be called from constructor only.
69
+ """
70
+ self._client = MqttClient(
71
+ CallbackAPIVersion.VERSION2,
72
+ protocol=MQTTProtocolVersion.MQTTv5,
73
+ transport=self._transport.transport.value,
74
+ client_id=self._client_id,
75
+ reconnect_on_failure=True,
76
+ )
77
+ self._client.on_connect = self._on_connect
78
+ self._client.on_message = self._on_message
79
+ self._client.on_publish = self.on_publish_complete
80
+ self._client.will_set(**self._lwt.online.paho_kwargs())
81
+ host = self._transport.host_or_path or "localhost"
82
+ self._client.connect(host, self._transport.port)
83
+
84
+ def __del__(self):
85
+ if self._lwt is not None:
86
+ self._client.publish(**self._lwt.offline.paho_kwargs()).wait_for_publish()
87
+ self._client.disconnect()
88
+ self._client.loop_stop()
89
+
90
+ @property
91
+ def online_topic(self) -> str:
92
+ return self._lwt.topic
93
+
94
+ @property
95
+ def client_id(self) -> str:
96
+ return self._client_id
97
+
98
+ def is_connected(self) -> bool:
99
+ return self._connected
100
+
101
+ def get_next_subscription_id(self) -> int:
102
+ sub_id = self._next_subscription_id
103
+ self._next_subscription_id += 1
104
+ return sub_id
105
+
106
+ def add_message_callback(self, callback: MessageCallback):
107
+ self._message_callbacks.append(callback)
108
+
109
+ def _on_message(self, client, userdata, msg):
110
+ self._logger.debug("Got a message to %s : %s", msg.topic, msg.payload.decode())
111
+ message = Message.from_paho_message(msg)
112
+ if message.subscription_id is not None:
113
+ sub_id = message.subscription_id
114
+ with self._message_handling_lock:
115
+ if sub_id in self._subscription_callbacks:
116
+ self._subscription_callbacks[sub_id](message)
117
+ return
118
+ with self._message_handling_lock:
119
+ for callback in self._message_callbacks:
120
+ callback(message)
121
+
122
+ def _on_connect(self, client, userdata, flags, reason_code, properties):
123
+ if reason_code == 0: # Connection successful
124
+ self._connected = True
125
+ self._logger.info(
126
+ "Connected to %s:%d", self._transport.host_or_path, self._transport.port
127
+ )
128
+ while not self._queued_subscriptions.empty():
129
+ try:
130
+ pending_subscr = self._queued_subscriptions.get_nowait()
131
+ except Empty:
132
+ break
133
+ else:
134
+ self._logger.debug(
135
+ "Connected and subscribing to %s", pending_subscr.topic
136
+ )
137
+ sub_props = MqttProperties(PacketTypes.SUBSCRIBE)
138
+ sub_props.SubscriptionIdentifier = pending_subscr.subscription_id
139
+ self._client.subscribe(
140
+ pending_subscr.topic, qos=1, properties=sub_props
141
+ )
142
+ while not self._queued_messages.empty():
143
+ try:
144
+ msg = self._queued_messages.get_nowait()
145
+ except Empty:
146
+ break
147
+ else:
148
+ self._logger.info(f"Publishing queued up message")
149
+ pub_info = self._client.publish(**msg.msg.paho_kwargs())
150
+ with self._publishing_lock:
151
+ self._publish_futures[pub_info.mid] = msg.future
152
+
153
+ self._client.publish(**self._lwt.online.paho_kwargs())
154
+ else:
155
+ self._logger.error(
156
+ "Connection failed with reason code %s", str(reason_code)
157
+ )
158
+ self._connected = False
159
+
160
+ def on_publish_complete(
161
+ self, client, userdata, mid, reason_code=None, properties=None
162
+ ):
163
+ with self._publishing_lock:
164
+ if mid in self._publish_futures:
165
+ fut = self._publish_futures.pop(mid)
166
+ fut.set_result(None)
167
+
168
+ def publish(self, message: Message) -> Future:
169
+ """Publish a message to mqtt, or queue it if not connected yet. Returns a Future that completes when the message is published."""
170
+ fut = Future() # type: Future
171
+ if self._connected:
172
+ self._logger.info("Publishing %s", message.topic)
173
+ msg_info = self._client.publish(**message.paho_kwargs())
174
+ with self._publishing_lock:
175
+ self._publish_futures[msg_info.mid] = fut
176
+ else:
177
+ self._logger.info("Queueing %s for publishing later", message.topic)
178
+ pending_pub = self.PendingPublish(msg=message, future=fut)
179
+ self._queued_messages.put(pending_pub)
180
+ return fut
181
+
182
+ def subscribe(self, topic: str, callback: Optional[MessageCallback] = None) -> int:
183
+ """Subscribes to a topic. If the connection is not established, the subscription is queued.
184
+ Returns the subscription ID.
185
+ """
186
+ sub_id = self.get_next_subscription_id()
187
+ if self._connected:
188
+ self._logger.debug("Subscribing to %s", topic)
189
+ sub_props = MqttProperties(PacketTypes.SUBSCRIBE)
190
+ sub_props.SubscriptionIdentifier = sub_id
191
+ self._client.subscribe(topic, qos=1, properties=sub_props)
192
+ else:
193
+ self._logger.debug("Pending subscription to %s", topic)
194
+ self._queued_subscriptions.put(self.PendingSubscription(topic, sub_id))
195
+ if callback is not None:
196
+ self._subscription_callbacks[sub_id] = callback
197
+ return sub_id
198
+
199
+ def is_topic_sub(self, topic: str, sub: str) -> bool:
200
+ return topic_matches_sub(sub, topic)
pyqttier/interface.py ADDED
@@ -0,0 +1,57 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Callable, Dict, Any, Union, Optional
3
+ from .message import Message
4
+ from concurrent.futures import Future
5
+
6
+ MessageCallback = Callable[[Message], None]
7
+
8
+
9
+ class IBrokerConnection(ABC):
10
+
11
+ @abstractmethod
12
+ def publish(self, message: Message) -> Future:
13
+ """
14
+ Publishes a message to the provided topic with the provided parameters. If the connection to the broker is not established,
15
+ the message may be queued until the connection is established.
16
+ """
17
+ pass
18
+
19
+ @abstractmethod
20
+ def subscribe(self, topic: str, callback: Optional[MessageCallback] = None) -> int:
21
+ """
22
+ Subscribes to the provided topic. May queue the subscription until the connection to the broker is established.
23
+ When a message is received on the topic, the provided callback will be executed if provided.
24
+ Returns a subscription identifier for the subscription.
25
+ """
26
+ pass
27
+
28
+ @abstractmethod
29
+ def add_message_callback(self, callback: MessageCallback) -> None:
30
+ """
31
+ The provided callback is called for all received messages that do not have a specific callback registered via the subscribe method.
32
+ """
33
+ pass
34
+
35
+ @abstractmethod
36
+ def is_topic_sub(self, topic: str, sub: str) -> bool:
37
+ """Returns True if the provided topic matches the provided subscription filter."""
38
+ pass
39
+
40
+ @property
41
+ @abstractmethod
42
+ def online_topic(self) -> Optional[str]:
43
+ pass
44
+
45
+ @property
46
+ @abstractmethod
47
+ def client_id(self) -> str:
48
+ pass
49
+
50
+ @abstractmethod
51
+ def is_connected(self) -> bool:
52
+ """Returns True if the connection to the broker is established."""
53
+ pass
54
+
55
+ def unpublish_retained(self, topic):
56
+ msg = Message(topic=topic, payload=b"", qos=1, retain=True)
57
+ self.publish(msg)
pyqttier/lwt.py ADDED
@@ -0,0 +1,23 @@
1
+ from pydantic import BaseModel
2
+ from .message import Message
3
+
4
+
5
+ class OnlinePresence(BaseModel):
6
+ topic: str
7
+ online: Message
8
+ offline: Message
9
+
10
+ def __post_init__(self):
11
+ self.online.topic = self.topic
12
+ self.offline.topic = self.topic
13
+
14
+ @classmethod
15
+ def default(cls, client_id: str) -> "OnlinePresence":
16
+ online_topic = f"client/{client_id}/online"
17
+ online_msg = Message(
18
+ topic=online_topic, payload=b'{"online":true}', qos=1, retain=True
19
+ )
20
+ offline_msg = Message(
21
+ topic=online_topic, payload=b'{"online":false}', qos=1, retain=True
22
+ )
23
+ return cls(topic=online_topic, online=online_msg, offline=offline_msg)
pyqttier/message.py ADDED
@@ -0,0 +1,223 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional, Dict, Union, Any
3
+ from paho.mqtt.client import MQTTMessage
4
+ from paho.mqtt.properties import Properties as MqttProperties
5
+ from paho.mqtt.packettypes import PacketTypes
6
+ from pydantic import BaseModel
7
+ from enum import Enum
8
+ import uuid
9
+
10
+
11
+ @dataclass
12
+ class Message:
13
+ topic: str
14
+ payload: bytes
15
+ qos: int
16
+ retain: bool = False
17
+ content_type: Optional[str] = None
18
+ correlation_data: Optional[bytes] = None
19
+ response_topic: Optional[str] = None
20
+ subscription_id: Optional[int] = None # Ignored on publish
21
+ message_expiry_interval: Optional[int] = None
22
+ user_properties: Optional[Dict[str, str]] = field(default_factory=dict)
23
+
24
+ def __post_init__(self):
25
+ if self.user_properties is None:
26
+ self.user_properties = dict()
27
+
28
+ def paho_kwargs(self) -> Dict[str, Any]:
29
+ props = MqttProperties(PacketTypes.PUBLISH)
30
+ if self.content_type is not None:
31
+ props.ContentType = self.content_type
32
+ if self.correlation_data is not None:
33
+ props.CorrelationData = self.correlation_data
34
+ if self.response_topic is not None:
35
+ props.ResponseTopic = self.response_topic
36
+ if self.message_expiry_interval is not None:
37
+ props.MessageExpiryInterval = self.message_expiry_interval
38
+ if self.user_properties is not None and len(self.user_properties) > 0:
39
+ props.UserProperty = list(self.user_properties.items())
40
+ kwargs = {
41
+ "topic": self.topic,
42
+ "payload": self.payload,
43
+ "qos": self.qos,
44
+ "retain": self.retain,
45
+ "properties": props,
46
+ }
47
+ return kwargs
48
+
49
+ @classmethod
50
+ def from_paho_message(cls, paho_msg: MQTTMessage) -> "Message":
51
+ msg_obj = cls(
52
+ topic=paho_msg.topic,
53
+ payload=paho_msg.payload,
54
+ qos=paho_msg.qos,
55
+ retain=paho_msg.retain,
56
+ )
57
+ properties = (
58
+ paho_msg.properties.__dict__ if hasattr(paho_msg, "properties") else {}
59
+ )
60
+ if "UserProperty" in properties:
61
+ msg_obj.user_properties = dict(properties["UserProperty"])
62
+ if "ContentType" in properties:
63
+ msg_obj.content_type = properties["ContentType"]
64
+ if "CorrelationData" in properties:
65
+ msg_obj.correlation_data = properties["CorrelationData"]
66
+ if "ResponseTopic" in properties:
67
+ msg_obj.response_topic = properties["ResponseTopic"]
68
+ if "MessageExpiryInterval" in properties:
69
+ msg_obj.message_expiry_interval = properties["MessageExpiryInterval"]
70
+ return msg_obj
71
+
72
+ @classmethod
73
+ def status_message(
74
+ cls, topic, status_message: BaseModel, expiry_seconds: int
75
+ ) -> "Message":
76
+ return cls(
77
+ topic=topic,
78
+ payload=status_message.model_dump_json(by_alias=True).encode("utf-8"),
79
+ qos=1,
80
+ retain=True,
81
+ message_expiry_interval=expiry_seconds,
82
+ )
83
+
84
+ @classmethod
85
+ def error_response_message(
86
+ cls,
87
+ topic: str,
88
+ return_code: int,
89
+ correlation_id: Union[str, bytes],
90
+ debug_info: Optional[str] = None,
91
+ ) -> "Message":
92
+ msg_obj = cls(
93
+ topic=topic,
94
+ payload=b"{}",
95
+ qos=1,
96
+ retain=False,
97
+ correlation_data=(
98
+ correlation_id.encode("utf-8")
99
+ if isinstance(correlation_id, str)
100
+ else correlation_id
101
+ ),
102
+ user_properties={"ReturnCode": str(return_code)},
103
+ )
104
+ if (
105
+ debug_info is not None and msg_obj.user_properties is not None
106
+ ): # user_properties should never be None here, but checking to satisfy type checker
107
+ msg_obj.user_properties["DebugInfo"] = debug_info
108
+ return msg_obj
109
+
110
+ @classmethod
111
+ def response_message(
112
+ cls,
113
+ response_topic: str,
114
+ response_obj: BaseModel,
115
+ return_code: int,
116
+ correlation_id: Union[str, bytes],
117
+ ) -> "Message":
118
+ msg_obj = cls(
119
+ topic=response_topic,
120
+ payload=response_obj.model_dump_json(by_alias=True).encode("utf-8"),
121
+ qos=1,
122
+ retain=False,
123
+ correlation_data=(
124
+ correlation_id.encode("utf-8")
125
+ if isinstance(correlation_id, str)
126
+ else correlation_id
127
+ ),
128
+ user_properties={"ReturnCode": str(return_code)},
129
+ )
130
+ return msg_obj
131
+
132
+ @classmethod
133
+ def property_state_message(
134
+ cls, topic: str, state_obj: BaseModel, state_version: Optional[int] = None
135
+ ) -> "Message":
136
+ msg_obj = cls(
137
+ topic=topic,
138
+ payload=state_obj.model_dump_json(by_alias=True).encode("utf-8"),
139
+ qos=1,
140
+ retain=True,
141
+ )
142
+ if state_version is not None:
143
+ msg_obj.user_properties = {"PropertyVersion": str(state_version)}
144
+ return msg_obj
145
+
146
+ @classmethod
147
+ def property_update_request_message(
148
+ cls,
149
+ topic: str,
150
+ property_obj: BaseModel,
151
+ version: str,
152
+ response_topic: str,
153
+ correlation_id: Union[str, bytes, None] = None,
154
+ ) -> "Message":
155
+ msg_obj = cls(
156
+ topic=topic,
157
+ payload=property_obj.model_dump_json(by_alias=True).encode("utf-8"),
158
+ qos=1,
159
+ retain=False,
160
+ response_topic=response_topic,
161
+ correlation_data=(
162
+ correlation_id.encode("utf-8")
163
+ if isinstance(correlation_id, str)
164
+ else correlation_id
165
+ ),
166
+ user_properties={"PropertyVersion": str(version)},
167
+ )
168
+ return msg_obj
169
+
170
+ @classmethod
171
+ def property_response_message(
172
+ cls,
173
+ response_topic: str,
174
+ property_obj: BaseModel,
175
+ version: str,
176
+ return_code: int,
177
+ correlation_id: Union[str, bytes],
178
+ debug_info: Optional[str] = None,
179
+ ) -> "Message":
180
+ msg_obj = cls(
181
+ topic=response_topic,
182
+ payload=property_obj.model_dump_json(by_alias=True).encode("utf-8"),
183
+ qos=1,
184
+ retain=False,
185
+ correlation_data=(
186
+ correlation_id.encode("utf-8")
187
+ if isinstance(correlation_id, str)
188
+ else correlation_id
189
+ ),
190
+ user_properties={
191
+ "ReturnCode": str(return_code),
192
+ "PropertyVersion": str(version),
193
+ },
194
+ )
195
+ if (
196
+ debug_info is not None and msg_obj.user_properties is not None
197
+ ): # user_properties should never be None here, but checking to satisfy type checker
198
+ msg_obj.user_properties["DebugInfo"] = debug_info
199
+ return msg_obj
200
+
201
+ @classmethod
202
+ def publish_request(
203
+ cls,
204
+ topic: str,
205
+ request_obj: BaseModel,
206
+ response_topic: str,
207
+ correlation_id: Union[str, bytes, None] = None,
208
+ ) -> "Message":
209
+ if correlation_id is None:
210
+ correlation_id = str(uuid.uuid4())
211
+ msg_obj = cls(
212
+ topic=topic,
213
+ payload=request_obj.model_dump_json(by_alias=True).encode("utf-8"),
214
+ qos=1,
215
+ retain=False,
216
+ response_topic=response_topic,
217
+ correlation_data=(
218
+ correlation_id.encode("utf-8")
219
+ if isinstance(correlation_id, str)
220
+ else correlation_id
221
+ ),
222
+ )
223
+ return msg_obj
pyqttier/mock.py ADDED
@@ -0,0 +1,144 @@
1
+ from concurrent.futures import Future
2
+ from typing import Optional, Dict, List, Callable, Tuple
3
+ from copy import copy
4
+ from .interface import IBrokerConnection, MessageCallback
5
+ from .message import Message
6
+ import threading
7
+
8
+
9
+ class MockConnection(IBrokerConnection):
10
+ """
11
+ Mock implementation of IBrokerConnection for testing purposes.
12
+ Simulates broker behavior without requiring an actual MQTT broker.
13
+ """
14
+
15
+ def __init__(self):
16
+ self._connected = True
17
+ self._subscriptions = (
18
+ {}
19
+ ) # type: Dict[int, Tuple[str, Optional[MessageCallback]]]
20
+ self._message_callbacks = [] # type: List[MessageCallback]
21
+ self._published_messages = [] # type: List[Message]
22
+ self._next_subscription_id = 1 # type: int
23
+ self._lock = threading.Lock()
24
+
25
+ @property
26
+ def client_id(self) -> str:
27
+ return "mock-client"
28
+
29
+ @property
30
+ def online_topic(self) -> Optional[str]:
31
+ return None
32
+
33
+ @property
34
+ def published_messages(self) -> List[Message]:
35
+ """Returns list of all published messages for testing verification."""
36
+ with self._lock:
37
+ return self._published_messages.copy()
38
+
39
+ def clear_published_messages(self) -> None:
40
+ """Clears the list of published messages."""
41
+ with self._lock:
42
+ self._published_messages.clear()
43
+
44
+ def set_connected(self, connected: bool) -> "MockConnection":
45
+ """Sets the connection status for testing."""
46
+ with self._lock:
47
+ self._connected = connected
48
+ return self
49
+
50
+ def simulate_message(self, message: Message) -> None:
51
+ """
52
+ Simulates receiving a message from the broker.
53
+ Triggers appropriate callbacks based on subscriptions.
54
+ """
55
+ with self._lock:
56
+ # Check subscription-specific callbacks
57
+ for sub_id, (topic_filter, callback) in self._subscriptions.items():
58
+ if self.is_topic_sub(message.topic, topic_filter):
59
+ if callback is not None:
60
+ receiving_msg = copy(message)
61
+ receiving_msg.subscription_id = sub_id
62
+ callback(receiving_msg)
63
+ return
64
+
65
+ # If no specific callback matched, call general message callbacks
66
+ for callback in self._message_callbacks:
67
+ callback(message)
68
+
69
+ def publish(self, message: Message) -> Future:
70
+ """
71
+ Records the published message and returns a completed Future.
72
+ """
73
+ with self._lock:
74
+ self._published_messages.append(message)
75
+
76
+ future: Future = Future()
77
+ future.set_result(None)
78
+ return future
79
+
80
+ def subscribe(self, topic: str, callback: Optional[MessageCallback] = None) -> int:
81
+ """
82
+ Registers a subscription and returns a subscription ID.
83
+ """
84
+ with self._lock:
85
+ sub_id = self._next_subscription_id
86
+ self._next_subscription_id += 1
87
+ self._subscriptions[sub_id] = (topic, callback)
88
+ return sub_id
89
+
90
+ def add_message_callback(self, callback: MessageCallback) -> None:
91
+ """
92
+ Adds a callback for messages without specific subscription callbacks.
93
+ """
94
+ with self._lock:
95
+ self._message_callbacks.append(callback)
96
+
97
+ def is_topic_sub(self, topic: str, sub: str) -> bool:
98
+ """
99
+ Simple topic matching implementation.
100
+ Supports MQTT wildcards: + (single level) and # (multi level).
101
+ """
102
+ topic_parts = topic.split("/")
103
+ sub_parts = sub.split("/")
104
+
105
+ # # must be last and matches everything after
106
+ if "#" in sub_parts:
107
+ hash_idx = sub_parts.index("#")
108
+ if hash_idx != len(sub_parts) - 1:
109
+ return False # # must be last
110
+ sub_parts = sub_parts[:hash_idx]
111
+ topic_parts = topic_parts[:hash_idx]
112
+
113
+ if len(topic_parts) != len(sub_parts):
114
+ return False
115
+
116
+ for topic_part, sub_part in zip(topic_parts, sub_parts):
117
+ if sub_part == "+":
118
+ continue # + matches any single level
119
+ if topic_part != sub_part:
120
+ return False
121
+
122
+ return True
123
+
124
+ def is_connected(self) -> bool:
125
+ """Returns the connection status."""
126
+ with self._lock:
127
+ return self._connected
128
+
129
+ def unsubscribe(self, subscription_id: int) -> None:
130
+ """
131
+ Helper method to unsubscribe by subscription ID.
132
+ Not part of the interface but useful for testing.
133
+ """
134
+ with self._lock:
135
+ if subscription_id in self._subscriptions:
136
+ del self._subscriptions[subscription_id]
137
+
138
+ def get_subscription_count(self) -> int:
139
+ """
140
+ Returns the number of active subscriptions.
141
+ Helper method for testing.
142
+ """
143
+ with self._lock:
144
+ return len(self._subscriptions)
pyqttier/py.typed ADDED
File without changes
pyqttier/transport.py ADDED
@@ -0,0 +1,27 @@
1
+ from enum import Enum
2
+ from typing import Optional
3
+
4
+
5
+ class MqttTransportType(Enum):
6
+ """Defines ways to connect to an MQTT broker."""
7
+
8
+ TCP = "tcp"
9
+ WEBSOCKET = "websockets"
10
+ UNIX = "unix"
11
+
12
+
13
+ class MqttTransport:
14
+ """Defines the transport parameters for connecting to an MQTT broker."""
15
+
16
+ def __init__(
17
+ self,
18
+ transport_type: MqttTransportType,
19
+ host: Optional[str] = None,
20
+ port: Optional[int] = None,
21
+ socket_path: Optional[str] = None,
22
+ ):
23
+ self.transport = transport_type
24
+ self.host_or_path = (
25
+ socket_path if transport_type == MqttTransportType.UNIX else host
26
+ )
27
+ self.port = 0 if transport_type == MqttTransportType.UNIX else (port or 1883)
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyqttier
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author-email: Jacob Brunson <github@jacobbrunson.com>
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.7
8
+ Requires-Dist: paho-mqtt>=2.1.0
9
+ Requires-Dist: pydantic>=2.5.3
10
+ Description-Content-Type: text/markdown
11
+
12
+ # PyQTTier
13
+
14
+ A Python MQTT client library providing a clean, type-safe wrapper around paho-mqtt with support for MQTT 5.0 features.
15
+
16
+ ## Features
17
+
18
+ - **MQTT 5.0 Support** - Full support for MQTT 5.0 protocol features including message properties, correlation data, and response topics
19
+ - **Type Safety** - Strongly typed with mypy-checked type hints for reliability
20
+ - **Multiple Transports** - Supports TCP, WebSocket, and Unix socket connections
21
+ - **Mock Implementation** - Built-in `MockConnection` for easy testing without a broker
22
+ - **Python 3.7+** - Compatible with Python 3.7 through 3.12
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install pyqttier
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ### Basic Usage
33
+
34
+ ```python
35
+ from pyqttier.connection import Mqtt5Connection
36
+ from pyqttier.transport import MqttTransport, MqttTransportType
37
+ from pyqttier.message import Message
38
+
39
+ # Connect to broker
40
+ transport = MqttTransport(MqttTransportType.TCP, host="localhost", port=1883)
41
+ conn = Mqtt5Connection(transport=transport, client_id="my-client")
42
+
43
+ # Subscribe to a topic
44
+ def on_message(msg: Message):
45
+ print(f"Received: {msg.payload.decode()}")
46
+
47
+ conn.subscribe("sensors/temperature", callback=on_message)
48
+
49
+ # Publish a message
50
+ msg = Message(topic="sensors/temperature", payload=b"23.5", qos=1)
51
+ conn.publish(msg)
52
+ ```
53
+
54
+ ### Testing with MockConnection
55
+
56
+ ```python
57
+ from pyqttier.mock import MockConnection
58
+ from pyqttier.message import Message
59
+
60
+ # Create mock connection for testing
61
+ conn = MockConnection()
62
+
63
+ # Publish and verify
64
+ conn.publish(Message(topic="test", payload=b"data", qos=1))
65
+ assert len(conn.published_messages) == 1
66
+ assert conn.published_messages[0].topic == "test"
67
+ ```
68
+
69
+ ## Examples
70
+
71
+ See the [examples/](examples/) directory for more detailed usage examples:
72
+
73
+ - `usage_example.py` - Real broker connections, wildcards, request-response patterns
74
+ - `mock_example.py` - Testing with MockConnection
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ # Type checking
80
+ uv run mypy --check-untyped-defs ./src/
81
+
82
+ # Unit tests
83
+ uv run pytest
84
+ ```
85
+
86
+ ## License
87
+
88
+ See [LICENSE](LICENSE) file for details.
@@ -0,0 +1,12 @@
1
+ pyqttier/__init__.py,sha256=UvsVYguOyK6Sj2n7AnSFT1kfZeYwCtejhnKw6f_hvCY,54
2
+ pyqttier/connection.py,sha256=b3TPcDN_-msTbqOwbsWnga3igxnBqRhbVFITuDuMnew,7917
3
+ pyqttier/interface.py,sha256=jqA8Q7TecWjPdMLfx13xzJIoWIXmUSs2Q2YHbSePvyQ,1887
4
+ pyqttier/lwt.py,sha256=NNTB8ZxJivC4aCz2lZaJJ14RQ_nKbbwto-8eAtpHKVc,707
5
+ pyqttier/message.py,sha256=7gG5HATDXxl8YdQG8nQ3_gU8SqhEV09YkQvhRgHD-VQ,7558
6
+ pyqttier/mock.py,sha256=LvMmlKUdHjgPtCoxn1okfuYAZXdI6mQLUTmfS5eoDOE,4952
7
+ pyqttier/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pyqttier/transport.py,sha256=Lf898T4wre6rJu2yv2xFs9XxnTvzI9xEKIab_RSssTU,742
9
+ pyqttier-0.1.0.dist-info/METADATA,sha256=I8VoJ-nQui3ksgaoRHuwD01HDH4pc7n4qagdk6aFduA,2315
10
+ pyqttier-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
11
+ pyqttier-0.1.0.dist-info/licenses/LICENSE,sha256=_T-8ExmblJbhv-1AxC6XDVEHg1JdJA-126NvRiMqS-I,1070
12
+ pyqttier-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jacob Brunson
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.