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.
- stinger_python_utils-0.1.9/.github/workflows/python-tests.yml +51 -0
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/.github/workflows/python37.yml +1 -1
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/PKG-INFO +4 -4
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/pyproject.toml +5 -4
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/plugin.py +17 -11
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/server.py +26 -13
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/message_creator.py +17 -0
- stinger_python_utils-0.1.9/uv.lock +2052 -0
- stinger_python_utils-0.1.8rc4/.github/workflows/python-tests.yml +0 -29
- stinger_python_utils-0.1.8rc4/.python-version +0 -4
- stinger_python_utils-0.1.8rc4/uv.lock +0 -868
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/.gitignore +0 -0
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/.vscode/settings.json +0 -0
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/LICENSE +0 -0
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/README.md +0 -0
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/__init__.py +0 -0
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/__init__.py +0 -0
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/__main__.py +0 -0
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/return_codes.py +0 -0
- {stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/test/__init__.py +0 -0
- {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/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stinger-python-utils
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
12
|
-
Requires-Dist: stevedore>=5.
|
|
13
|
-
Requires-Dist: uvicorn>=0.
|
|
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.
|
|
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.
|
|
20
|
-
"stevedore>=5.
|
|
21
|
-
"uvicorn>=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]
|
{stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/plugin.py
RENAMED
|
@@ -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
|
-
*
|
|
71
|
+
derived from *arguments_model* via ``model_json_schema()``.
|
|
70
72
|
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
78
|
+
If *arguments_model* is ``None`` the tool accepts no arguments.
|
|
76
79
|
"""
|
|
77
80
|
|
|
78
81
|
name: str
|
|
79
|
-
|
|
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:
|
|
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)(
|
|
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
|
-
|
|
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.
|
{stinger_python_utils-0.1.8rc4 → stinger_python_utils-0.1.9}/src/stinger_python_utils/mcp/server.py
RENAMED
|
@@ -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=
|
|
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,
|
|
218
|
-
"""Map a tool *name* → ``(state, kind,
|
|
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
|
|
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
|
|
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,
|
|
263
|
+
state, kind, defn = target
|
|
260
264
|
|
|
261
265
|
if kind == "property":
|
|
262
266
|
try:
|
|
263
|
-
state.plugin.write_property(
|
|
264
|
-
|
|
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",
|
|
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,
|
|
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",
|
|
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:
|
|
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(
|