stinger-python-utils 0.1.8rc5__py3-none-any.whl → 0.2.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.
@@ -7,27 +7,82 @@ from .return_codes import MethodReturnCode
7
7
 
8
8
  class MessageCreator:
9
9
 
10
+ @staticmethod
11
+ def _validate_topic(topic: str, param_name: str = "topic") -> None:
12
+ if "+" in topic:
13
+ raise ValueError(
14
+ f"{param_name} must not contain '+', got: {topic!r}"
15
+ )
16
+
10
17
  @classmethod
11
18
  def signal_message(cls, topic: str, payload: BaseModel) -> Message:
19
+ return cls.binary_signal_message(
20
+ topic,
21
+ payload.model_dump_json(by_alias=True).encode("utf-8"),
22
+ "application/json",
23
+ )
24
+
25
+ @classmethod
26
+ def binary_signal_message(cls, topic: str, payload: bytes, content_type: str) -> Message:
27
+ cls._validate_topic(topic)
12
28
  return Message(
13
29
  topic=topic,
14
- payload=payload.model_dump_json(by_alias=True).encode("utf-8"),
30
+ payload=payload,
15
31
  qos=1,
16
32
  retain=False,
17
- content_type="application/json",
33
+ content_type=content_type,
34
+ )
35
+
36
+ @classmethod
37
+ def command_message(cls, topic: str, payload: BaseModel, qos: int = 2) -> Message:
38
+ return cls.binary_command_message(
39
+ topic,
40
+ payload.model_dump_json(by_alias=True).encode("utf-8"),
41
+ "application/json",
42
+ qos=qos,
43
+ )
44
+
45
+ @classmethod
46
+ def binary_command_message(
47
+ cls, topic: str, payload: bytes, content_type: str, qos: int = 2
48
+ ) -> Message:
49
+ """
50
+ A command is a signal in reverse: the client publishes it to the server and never
51
+ learns whether it was acted on. There is therefore no response topic and no
52
+ correlation data on the message.
53
+ """
54
+ cls._validate_topic(topic)
55
+ return Message(
56
+ topic=topic,
57
+ payload=payload,
58
+ qos=qos,
59
+ retain=False,
60
+ content_type=content_type,
18
61
  )
19
62
 
20
63
  @classmethod
21
64
  def status_message(
22
- cls, topic, status_message: BaseModel, expiry_seconds: int
65
+ cls, topic: str, status_message: BaseModel, expiry_seconds: int
66
+ ) -> Message:
67
+ return cls.binary_status_message(
68
+ topic,
69
+ status_message.model_dump_json(by_alias=True).encode("utf-8"),
70
+ "application/json",
71
+ expiry_seconds=expiry_seconds,
72
+ )
73
+
74
+ @classmethod
75
+ def binary_status_message(
76
+ cls, topic: str, payload: bytes, content_type: str, expiry_seconds: int
23
77
  ) -> Message:
78
+ cls._validate_topic(topic)
24
79
  return Message(
25
80
  topic=topic,
26
- payload=status_message.model_dump_json(by_alias=True).encode("utf-8"),
81
+ payload=payload,
27
82
  qos=1,
28
83
  retain=True,
29
84
  message_expiry_interval=expiry_seconds,
30
- content_type="application/json",
85
+ content_type=content_type,
31
86
  )
32
87
 
33
88
  @classmethod
@@ -37,10 +92,16 @@ class MessageCreator:
37
92
  return_code: Union[int, MethodReturnCode],
38
93
  correlation_id: Union[str, bytes, None] = None,
39
94
  debug_info: Optional[str] = None,
95
+ content_type: str = "application/json",
40
96
  ) -> Message:
41
97
  """
42
98
  This could be used for a response to a request, but where there was an error fulfilling the request.
99
+
100
+ An error carries no data -- the return code and any debug info travel in MQTT user
101
+ properties -- so the body is an empty JSON object for a JSON response and empty
102
+ bytes for any other content type, since a protobuf consumer would reject "{}".
43
103
  """
104
+ cls._validate_topic(topic)
44
105
  rc = (
45
106
  return_code.value
46
107
  if isinstance(return_code, MethodReturnCode)
@@ -48,7 +109,7 @@ class MessageCreator:
48
109
  )
49
110
  msg_obj = Message(
50
111
  topic=topic,
51
- payload=b"{}",
112
+ payload=b"{}" if content_type == "application/json" else b"",
52
113
  qos=1,
53
114
  retain=False,
54
115
  correlation_data=(
@@ -57,7 +118,7 @@ class MessageCreator:
57
118
  else correlation_id
58
119
  ),
59
120
  user_properties={"ReturnCode": str(rc)},
60
- content_type="application/json",
121
+ content_type=content_type,
61
122
  )
62
123
  if (
63
124
  debug_info is not None and msg_obj.user_properties is not None
@@ -82,12 +143,26 @@ class MessageCreator:
82
143
  payload = response_obj.encode("utf-8")
83
144
  else:
84
145
  payload = response_obj
146
+ return cls.binary_response_message(
147
+ response_topic, payload, "application/json", return_code, correlation_id
148
+ )
149
+
150
+ @classmethod
151
+ def binary_response_message(
152
+ cls,
153
+ response_topic: str,
154
+ payload: bytes,
155
+ content_type: str,
156
+ return_code: Union[int, MethodReturnCode],
157
+ correlation_id: Union[str, bytes, None] = None,
158
+ ) -> Message:
159
+ cls._validate_topic(response_topic, "response_topic")
85
160
  rc = (
86
161
  return_code.value
87
162
  if isinstance(return_code, MethodReturnCode)
88
163
  else return_code
89
164
  )
90
- msg_obj = Message(
165
+ return Message(
91
166
  topic=response_topic,
92
167
  payload=payload,
93
168
  qos=1,
@@ -98,9 +173,8 @@ class MessageCreator:
98
173
  else correlation_id
99
174
  ),
100
175
  user_properties={"ReturnCode": str(rc)},
101
- content_type="application/json",
176
+ content_type=content_type,
102
177
  )
103
- return msg_obj
104
178
 
105
179
  @classmethod
106
180
  def property_state_message(
@@ -109,12 +183,24 @@ class MessageCreator:
109
183
  """
110
184
  Creates a retained message representing the state/value of a property.
111
185
  """
186
+ return cls.binary_property_state_message(
187
+ topic,
188
+ state_obj.model_dump_json(by_alias=True).encode("utf-8"),
189
+ "application/json",
190
+ state_version,
191
+ )
192
+
193
+ @classmethod
194
+ def binary_property_state_message(
195
+ cls, topic: str, payload: bytes, content_type: str, state_version: Optional[int] = None
196
+ ) -> Message:
197
+ cls._validate_topic(topic)
112
198
  msg_obj = Message(
113
199
  topic=topic,
114
- payload=state_obj.model_dump_json(by_alias=True).encode("utf-8"),
200
+ payload=payload,
115
201
  qos=1,
116
202
  retain=True,
117
- content_type="application/json",
203
+ content_type=content_type,
118
204
  )
119
205
  if state_version is not None:
120
206
  msg_obj.user_properties = {"PropertyVersion": str(state_version)}
@@ -132,12 +218,33 @@ class MessageCreator:
132
218
  """
133
219
  Creates a message representing a request to update a property.
134
220
  """
135
- msg_obj = Message(
221
+ return cls.binary_property_update_request_message(
222
+ topic,
223
+ property_obj.model_dump_json(by_alias=True).encode("utf-8"),
224
+ "application/json",
225
+ version,
226
+ response_topic,
227
+ correlation_id,
228
+ )
229
+
230
+ @classmethod
231
+ def binary_property_update_request_message(
232
+ cls,
233
+ topic: str,
234
+ payload: bytes,
235
+ content_type: str,
236
+ version: str,
237
+ response_topic: str,
238
+ correlation_id: Union[str, bytes, None] = None,
239
+ ) -> Message:
240
+ cls._validate_topic(topic)
241
+ cls._validate_topic(response_topic, "response_topic")
242
+ return Message(
136
243
  topic=topic,
137
- payload=property_obj.model_dump_json(by_alias=True).encode("utf-8"),
244
+ payload=payload,
138
245
  qos=1,
139
246
  retain=False,
140
- content_type="application/json",
247
+ content_type=content_type,
141
248
  response_topic=response_topic,
142
249
  correlation_data=(
143
250
  correlation_id.encode("utf-8")
@@ -146,7 +253,6 @@ class MessageCreator:
146
253
  ),
147
254
  user_properties={"PropertyVersion": str(version)},
148
255
  )
149
- return msg_obj
150
256
 
151
257
  @classmethod
152
258
  def property_response_message(
@@ -161,6 +267,28 @@ class MessageCreator:
161
267
  """
162
268
  Creates a message representing a response to a property update request.
163
269
  """
270
+ return cls.binary_property_response_message(
271
+ response_topic,
272
+ property_obj.model_dump_json(by_alias=True).encode("utf-8"),
273
+ "application/json",
274
+ version,
275
+ return_code,
276
+ correlation_id,
277
+ debug_info,
278
+ )
279
+
280
+ @classmethod
281
+ def binary_property_response_message(
282
+ cls,
283
+ response_topic: str,
284
+ payload: bytes,
285
+ content_type: str,
286
+ version: str,
287
+ return_code: Union[int, MethodReturnCode],
288
+ correlation_id: Union[str, bytes, None] = None,
289
+ debug_info: Optional[str] = None,
290
+ ) -> Message:
291
+ cls._validate_topic(response_topic, "response_topic")
164
292
  rc = (
165
293
  return_code.value
166
294
  if isinstance(return_code, MethodReturnCode)
@@ -168,10 +296,10 @@ class MessageCreator:
168
296
  )
169
297
  msg_obj = Message(
170
298
  topic=response_topic,
171
- payload=property_obj.model_dump_json(by_alias=True).encode("utf-8"),
299
+ payload=payload,
172
300
  qos=1,
173
301
  retain=False,
174
- content_type="application/json",
302
+ content_type=content_type,
175
303
  correlation_data=(
176
304
  correlation_id.encode("utf-8")
177
305
  if isinstance(correlation_id, str)
@@ -196,19 +324,37 @@ class MessageCreator:
196
324
  response_topic: str,
197
325
  correlation_id: Union[str, bytes, None] = None,
198
326
  ) -> Message:
327
+ return cls.binary_request_message(
328
+ topic,
329
+ request_obj.model_dump_json(by_alias=True).encode("utf-8"),
330
+ "application/json",
331
+ response_topic,
332
+ correlation_id,
333
+ )
334
+
335
+ @classmethod
336
+ def binary_request_message(
337
+ cls,
338
+ topic: str,
339
+ payload: bytes,
340
+ content_type: str,
341
+ response_topic: str,
342
+ correlation_id: Union[str, bytes, None] = None,
343
+ ) -> Message:
344
+ cls._validate_topic(topic)
345
+ cls._validate_topic(response_topic, "response_topic")
199
346
  if correlation_id is None:
200
347
  correlation_id = str(uuid.uuid4())
201
- msg_obj = Message(
348
+ return Message(
202
349
  topic=topic,
203
- payload=request_obj.model_dump_json(by_alias=True).encode("utf-8"),
350
+ payload=payload,
204
351
  qos=1,
205
352
  retain=False,
206
353
  response_topic=response_topic,
207
- content_type="application/json",
354
+ content_type=content_type,
208
355
  correlation_data=(
209
356
  correlation_id.encode("utf-8")
210
357
  if isinstance(correlation_id, str)
211
358
  else correlation_id
212
359
  ),
213
360
  )
214
- return msg_obj
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: stinger-python-utils
3
+ Version: 0.2.0
4
+ Summary: Common utilities for Stinger Python services.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.7
8
+ Requires-Dist: pydantic>=2.5.3
9
+ Requires-Dist: pyqttier>=0.2.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # stinger-python-utils
13
+
14
+ Shared utilities for Stinger Python services, providing convenient message creation for MQTT communication.
15
+
16
+
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ uv add stinger-python-utils
22
+ ```
23
+
24
+ ## MessageCreator
25
+
26
+ `MessageCreator` is a utility class for creating MQTT messages with standardized properties and payloads.
27
+
28
+ ### Basic Usage
29
+
30
+ ```python
31
+ from pydantic import BaseModel
32
+ from stinger_python_utils.message_creator import MessageCreator
33
+
34
+ class MyPayload(BaseModel):
35
+ name: str
36
+ value: int
37
+
38
+ payload = MyPayload(name="test", value=42)
39
+ message = MessageCreator.signal_message("my/topic", payload)
40
+ ```
41
+
42
+ ### Methods
43
+
44
+ | Method | Purpose | Return Code |
45
+ |--------|---------|-------------|
46
+ | `signal_message(topic, payload)` | Send a signal with one-time delivery | QoS 1, no retain |
47
+ | `status_message(topic, payload, expiry_seconds)` | Send status that expires | QoS 1, retained, with expiry |
48
+ | `error_response_message(topic, return_code, correlation_id, debug_info)` | Error response to a request | QoS 1, user properties: `ReturnCode` |
49
+ | `response_message(topic, payload, return_code, correlation_id)` | Successful response to a request | QoS 1, user properties: `ReturnCode` |
50
+ | `property_state_message(topic, payload, state_version)` | Publish property state | QoS 1, retained, JSON content type |
51
+ | `property_update_request_message(topic, payload, version, response_topic, correlation_id)` | Request property update | QoS 1, user property: `PropertyVersion` |
52
+ | `property_response_message(topic, payload, version, return_code, correlation_id, debug_info)` | Respond to property update | QoS 1, user properties: `ReturnCode`, `PropertyVersion` |
53
+ | `request_message(topic, payload, response_topic, correlation_id)` | Send a request (auto-generates UUID if no correlation_id) | QoS 1, auto correlation ID |
54
+
55
+ ### Example: Request/Response Pattern
56
+
57
+ ```python
58
+ from pydantic import BaseModel
59
+ from stinger_python_utils.message_creator import MessageCreator
60
+
61
+ class Request(BaseModel):
62
+ action: str
63
+
64
+ request = Request(action="start")
65
+ msg = MessageCreator.request_message(
66
+ "devices/cmd",
67
+ request,
68
+ response_topic="devices/response"
69
+ )
70
+ # Returns a Message with auto-generated correlation ID
71
+ ```
72
+
73
+ ### Example: Error Response
74
+
75
+ ```python
76
+ msg = MessageCreator.error_response_message(
77
+ "devices/response",
78
+ return_code=500,
79
+ correlation_id="req-123",
80
+ debug_info="Device not found"
81
+ )
82
+ # User properties include: ReturnCode=500, DebugInfo=Device not found
83
+ ```
84
+
@@ -0,0 +1,7 @@
1
+ stinger_python_utils/__init__.py,sha256=IjHRV0k2DNwvFrEHebmsXiBvmITE8nQUnsR07h9tVkU,7
2
+ stinger_python_utils/message_creator.py,sha256=SEdtwRJCDSBmVy_1ciJeHwPjK6pav4OIaF0W88RjOMY,11588
3
+ stinger_python_utils/return_codes.py,sha256=AAwshvHu3SBoctwMX35k3j666J4a7DQ3V8AnR7QBjC8,5114
4
+ stinger_python_utils-0.2.0.dist-info/METADATA,sha256=OjtR5mmKovSMZSbeMtaVhRZ-ZJ_GtUe99pKyKC0soUk,2769
5
+ stinger_python_utils-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
6
+ stinger_python_utils-0.2.0.dist-info/licenses/LICENSE,sha256=_T-8ExmblJbhv-1AxC6XDVEHg1JdJA-126NvRiMqS-I,1070
7
+ stinger_python_utils-0.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,20 +0,0 @@
1
- """Stinger MCP server – plugin-based MCP interface to stinger-ipc services.
2
-
3
- Public API re-exported here for convenience::
4
-
5
- from stinger_python_utils.mcp import StingerMCPPlugin, SignalDefinition, ...
6
- """
7
-
8
- from .plugin import (
9
- MethodDefinition,
10
- PropertyDefinition,
11
- SignalDefinition,
12
- StingerMCPPlugin,
13
- )
14
-
15
- __all__ = [
16
- "MethodDefinition",
17
- "PropertyDefinition",
18
- "SignalDefinition",
19
- "StingerMCPPlugin",
20
- ]
@@ -1,58 +0,0 @@
1
- """Entry-point for ``python -m stinger_python_utils.mcp``."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import asyncio
7
- import logging
8
-
9
-
10
- def main() -> None:
11
- parser = argparse.ArgumentParser(
12
- prog="stinger-mcp-server",
13
- description="Stinger MCP Server – expose stinger-ipc services over MCP",
14
- )
15
- parser.add_argument(
16
- "--transport",
17
- choices=["stdio", "sse", "streamable-http"],
18
- default="stdio",
19
- help="MCP transport to use (default: stdio)",
20
- )
21
- parser.add_argument(
22
- "--host",
23
- default="0.0.0.0",
24
- help="Bind address for SSE/streamable-http transport (default: 0.0.0.0)",
25
- )
26
- parser.add_argument(
27
- "--port",
28
- type=int,
29
- default=8000,
30
- help="Port for SSE/streamable-http transport (default: 8000)",
31
- )
32
- parser.add_argument(
33
- "--log-level",
34
- default="INFO",
35
- choices=["DEBUG", "INFO", "WARNING", "ERROR"],
36
- help="Logging level (default: INFO)",
37
- )
38
- args = parser.parse_args()
39
-
40
- logging.basicConfig(
41
- level=getattr(logging, args.log_level),
42
- format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
43
- )
44
-
45
- from .server import StingerMCPServer
46
-
47
- server = StingerMCPServer()
48
-
49
- if args.transport == "stdio":
50
- asyncio.run(server.run_stdio())
51
- elif args.transport == "sse":
52
- asyncio.run(server.run_sse(host=args.host, port=args.port))
53
- elif args.transport == "streamable-http":
54
- asyncio.run(server.run_streamable_http(host=args.host, port=args.port))
55
-
56
-
57
- if __name__ == "__main__":
58
- main()