stinger-python-utils 0.1.8rc4__tar.gz → 0.1.9__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 (21) hide show
  1. stinger_python_utils-0.1.9/.github/workflows/python-tests.yml +51 -0
  2. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/.github/workflows/python37.yml +1 -1
  3. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/PKG-INFO +4 -4
  4. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/pyproject.toml +5 -4
  5. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/plugin.py +17 -11
  6. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/server.py +26 -13
  7. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/message_creator.py +17 -0
  8. stinger_python_utils-0.1.9/uv.lock +2052 -0
  9. stinger_python_utils-0.1.8rc4/.github/workflows/python-tests.yml +0 -29
  10. stinger_python_utils-0.1.8rc4/.python-version +0 -4
  11. stinger_python_utils-0.1.8rc4/uv.lock +0 -868
  12. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/.gitignore +0 -0
  13. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/.vscode/settings.json +0 -0
  14. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/LICENSE +0 -0
  15. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/README.md +0 -0
  16. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/__init__.py +0 -0
  17. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/__init__.py +0 -0
  18. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/__main__.py +0 -0
  19. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/return_codes.py +0 -0
  20. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/test/__init__.py +0 -0
  21. {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/test/test_message_creator.py +0 -0
@@ -0,0 +1,51 @@
1
+ name: Python Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ # Python 3.7 requires a completely different setup because it is very EOL.
15
+ include:
16
+ - python-version: "3.10"
17
+ uv-sync-flags: "--all-extras"
18
+ mypy-flags: "--exclude '/mcp/'"
19
+ - python-version: "3.11"
20
+ uv-sync-flags: "--all-extras"
21
+ mypy-flags: ""
22
+ - python-version: "3.12"
23
+ uv-sync-flags: "--all-extras"
24
+ mypy-flags: ""
25
+ - python-version: "3.13"
26
+ uv-sync-flags: "--all-extras"
27
+ mypy-flags: ""
28
+ - python-version: "3.14"
29
+ uv-sync-flags: "--all-extras"
30
+ mypy-flags: ""
31
+ - python-version: "3.8"
32
+ uv-sync-flags: ""
33
+ mypy-flags: "--exclude '/mcp/'"
34
+ - python-version: "3.9"
35
+ uv-sync-flags: ""
36
+ mypy-flags: "--exclude '/mcp/'"
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - name: Install UV
40
+ uses: astral-sh/setup-uv@v7
41
+ with:
42
+ python-version: ${{ matrix.python-version }}
43
+ - name: Install project
44
+ run: |
45
+ uv sync --locked ${{ matrix.uv-sync-flags }} --dev
46
+ - name: Run unit tests
47
+ run: |
48
+ uv run pytest
49
+ - name: Mypy Type Check
50
+ run: |
51
+ uv run mypy --check-untyped-defs ${{ matrix.mypy-flags }} ./src/
@@ -7,7 +7,7 @@ on:
7
7
  branches: [ main ]
8
8
 
9
9
  jobs:
10
- test:
10
+ test_py37:
11
11
  runs-on: ubuntu-22.04
12
12
  steps:
13
13
  - uses: actions/checkout@v4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stinger-python-utils
3
- Version: 0.1.8rc4
3
+ Version: 0.1.9
4
4
  Summary: Common utilities for Stinger Python services.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -8,9 +8,9 @@ Requires-Python: >=3.7
8
8
  Requires-Dist: pydantic>=2.5.3
9
9
  Requires-Dist: pyqttier>=0.2.0
10
10
  Provides-Extra: mcp
11
- Requires-Dist: mcp>=1.0.0; extra == 'mcp'
12
- Requires-Dist: stevedore>=5.0; extra == 'mcp'
13
- Requires-Dist: uvicorn>=0.20.0; extra == 'mcp'
11
+ Requires-Dist: mcp>=1.27.0; (python_version >= '3.10') and extra == 'mcp'
12
+ Requires-Dist: stevedore>=5.7; (python_version >= '3.10') and extra == 'mcp'
13
+ Requires-Dist: uvicorn>=0.44.0; (python_version >= '3.10') and extra == 'mcp'
14
14
  Description-Content-Type: text/markdown
15
15
 
16
16
  # stinger-python-utils
@@ -4,11 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stinger-python-utils"
7
- version = "0.1.8rc4"
7
+ version = "0.1.9"
8
8
  description = "Common utilities for Stinger Python services."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.7"
12
+
12
13
  dependencies = [
13
14
  "pydantic>=2.5.3",
14
15
  "pyqttier>=0.2.0",
@@ -16,9 +17,9 @@ dependencies = [
16
17
 
17
18
  [project.optional-dependencies]
18
19
  mcp = [
19
- "mcp>=1.0.0",
20
- "stevedore>=5.0",
21
- "uvicorn>=0.20.0",
20
+ "mcp>=1.27.0; python_version >= '3.10'",
21
+ "stevedore>=5.7; python_version >= '3.10'",
22
+ "uvicorn>=0.44.0; python_version >= '3.10'",
22
23
  ]
23
24
 
24
25
  [project.scripts]
@@ -17,6 +17,8 @@ from abc import ABC, abstractmethod
17
17
  from dataclasses import dataclass, field
18
18
  from typing import Any
19
19
 
20
+ from pydantic import BaseModel
21
+
20
22
 
21
23
  # ------------------------------------------------------------------
22
24
  # Data models
@@ -66,19 +68,18 @@ class MethodDefinition:
66
68
  """Describes a callable method on a stinger-ipc client.
67
69
 
68
70
  Each method is exposed as an MCP **tool** whose ``inputSchema`` is
69
- *arguments_schema*.
71
+ derived from *arguments_model* via ``model_json_schema()``.
70
72
 
71
- *arguments_schema* must be a valid `JSON Schema`_ of type
72
- ``"object"``. The property names **must** match the keyword
73
- argument names accepted by the client method.
73
+ *arguments_model* must be a :class:`pydantic.BaseModel` subclass.
74
+ When the tool is invoked, the raw JSON arguments are loaded into
75
+ an instance of this model and the model is passed to
76
+ ``call_{method_name}`` on the client.
74
77
 
75
- .. _JSON Schema: https://json-schema.org/
78
+ If *arguments_model* is ``None`` the tool accepts no arguments.
76
79
  """
77
80
 
78
81
  name: str
79
- arguments_schema: dict[str, Any] = field(
80
- default_factory=lambda: {"type": "object"}
81
- )
82
+ arguments_model: type[BaseModel] | None = None
82
83
  description: str = ""
83
84
 
84
85
 
@@ -189,19 +190,24 @@ class StingerMCPPlugin(ABC):
189
190
  setattr(client, prop_name, list(arguments.values())[0])
190
191
 
191
192
  def call_method(
192
- self, client: Any, method_name: str, arguments: dict[str, Any]
193
+ self, client: Any, method_name: str, arguments: BaseModel | None
193
194
  ) -> Any:
194
195
  """Invoke *method_name* on *client* with *arguments*.
195
196
 
197
+ *arguments* is a validated :class:`pydantic.BaseModel` instance
198
+ (or ``None`` when the method takes no parameters).
199
+
196
200
  The default implementation calls::
197
201
 
198
- getattr(client, method_name)(**arguments)
202
+ getattr(client, f"call_{method_name}")(arguments)
199
203
 
200
204
  and returns whatever the method returns (typically a
201
205
  ``concurrent.futures.Future``).
202
206
  """
203
207
  method = getattr(client, f"call_{method_name}")
204
- return method(**arguments)
208
+ if arguments is None:
209
+ return method()
210
+ return method(arguments)
205
211
 
206
212
  def serialize_property(self, prop_name: str, value: Any) -> str:
207
213
  """Serialize a property *value* to a JSON string for the MCP resource.
@@ -193,7 +193,11 @@ class StingerMCPServer:
193
193
  or f"Call {mdef.name} on {state.plugin_name} "
194
194
  f"instance {state.instance_id}"
195
195
  ),
196
- inputSchema=mdef.arguments_schema,
196
+ inputSchema=(
197
+ mdef.arguments_model.model_json_schema()
198
+ if mdef.arguments_model is not None
199
+ else {"type": "object"}
200
+ ),
197
201
  )
198
202
  )
199
203
 
@@ -214,8 +218,8 @@ class StingerMCPServer:
214
218
 
215
219
  def _resolve_tool(
216
220
  self, name: str
217
- ) -> tuple[InstanceState, str, str] | None:
218
- """Map a tool *name* → ``(state, kind, item_name)``.
221
+ ) -> tuple[InstanceState, str, MethodDefinition | PropertyDefinition] | None:
222
+ """Map a tool *name* → ``(state, kind, definition)``.
219
223
 
220
224
  *kind* is ``"method"`` or ``"property"``. Methods are checked
221
225
  first so that a method named ``set_foo`` takes precedence over
@@ -235,7 +239,7 @@ class StingerMCPServer:
235
239
  # Methods take priority
236
240
  for mdef in state.plugin.get_methods():
237
241
  if _sanitize(mdef.name) == remainder:
238
- return state, "method", mdef.name
242
+ return state, "method", mdef
239
243
 
240
244
  # Property setters: set_<prop_name>
241
245
  if remainder.startswith("set_"):
@@ -245,7 +249,7 @@ class StingerMCPServer:
245
249
  _sanitize(pdef.name) == prop_token
246
250
  and not pdef.readonly
247
251
  ):
248
- return state, "property", pdef.name
252
+ return state, "property", pdef
249
253
 
250
254
  return None
251
255
 
@@ -256,23 +260,32 @@ class StingerMCPServer:
256
260
  if target is None:
257
261
  raise ValueError(f"Unknown tool: {name}")
258
262
 
259
- state, kind, item_name = target
263
+ state, kind, defn = target
260
264
 
261
265
  if kind == "property":
262
266
  try:
263
- state.plugin.write_property(state.client, item_name, arguments)
264
- text = json.dumps({"status": "ok", "property": item_name})
267
+ state.plugin.write_property(
268
+ state.client, defn.name, arguments
269
+ )
270
+ text = json.dumps({"status": "ok", "property": defn.name})
265
271
  except Exception as exc:
266
- logger.exception("Error setting property %s", item_name)
272
+ logger.exception("Error setting property %s", defn.name)
267
273
  text = json.dumps(
268
274
  {"status": "error", "error": str(exc)}, default=str
269
275
  )
270
276
  return [types.TextContent(type="text", text=text)]
271
277
 
272
278
  # kind == "method"
279
+ assert isinstance(defn, MethodDefinition)
273
280
  try:
281
+ # Load the pydantic model from the raw arguments
282
+ model = (
283
+ defn.arguments_model(**arguments)
284
+ if defn.arguments_model is not None
285
+ else None
286
+ )
274
287
  result = state.plugin.call_method(
275
- state.client, item_name, arguments
288
+ state.client, defn.name, model
276
289
  )
277
290
  if isinstance(result, Future):
278
291
  result = await _resolve_future(result)
@@ -284,7 +297,7 @@ class StingerMCPServer:
284
297
  else:
285
298
  text = json.dumps(result, default=str)
286
299
  except Exception as exc:
287
- logger.exception("Error calling method %s", item_name)
300
+ logger.exception("Error calling method %s", defn.name)
288
301
  text = json.dumps(
289
302
  {"status": "error", "error": str(exc)}, default=str
290
303
  )
@@ -364,11 +377,11 @@ class StingerMCPServer:
364
377
 
365
378
  def _load_plugins(self) -> None:
366
379
  def _on_failure(
367
- _mgr: ExtensionManager, entrypoint: Any, err: Exception
380
+ _mgr: Any, entrypoint: Any, err: BaseException
368
381
  ) -> None:
369
382
  logger.error("Failed to load plugin %s: %s", entrypoint, err)
370
383
 
371
- mgr = ExtensionManager(
384
+ mgr: ExtensionManager[Any] = ExtensionManager(
372
385
  namespace=self.STEVEDORE_NAMESPACE,
373
386
  invoke_on_load=True,
374
387
  on_load_failure_callback=_on_failure,
@@ -7,8 +7,16 @@ 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
+ cls._validate_topic(topic)
12
20
  return Message(
13
21
  topic=topic,
14
22
  payload=payload.model_dump_json(by_alias=True).encode("utf-8"),
@@ -21,6 +29,7 @@ class MessageCreator:
21
29
  def status_message(
22
30
  cls, topic, status_message: BaseModel, expiry_seconds: int
23
31
  ) -> Message:
32
+ cls._validate_topic(topic)
24
33
  return Message(
25
34
  topic=topic,
26
35
  payload=status_message.model_dump_json(by_alias=True).encode("utf-8"),
@@ -41,6 +50,7 @@ class MessageCreator:
41
50
  """
42
51
  This could be used for a response to a request, but where there was an error fulfilling the request.
43
52
  """
53
+ cls._validate_topic(topic)
44
54
  rc = (
45
55
  return_code.value
46
56
  if isinstance(return_code, MethodReturnCode)
@@ -76,6 +86,7 @@ class MessageCreator:
76
86
  """
77
87
  This could be used for a successful response to a request.
78
88
  """
89
+ cls._validate_topic(response_topic, "response_topic")
79
90
  if isinstance(response_obj, BaseModel):
80
91
  payload = response_obj.model_dump_json(by_alias=True).encode("utf-8")
81
92
  elif isinstance(response_obj, str):
@@ -109,6 +120,7 @@ class MessageCreator:
109
120
  """
110
121
  Creates a retained message representing the state/value of a property.
111
122
  """
123
+ cls._validate_topic(topic)
112
124
  msg_obj = Message(
113
125
  topic=topic,
114
126
  payload=state_obj.model_dump_json(by_alias=True).encode("utf-8"),
@@ -132,6 +144,8 @@ class MessageCreator:
132
144
  """
133
145
  Creates a message representing a request to update a property.
134
146
  """
147
+ cls._validate_topic(topic)
148
+ cls._validate_topic(response_topic, "response_topic")
135
149
  msg_obj = Message(
136
150
  topic=topic,
137
151
  payload=property_obj.model_dump_json(by_alias=True).encode("utf-8"),
@@ -161,6 +175,7 @@ class MessageCreator:
161
175
  """
162
176
  Creates a message representing a response to a property update request.
163
177
  """
178
+ cls._validate_topic(response_topic, "response_topic")
164
179
  rc = (
165
180
  return_code.value
166
181
  if isinstance(return_code, MethodReturnCode)
@@ -196,6 +211,8 @@ class MessageCreator:
196
211
  response_topic: str,
197
212
  correlation_id: Union[str, bytes, None] = None,
198
213
  ) -> Message:
214
+ cls._validate_topic(topic)
215
+ cls._validate_topic(response_topic, "response_topic")
199
216
  if correlation_id is None:
200
217
  correlation_id = str(uuid.uuid4())
201
218
  msg_obj = Message(