sdcmodeler 4.0.0__tar.gz → 4.2.0__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.
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/PKG-INFO +6 -6
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/README.md +5 -5
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/pyproject.toml +1 -1
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/__init__.py +1 -1
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/cli.py +16 -5
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/client.py +102 -17
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/mcp_server.py +49 -22
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/PKG-INFO +6 -6
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/tests/test_client.py +69 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/tests/test_mcp_server.py +33 -1
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/LICENSE +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/setup.cfg +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/pricing.py +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/SOURCES.txt +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/dependency_links.txt +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/entry_points.txt +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/requires.txt +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/top_level.txt +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/tests/test_cli.py +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/tests/test_pricing.py +0 -0
- {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/tests/test_registry_launch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdcmodeler
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: The modeler's client for SDCStudio: search the catalog, estimate, assemble and publish SDC4 data models, generate applications; a CLI and an MCP server over one client
|
|
5
5
|
Author: Axius SDC, Inc.
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -68,11 +68,11 @@ tree = {
|
|
|
68
68
|
],
|
|
69
69
|
"clusters": [],
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
result = c.assemble("Vitals", tree)
|
|
71
|
+
estimate = c.estimate(tree, "Vitals") # the server's estimate: reuse, mint, problems, total_credits, wallet, key, estimate_id
|
|
72
|
+
result = c.assemble("Vitals", tree, estimate_id=estimate["estimate_id"]) # 500 credits plus the mints
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
Every leaf existing: the server assembles, publishes and generates the package in one call (`result["mode"] == "reuse"`). Some leaves new: the pipeline runs and `result["
|
|
75
|
+
Every leaf existing: the server assembles, publishes and generates the package in one call (`result["mode"] == "reuse"`; `published` and `generated` say so). Some leaves new: the pipeline runs and `result["job_ct_id"]` is polled with `c.job_status(...)`, or `c.wait_for_job(...)`, until `completed`, when the job has published and generated as asked and its result carries the model's links. `publish=False` leaves a draft for a person to review. The server refuses a build whose tree changed since the estimate.
|
|
76
76
|
|
|
77
77
|
A 402 is `InsufficientCredits` with the numbers in credits. A 403 from the key's policy (no `spend` scope, or past its limit) is `KeyPolicyRefused`. The client never funds a wallet; a person does that at the wallet page.
|
|
78
78
|
|
|
@@ -106,8 +106,8 @@ A JSON-RPC 2.0 stdio server, no SDK, the way sdcvalidator and sdcgovernance are
|
|
|
106
106
|
| `search_components` | the public catalog by label, type and project | no |
|
|
107
107
|
| `list_models`, `get_model` | published models and their artifact routes | no |
|
|
108
108
|
| `estimate_model` | what a tree would reuse, mint and cost; returns `estimate_id` | no |
|
|
109
|
-
| `build_model` | assemble and publish; needs the `estimate_id` the person saw; refuses past the key's limit | yes |
|
|
110
|
-
| `job_status` |
|
|
109
|
+
| `build_model` | assemble, publish and generate (or `publish: false` for a draft); needs the `estimate_id` the person saw; refuses past the key's limit | yes |
|
|
110
|
+
| `job_status` | one job by id: status, step, cost so far, the model's links | no |
|
|
111
111
|
| `generate_app` | generate and download the application; needs `confirm_charge`; 1,500 credits the first time, free after | yes |
|
|
112
112
|
| `wallet` | balance in credits and the key's policy | no |
|
|
113
113
|
|
|
@@ -42,11 +42,11 @@ tree = {
|
|
|
42
42
|
],
|
|
43
43
|
"clusters": [],
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
result = c.assemble("Vitals", tree)
|
|
45
|
+
estimate = c.estimate(tree, "Vitals") # the server's estimate: reuse, mint, problems, total_credits, wallet, key, estimate_id
|
|
46
|
+
result = c.assemble("Vitals", tree, estimate_id=estimate["estimate_id"]) # 500 credits plus the mints
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
Every leaf existing: the server assembles, publishes and generates the package in one call (`result["mode"] == "reuse"`). Some leaves new: the pipeline runs and `result["
|
|
49
|
+
Every leaf existing: the server assembles, publishes and generates the package in one call (`result["mode"] == "reuse"`; `published` and `generated` say so). Some leaves new: the pipeline runs and `result["job_ct_id"]` is polled with `c.job_status(...)`, or `c.wait_for_job(...)`, until `completed`, when the job has published and generated as asked and its result carries the model's links. `publish=False` leaves a draft for a person to review. The server refuses a build whose tree changed since the estimate.
|
|
50
50
|
|
|
51
51
|
A 402 is `InsufficientCredits` with the numbers in credits. A 403 from the key's policy (no `spend` scope, or past its limit) is `KeyPolicyRefused`. The client never funds a wallet; a person does that at the wallet page.
|
|
52
52
|
|
|
@@ -80,8 +80,8 @@ A JSON-RPC 2.0 stdio server, no SDK, the way sdcvalidator and sdcgovernance are
|
|
|
80
80
|
| `search_components` | the public catalog by label, type and project | no |
|
|
81
81
|
| `list_models`, `get_model` | published models and their artifact routes | no |
|
|
82
82
|
| `estimate_model` | what a tree would reuse, mint and cost; returns `estimate_id` | no |
|
|
83
|
-
| `build_model` | assemble and publish; needs the `estimate_id` the person saw; refuses past the key's limit | yes |
|
|
84
|
-
| `job_status` |
|
|
83
|
+
| `build_model` | assemble, publish and generate (or `publish: false` for a draft); needs the `estimate_id` the person saw; refuses past the key's limit | yes |
|
|
84
|
+
| `job_status` | one job by id: status, step, cost so far, the model's links | no |
|
|
85
85
|
| `generate_app` | generate and download the application; needs `confirm_charge`; 1,500 credits the first time, free after | yes |
|
|
86
86
|
| `wallet` | balance in credits and the key's policy | no |
|
|
87
87
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sdcmodeler"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.2.0"
|
|
8
8
|
description = "The modeler's client for SDCStudio: search the catalog, estimate, assemble and publish SDC4 data models, generate applications; a CLI and an MCP server over one client"
|
|
9
9
|
license = "Apache-2.0"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -17,7 +17,7 @@ servers: sdcvalidator validates, sdcgovernance governs, sdcreader reads,
|
|
|
17
17
|
sdcmodeler models. It also names the concept SDCStudio already has: every user
|
|
18
18
|
is a Modeler, and the Modeler's projects scope every call.
|
|
19
19
|
"""
|
|
20
|
-
__version__ = "4.
|
|
20
|
+
__version__ = "4.2.0"
|
|
21
21
|
|
|
22
22
|
from sdcmodeler.client import ( # noqa: E402
|
|
23
23
|
AssemblyRefused,
|
|
@@ -50,11 +50,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
50
50
|
|
|
51
51
|
s = sub.add_parser("build", help="assemble (and, with every leaf existing, publish and generate) a model; spends credits")
|
|
52
52
|
s.add_argument("tree"); s.add_argument("--title", required=True); s.add_argument("--description", default="")
|
|
53
|
-
s.
|
|
54
|
-
|
|
55
|
-
s
|
|
56
|
-
s.add_argument("
|
|
57
|
-
|
|
53
|
+
s.add_argument("--estimate-id", default=None, help="the id `estimate` printed; the server refuses a build whose tree changed since")
|
|
54
|
+
s.add_argument("--draft", action="store_true", help="do not publish (nothing is generated either)")
|
|
55
|
+
s.add_argument("--no-generate", action="store_true", help="publish without building the package")
|
|
56
|
+
s.add_argument("--wait", action="store_true", help="on the mixed path, poll the job until it completes or fails")
|
|
57
|
+
def _build(c, a):
|
|
58
|
+
result = c.assemble(a.title, _load_tree(a.tree), description=a.description, estimate_id=a.estimate_id,
|
|
59
|
+
publish=not a.draft, generate=not a.no_generate)
|
|
60
|
+
if a.wait and result.get("mode") == "mixed" and result.get("job_ct_id"):
|
|
61
|
+
result["job"] = c.wait_for_job(result["job_ct_id"])
|
|
62
|
+
return result
|
|
63
|
+
s.set_defaults(func=_build)
|
|
64
|
+
|
|
65
|
+
s = sub.add_parser("job", help="one job by id (from build), or every job with --list")
|
|
66
|
+
s.add_argument("job_ct_id", nargs="?")
|
|
67
|
+
s.add_argument("--list", action="store_true")
|
|
68
|
+
s.set_defaults(func=lambda c, a: c.jobs() if a.list or not a.job_ct_id else c.job_status(a.job_ct_id))
|
|
58
69
|
|
|
59
70
|
s = sub.add_parser("package", help="generate a published model's package and download it")
|
|
60
71
|
s.add_argument("ct_id"); s.add_argument("--dir", default=".")
|
|
@@ -88,6 +88,7 @@ class SDCStudioClient:
|
|
|
88
88
|
self._http = http or httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout)
|
|
89
89
|
if http is not None and self.api_key and "Authorization" not in http.headers:
|
|
90
90
|
http.headers["Authorization"] = f"Token {self.api_key}"
|
|
91
|
+
self._default_project_checked = False
|
|
91
92
|
|
|
92
93
|
def close(self) -> None:
|
|
93
94
|
self._http.close()
|
|
@@ -193,25 +194,71 @@ class SDCStudioClient:
|
|
|
193
194
|
|
|
194
195
|
# ------------------------------------------------------------------ building
|
|
195
196
|
|
|
196
|
-
def estimate(self, tree: dict[str, Any], title: str = "") -> dict[str, Any]:
|
|
197
|
-
"""
|
|
198
|
-
|
|
197
|
+
def estimate(self, tree: dict[str, Any], title: str = "", *, allow_retired: bool = False) -> dict[str, Any]:
|
|
198
|
+
"""
|
|
199
|
+
What the assembly would reuse, mint and cost.
|
|
200
|
+
|
|
201
|
+
With a key, the server's estimate (``POST /api/v1/dmgen/assemble/estimate/``,
|
|
202
|
+
SDCStudio 4.17.0): every leaf resolved as ``assemble/`` resolves it, problems
|
|
203
|
+
named, the wallet's and the key's position, and the same ``estimate_id`` the
|
|
204
|
+
local computation produces. Without a key, or against a deployment older
|
|
205
|
+
than 4.17.0, the local estimate (``sdcmodeler.pricing``), marked
|
|
206
|
+
``"source": "local"``.
|
|
207
|
+
"""
|
|
208
|
+
local = pricing.estimate_tree(tree, title)
|
|
209
|
+
if not self.api_key:
|
|
210
|
+
local["source"] = "local"
|
|
211
|
+
return local
|
|
212
|
+
payload: dict[str, Any] = {"title": title, "data": tree}
|
|
213
|
+
if allow_retired:
|
|
214
|
+
payload["allow_retired"] = True
|
|
215
|
+
resp = self._http.post("/api/v1/dmgen/assemble/estimate/", json=payload)
|
|
216
|
+
if resp.status_code == 404:
|
|
217
|
+
local["source"] = "local"
|
|
218
|
+
return local
|
|
219
|
+
self._raise(resp)
|
|
220
|
+
server = _body(resp)
|
|
221
|
+
server["source"] = "server"
|
|
222
|
+
return server
|
|
199
223
|
|
|
200
224
|
def assemble(self, title: str, tree: dict[str, Any], *, description: str = "",
|
|
201
|
-
contextual: dict | None = None, allow_retired: bool = False
|
|
225
|
+
contextual: dict | None = None, allow_retired: bool = False,
|
|
226
|
+
estimate_id: str | None = None, publish: bool = True, generate: bool = True) -> dict[str, Any]:
|
|
202
227
|
"""
|
|
203
228
|
POST ``/api/v1/dmgen/assemble/``.
|
|
204
229
|
|
|
205
|
-
Every leaf existing: the server assembles,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
Every leaf existing: the server assembles, and with ``publish`` (default)
|
|
231
|
+
publishes and with ``generate`` (default) builds the package, in one call;
|
|
232
|
+
200 with the model, ``published``, ``generated`` and ``model_url``
|
|
233
|
+
(``mode`` ``"reuse"``). Some leaves new: 202 with ``job_ct_id`` (SDCStudio
|
|
234
|
+
4.17.0) to poll with ``job_status`` (``mode`` ``"mixed"``); when the
|
|
235
|
+
pipeline completes, the job publishes bottom-up and generates as the flags
|
|
236
|
+
asked (4.17.2) and its result says ``published`` and ``generated``.
|
|
237
|
+
``estimate_id`` is the id of the estimate the person saw; the server
|
|
238
|
+
refuses a build whose tree no longer matches it (409 ``stale_estimate``,
|
|
239
|
+
raised as ``AssemblyRefused``).
|
|
209
240
|
"""
|
|
210
|
-
|
|
241
|
+
# assemble/ needs the Modeler's default project and answers 400 without one;
|
|
242
|
+
# the PRD's "default project created on first use" (3.1.4) is done here,
|
|
243
|
+
# once per client, until the server does it.
|
|
244
|
+
if not self._default_project_checked:
|
|
245
|
+
self.ensure_default_project()
|
|
246
|
+
self._default_project_checked = True
|
|
247
|
+
# The server refuses a blank description ("may not be blank") but accepts its
|
|
248
|
+
# absence, so an empty one is left out.
|
|
249
|
+
payload: dict[str, Any] = {"title": title, "data": tree}
|
|
250
|
+
if description:
|
|
251
|
+
payload["description"] = description
|
|
211
252
|
if contextual:
|
|
212
253
|
payload["contextual"] = contextual
|
|
213
254
|
if allow_retired:
|
|
214
255
|
payload["allow_retired"] = True
|
|
256
|
+
if estimate_id:
|
|
257
|
+
payload["estimate_id"] = estimate_id
|
|
258
|
+
if not publish:
|
|
259
|
+
payload["publish"] = False
|
|
260
|
+
if not generate or not publish:
|
|
261
|
+
payload["generate"] = False
|
|
215
262
|
resp = self._http.post("/api/v1/dmgen/assemble/", json=payload)
|
|
216
263
|
if resp.status_code == 409:
|
|
217
264
|
raise AssemblyRefused(409, _body(resp), (_body(resp) or {}).get("error", "Assembly refused.") if isinstance(_body(resp), dict) else "Assembly refused.")
|
|
@@ -221,16 +268,51 @@ class SDCStudioClient:
|
|
|
221
268
|
data["mode"] = "mixed" if resp.status_code == 202 else "reuse"
|
|
222
269
|
return data
|
|
223
270
|
|
|
224
|
-
def job_status(self,
|
|
225
|
-
"""
|
|
271
|
+
def job_status(self, job_ct_id: str) -> dict[str, Any]:
|
|
272
|
+
"""
|
|
273
|
+
The job resource (SDCStudio 4.17.0): ``status`` (queued, running, completed,
|
|
274
|
+
failed), ``step``, ``cost_credits``, ``result`` with the model's links on
|
|
275
|
+
completion, ``error`` on failure.
|
|
276
|
+
"""
|
|
277
|
+
return self._get(f"/api/v1/jobs/{job_ct_id}/")
|
|
278
|
+
|
|
279
|
+
def wait_for_job(self, job_ct_id: str, *, timeout: float = 900.0, interval: float = 3.0,
|
|
280
|
+
max_interval: float = 20.0) -> dict[str, Any]:
|
|
281
|
+
"""
|
|
282
|
+
Poll the job until it is ``completed`` or ``failed`` (or ``timeout`` seconds pass).
|
|
283
|
+
|
|
284
|
+
Backs off from ``interval`` to ``max_interval``. Returns the last job
|
|
285
|
+
record; raises ``TimeoutError`` with it attached when time runs out.
|
|
286
|
+
"""
|
|
287
|
+
import time
|
|
288
|
+
|
|
289
|
+
deadline = time.monotonic() + timeout
|
|
290
|
+
wait = interval
|
|
291
|
+
while True:
|
|
292
|
+
job = self.job_status(job_ct_id)
|
|
293
|
+
if job.get("status") in ("completed", "failed"):
|
|
294
|
+
return job
|
|
295
|
+
if time.monotonic() >= deadline:
|
|
296
|
+
err = TimeoutError(f"Job {job_ct_id} still {job.get('status')} after {timeout:.0f}s")
|
|
297
|
+
err.job = job # type: ignore[attr-defined]
|
|
298
|
+
raise err
|
|
299
|
+
time.sleep(wait)
|
|
300
|
+
wait = min(max_interval, wait * 1.5)
|
|
301
|
+
|
|
302
|
+
def jobs(self, *, kind: str | None = None, data_source_ct_id: str | None = None) -> dict[str, Any]:
|
|
303
|
+
"""The caller's jobs, newest first; narrowed by ``kind`` or ``data_source_ct_id``."""
|
|
304
|
+
return self._get("/api/v1/jobs/", kind=kind, data_source=data_source_ct_id)
|
|
305
|
+
|
|
306
|
+
def pipeline_status(self, data_source_ct_id: str) -> dict[str, Any]:
|
|
307
|
+
"""The pipeline's own status for a data source, for deployments older than 4.17.0."""
|
|
226
308
|
return self._get(f"/api/v1/uploader/api/data-sources/{data_source_ct_id}/workflow_status/")
|
|
227
309
|
|
|
228
310
|
def publish_model(self, ct_id: str) -> dict[str, Any]:
|
|
229
311
|
return self._post(f"/api/v1/dmgen/dms/{ct_id}/publish/")
|
|
230
312
|
|
|
231
|
-
def generate_package(self, ct_id: str) -> dict[str, Any]:
|
|
232
|
-
"""Build the published model's package (free
|
|
233
|
-
return self._post(f"/api/v1/dmgen/dms/{ct_id}/generate/")
|
|
313
|
+
def generate_package(self, ct_id: str, *, as_job: bool = False) -> dict[str, Any]:
|
|
314
|
+
"""Build the published model's package (free). ``as_job`` answers a job to poll instead of waiting (4.17.0)."""
|
|
315
|
+
return self._post(f"/api/v1/dmgen/dms/{ct_id}/generate/", {"async": True} if as_job else {})
|
|
234
316
|
|
|
235
317
|
def download_package(self, ct_id: str, dest_dir: str | Path) -> Path:
|
|
236
318
|
content, name = self._fetch_bytes(f"/api/v1/dmgen/dms/{ct_id}/download_package/")
|
|
@@ -239,11 +321,14 @@ class SDCStudioClient:
|
|
|
239
321
|
path.write_bytes(content)
|
|
240
322
|
return path
|
|
241
323
|
|
|
242
|
-
def generate_app(self, ct_id: str, profile: str = "lightweight") -> dict[str, Any]:
|
|
243
|
-
"""Generate the application (free
|
|
324
|
+
def generate_app(self, ct_id: str, profile: str = "lightweight", *, as_job: bool = False) -> dict[str, Any]:
|
|
325
|
+
"""Generate the application (free). ``profile`` is ``lightweight`` or ``enterprise``; ``as_job`` answers a job to poll (4.17.0)."""
|
|
244
326
|
if profile not in ("lightweight", "enterprise"):
|
|
245
327
|
raise ValueError("profile must be 'lightweight' or 'enterprise'")
|
|
246
|
-
|
|
328
|
+
payload: dict[str, Any] = {"profile": profile}
|
|
329
|
+
if as_job:
|
|
330
|
+
payload["async"] = True
|
|
331
|
+
return self._post(f"/api/v1/dmgen/dms/{ct_id}/generate_app/", payload)
|
|
247
332
|
|
|
248
333
|
def download_app(self, ct_id: str, dest_dir: str | Path, profile: str = "lightweight") -> tuple[Path, bool | None]:
|
|
249
334
|
"""
|
|
@@ -125,11 +125,13 @@ TOOLS: list[dict[str, Any]] = [
|
|
|
125
125
|
"name": "build_model",
|
|
126
126
|
"description": (
|
|
127
127
|
"Assemble and publish the model the person estimated and approved. Needs the estimate_id "
|
|
128
|
-
"from estimate_model for the same title and tree; refuses otherwise,
|
|
129
|
-
"estimate exceeds what the key may still spend. Spends credits:
|
|
130
|
-
"100 per minted component. With every leaf existing the
|
|
131
|
-
"package generated at once (result has ct_id and artifacts); with
|
|
132
|
-
"pipeline runs and the result has a
|
|
128
|
+
"from estimate_model for the same title and tree; refuses otherwise, the server refuses a stale "
|
|
129
|
+
"one too, and refuses when the estimate exceeds what the key may still spend. Spends credits: "
|
|
130
|
+
"500 for the model plus 100 per minted component or new cluster. With every leaf existing the "
|
|
131
|
+
"model is published and its package generated at once (result has ct_id and artifacts); with "
|
|
132
|
+
"new leaves the pipeline runs and the result has a job_ct_id to poll with job_status; when the "
|
|
133
|
+
"job completes it has published and generated as asked and carries the model's links. publish "
|
|
134
|
+
"false leaves a draft for a person to review. A 402 "
|
|
133
135
|
"means the wallet needs funding at the wallet page; report it and stop."
|
|
134
136
|
),
|
|
135
137
|
"inputSchema": {
|
|
@@ -140,14 +142,20 @@ TOOLS: list[dict[str, Any]] = [
|
|
|
140
142
|
"tree": {"type": "object"},
|
|
141
143
|
"description": {"type": "string", "default": ""},
|
|
142
144
|
"contextual": {"type": "object", "description": "Optional contextual component references (audit, attestation, party, subject, provider, participation, protocol, workflow, acs)."},
|
|
145
|
+
"publish": {"type": "boolean", "default": True, "description": "Publish the clusters and the model (false leaves a draft for a person to review)."},
|
|
146
|
+
"generate": {"type": "boolean", "default": True, "description": "Build the package after publishing (needs publish)."},
|
|
143
147
|
},
|
|
144
148
|
"required": ["estimate_id", "title", "tree"],
|
|
145
149
|
},
|
|
146
150
|
},
|
|
147
151
|
{
|
|
148
152
|
"name": "job_status",
|
|
149
|
-
"description":
|
|
150
|
-
|
|
153
|
+
"description": (
|
|
154
|
+
"One job (job_ct_id from build_model): status queued, running, completed or failed; step; "
|
|
155
|
+
"cost_credits so far; on completion the model's links (dm_ct_id, catalog_url, package_url); "
|
|
156
|
+
"on failure the reason. Poll every few seconds, backing off. Free."
|
|
157
|
+
),
|
|
158
|
+
"inputSchema": {"type": "object", "properties": {"job_ct_id": {"type": "string"}}, "required": ["job_ct_id"]},
|
|
151
159
|
},
|
|
152
160
|
{
|
|
153
161
|
"name": "generate_app",
|
|
@@ -218,11 +226,16 @@ def _handle_estimate_model(args: dict) -> Any:
|
|
|
218
226
|
tree = _object(args, "tree", required=True)
|
|
219
227
|
estimate = get_client().estimate(tree, _string(args, "title", required=True))
|
|
220
228
|
_estimates[estimate["estimate_id"]] = estimate
|
|
221
|
-
|
|
222
|
-
if
|
|
223
|
-
|
|
224
|
-
estimate["
|
|
225
|
-
|
|
229
|
+
key = estimate.get("key") if estimate.get("source") == "server" else None
|
|
230
|
+
if key and key.get("remaining_credits") is not None:
|
|
231
|
+
estimate["key_remaining_credits"] = key["remaining_credits"]
|
|
232
|
+
estimate["within_key_limit"] = bool(key.get("within_limit"))
|
|
233
|
+
else:
|
|
234
|
+
policy = _policy_or_none()
|
|
235
|
+
if policy and policy.get("spend_limit_credits") is not None:
|
|
236
|
+
remaining = max(0, policy["spend_limit_credits"] - policy.get("spent_credits", 0))
|
|
237
|
+
estimate["key_remaining_credits"] = remaining
|
|
238
|
+
estimate["within_key_limit"] = estimate["total_credits"] <= remaining
|
|
226
239
|
return estimate
|
|
227
240
|
|
|
228
241
|
|
|
@@ -246,21 +259,35 @@ def _handle_build_model(args: dict) -> Any:
|
|
|
246
259
|
)
|
|
247
260
|
if current["problems"]:
|
|
248
261
|
raise ValueError("The tree has problems: " + "; ".join(current["problems"]))
|
|
249
|
-
|
|
250
|
-
if
|
|
251
|
-
remaining =
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
262
|
+
remaining = None
|
|
263
|
+
if current.get("source") == "server" and (current.get("key") or {}).get("remaining_credits") is not None:
|
|
264
|
+
remaining = current["key"]["remaining_credits"]
|
|
265
|
+
else:
|
|
266
|
+
policy = _policy_or_none()
|
|
267
|
+
if policy and policy.get("spend_limit_credits") is not None:
|
|
268
|
+
remaining = max(0, policy["spend_limit_credits"] - policy.get("spent_credits", 0))
|
|
269
|
+
if remaining is not None and current["total_credits"] > remaining:
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"This key may still spend {remaining} credits and the build needs {current['total_credits']}. "
|
|
272
|
+
"Ask the person for a key with a higher limit, or to run the build themselves."
|
|
273
|
+
)
|
|
274
|
+
publish = bool(args.get("publish", True))
|
|
275
|
+
generate = bool(args.get("generate", True)) and publish
|
|
276
|
+
result = get_client().assemble(title, tree, description=_string(args, "description"),
|
|
277
|
+
contextual=_object(args, "contextual"), estimate_id=estimate_id,
|
|
278
|
+
publish=publish, generate=generate)
|
|
258
279
|
result["estimate"] = current
|
|
280
|
+
result["requested"] = {"publish": publish, "generate": generate}
|
|
281
|
+
if result.get("mode") == "mixed":
|
|
282
|
+
result["next"] = (
|
|
283
|
+
f"Poll job_status with job_ct_id {result.get('job_ct_id')} until completed; its result then says "
|
|
284
|
+
"published and generated and carries the model's links."
|
|
285
|
+
)
|
|
259
286
|
return result
|
|
260
287
|
|
|
261
288
|
|
|
262
289
|
def _handle_job_status(args: dict) -> Any:
|
|
263
|
-
return get_client().job_status(_string(args, "
|
|
290
|
+
return get_client().job_status(_string(args, "job_ct_id", required=True))
|
|
264
291
|
|
|
265
292
|
|
|
266
293
|
def _handle_generate_app(args: dict) -> Any:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdcmodeler
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: The modeler's client for SDCStudio: search the catalog, estimate, assemble and publish SDC4 data models, generate applications; a CLI and an MCP server over one client
|
|
5
5
|
Author: Axius SDC, Inc.
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -68,11 +68,11 @@ tree = {
|
|
|
68
68
|
],
|
|
69
69
|
"clusters": [],
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
result = c.assemble("Vitals", tree)
|
|
71
|
+
estimate = c.estimate(tree, "Vitals") # the server's estimate: reuse, mint, problems, total_credits, wallet, key, estimate_id
|
|
72
|
+
result = c.assemble("Vitals", tree, estimate_id=estimate["estimate_id"]) # 500 credits plus the mints
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
Every leaf existing: the server assembles, publishes and generates the package in one call (`result["mode"] == "reuse"`). Some leaves new: the pipeline runs and `result["
|
|
75
|
+
Every leaf existing: the server assembles, publishes and generates the package in one call (`result["mode"] == "reuse"`; `published` and `generated` say so). Some leaves new: the pipeline runs and `result["job_ct_id"]` is polled with `c.job_status(...)`, or `c.wait_for_job(...)`, until `completed`, when the job has published and generated as asked and its result carries the model's links. `publish=False` leaves a draft for a person to review. The server refuses a build whose tree changed since the estimate.
|
|
76
76
|
|
|
77
77
|
A 402 is `InsufficientCredits` with the numbers in credits. A 403 from the key's policy (no `spend` scope, or past its limit) is `KeyPolicyRefused`. The client never funds a wallet; a person does that at the wallet page.
|
|
78
78
|
|
|
@@ -106,8 +106,8 @@ A JSON-RPC 2.0 stdio server, no SDK, the way sdcvalidator and sdcgovernance are
|
|
|
106
106
|
| `search_components` | the public catalog by label, type and project | no |
|
|
107
107
|
| `list_models`, `get_model` | published models and their artifact routes | no |
|
|
108
108
|
| `estimate_model` | what a tree would reuse, mint and cost; returns `estimate_id` | no |
|
|
109
|
-
| `build_model` | assemble and publish; needs the `estimate_id` the person saw; refuses past the key's limit | yes |
|
|
110
|
-
| `job_status` |
|
|
109
|
+
| `build_model` | assemble, publish and generate (or `publish: false` for a draft); needs the `estimate_id` the person saw; refuses past the key's limit | yes |
|
|
110
|
+
| `job_status` | one job by id: status, step, cost so far, the model's links | no |
|
|
111
111
|
| `generate_app` | generate and download the application; needs `confirm_charge`; 1,500 credits the first time, free after | yes |
|
|
112
112
|
| `wallet` | balance in credits and the key's policy | no |
|
|
113
113
|
|
|
@@ -41,10 +41,13 @@ def test_artifact_download_streams_bytes_on_a_local_deployment(fake, tmp_path):
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def test_assemble_reuse_is_sync_and_mixed_is_async(fake):
|
|
44
|
+
fake.on("GET", "/api/v1/auth/modeler/", body={"project_ct_id": "p1"})
|
|
44
45
|
fake.on("POST", "/api/v1/dmgen/assemble/", body={"ct_id": "m1", "title": "Vitals", "artifacts": {"xsd": "/api/v1/catalog/dm/m1/xsd/"}})
|
|
45
46
|
r = fake.client().assemble("Vitals", TREE, description="d")
|
|
46
47
|
assert r["mode"] == "reuse" and r["ct_id"] == "m1"
|
|
47
48
|
assert body_of(fake.calls[-1]) == {"title": "Vitals", "description": "d", "data": TREE}
|
|
49
|
+
fake.client().assemble("Vitals", TREE)
|
|
50
|
+
assert "description" not in body_of(fake.calls[-1]), "the server refuses a blank description; an empty one is left out"
|
|
48
51
|
fake.on("POST", "/api/v1/dmgen/assemble/", status=202, body={"status": "processing", "task_id": "t", "data_source_ct_id": "ds1", "estimated_cost_credits": 600, "new_components": 1})
|
|
49
52
|
r = fake.client().assemble("Vitals", TREE)
|
|
50
53
|
assert r["mode"] == "mixed" and r["data_source_ct_id"] == "ds1"
|
|
@@ -69,6 +72,35 @@ def test_the_three_refusals_are_typed(fake):
|
|
|
69
72
|
assert e.value.status == 500
|
|
70
73
|
|
|
71
74
|
|
|
75
|
+
def test_estimate_is_the_servers_with_a_key_and_local_without(fake):
|
|
76
|
+
server = {"estimate_id": "abc", "total_credits": 700, "mode": "mixed", "reuse": [], "mint": [], "problems": [],
|
|
77
|
+
"wallet": {"balance_credits": 10, "can_pay": False}, "key": {"remaining_credits": 5000, "within_limit": True}}
|
|
78
|
+
fake.on("POST", "/api/v1/dmgen/assemble/estimate/", body=server)
|
|
79
|
+
e = fake.client().estimate(TREE, "Vitals")
|
|
80
|
+
assert e["source"] == "server" and e["total_credits"] == 700 and body_of(fake.calls[-1]) == {"title": "Vitals", "data": TREE}
|
|
81
|
+
e = fake.client(api_key=None).estimate(TREE, "Vitals")
|
|
82
|
+
assert e["source"] == "local" and e["total_credits"] == 500
|
|
83
|
+
fake.on("POST", "/api/v1/dmgen/assemble/estimate/", status=404, body={"detail": "Not found."})
|
|
84
|
+
assert fake.client().estimate(TREE, "Vitals")["source"] == "local", "a deployment older than 4.17.0"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_assemble_sends_the_estimate_id_and_a_stale_one_is_refused(fake):
|
|
88
|
+
fake.on("POST", "/api/v1/dmgen/assemble/", body={"ct_id": "m1"})
|
|
89
|
+
fake.client().assemble("Vitals", TREE, estimate_id="abc123def456")
|
|
90
|
+
assert body_of(fake.calls[-1])["estimate_id"] == "abc123def456"
|
|
91
|
+
fake.on("POST", "/api/v1/dmgen/assemble/", status=409, body={"error": "estimate_id does not match", "error_type": "stale_estimate"})
|
|
92
|
+
with pytest.raises(AssemblyRefused, match="does not match"):
|
|
93
|
+
fake.client().assemble("Vitals", TREE, estimate_id="stale")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_job_status_and_jobs_use_the_job_resource(fake):
|
|
97
|
+
fake.on("GET", "/api/v1/jobs/j1/", body={"ct_id": "j1", "status": "completed", "result": {"dm_ct_id": "m1"}})
|
|
98
|
+
fake.on("GET", "/api/v1/jobs/", body={"count": 1, "results": [{"ct_id": "j1"}]})
|
|
99
|
+
c = fake.client()
|
|
100
|
+
assert c.job_status("j1")["status"] == "completed"
|
|
101
|
+
assert c.jobs(kind="assemble")["count"] == 1 and dict(fake.calls[-1].url.params) == {"kind": "assemble"}
|
|
102
|
+
|
|
103
|
+
|
|
72
104
|
def test_key_policy_finds_this_keys_own_row(fake):
|
|
73
105
|
fake.on("GET", "/api/v1/auth/api-tokens/", body={"tokens": [
|
|
74
106
|
{"prefix": "sdcs_bbbbbbb", "scopes": ["read"], "spend_limit_credits": None},
|
|
@@ -93,3 +125,40 @@ def test_download_app_reports_the_servers_charge(fake, tmp_path):
|
|
|
93
125
|
assert path.name == "app.zip" and charged is False
|
|
94
126
|
with pytest.raises(ValueError):
|
|
95
127
|
fake.client().generate_app("m1", "huge")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_assemble_creates_the_default_project_once_when_the_modeler_has_none(fake):
|
|
131
|
+
fake.on("GET", "/api/v1/auth/modeler/", body={"project_ct_id": None, "projects": []})
|
|
132
|
+
fake.on("POST", "/api/v1/dmgen/projects/", status=201, body={"ct_id": "p1", "name": "Agent models"})
|
|
133
|
+
fake.on("PATCH", "/api/v1/auth/modeler/update/", body={"project_ct_id": "p1"})
|
|
134
|
+
fake.on("POST", "/api/v1/dmgen/assemble/", body={"ct_id": "m1"})
|
|
135
|
+
c = fake.client()
|
|
136
|
+
c.assemble("A", TREE)
|
|
137
|
+
c.assemble("B", TREE)
|
|
138
|
+
assert [r.url.path for r in fake.calls] == [
|
|
139
|
+
"/api/v1/auth/modeler/", "/api/v1/dmgen/projects/", "/api/v1/auth/modeler/update/",
|
|
140
|
+
"/api/v1/dmgen/assemble/", "/api/v1/dmgen/assemble/",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_assemble_sends_the_flags_only_when_they_differ_from_the_default(fake):
|
|
145
|
+
fake.on("POST", "/api/v1/dmgen/assemble/", body={"ct_id": "m1", "published": True, "generated": True})
|
|
146
|
+
c = fake.client()
|
|
147
|
+
c.assemble("A", TREE)
|
|
148
|
+
assert "publish" not in body_of(fake.calls[-1]) and "generate" not in body_of(fake.calls[-1])
|
|
149
|
+
c.assemble("B", TREE, publish=False)
|
|
150
|
+
assert body_of(fake.calls[-1])["publish"] is False and body_of(fake.calls[-1])["generate"] is False
|
|
151
|
+
c.assemble("C", TREE, generate=False)
|
|
152
|
+
assert "publish" not in body_of(fake.calls[-1]) and body_of(fake.calls[-1])["generate"] is False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_wait_for_job_polls_until_terminal(fake, monkeypatch):
|
|
156
|
+
states = iter([{"status": "queued"}, {"status": "running", "step": "modeling"}, {"status": "completed", "result": {"published": True}}])
|
|
157
|
+
fake.on("GET", "/api/v1/jobs/j1/", body=lambda request: next(states))
|
|
158
|
+
monkeypatch.setattr("time.sleep", lambda s: None)
|
|
159
|
+
job = fake.client().wait_for_job("j1", interval=0.01)
|
|
160
|
+
assert job["status"] == "completed" and job["result"]["published"] is True
|
|
161
|
+
fake.on("GET", "/api/v1/jobs/j2/", body={"status": "running"})
|
|
162
|
+
with pytest.raises(TimeoutError) as exc:
|
|
163
|
+
fake.client().wait_for_job("j2", timeout=0, interval=0.01)
|
|
164
|
+
assert exc.value.job["status"] == "running"
|
|
@@ -22,6 +22,16 @@ def call(name, arguments):
|
|
|
22
22
|
@pytest.fixture(autouse=True)
|
|
23
23
|
def bound(fake, tmp_path):
|
|
24
24
|
fake.on("GET", "/api/v1/auth/api-tokens/", body={"tokens": [{"prefix": KEY[:12], "scopes": ["read", "build", "spend"], "spend_limit_credits": 1000, "spent_credits": 300}]})
|
|
25
|
+
# The server's estimate (4.17.0) for TREE: 500 + 100, the key at 700 remaining.
|
|
26
|
+
from sdcmodeler.pricing import estimate_tree
|
|
27
|
+
|
|
28
|
+
def server_estimate(request):
|
|
29
|
+
body = json.loads(request.content)
|
|
30
|
+
local = estimate_tree(body["data"], body["title"])
|
|
31
|
+
return {**local, "wallet": {"balance_credits": 10000, "can_pay": True},
|
|
32
|
+
"key": {"remaining_credits": 700, "within_limit": local["total_credits"] <= 700}}
|
|
33
|
+
|
|
34
|
+
fake.on("POST", "/api/v1/dmgen/assemble/estimate/", body=server_estimate)
|
|
25
35
|
mcp_server.configure(fake.client(), output_dir=tmp_path)
|
|
26
36
|
return fake
|
|
27
37
|
|
|
@@ -45,10 +55,17 @@ def test_build_needs_the_estimate_the_person_saw(bound):
|
|
|
45
55
|
assert "exact title and tree" in err
|
|
46
56
|
built, _ = call("build_model", {"estimate_id": est["estimate_id"], "title": "Vitals", "tree": TREE})
|
|
47
57
|
assert built["mode"] == "mixed" and built["estimate"]["estimate_id"] == est["estimate_id"]
|
|
58
|
+
assert built["estimate"]["source"] == "server"
|
|
59
|
+
assert json.loads([c for c in bound.calls if c.url.path.endswith("/assemble/")][-1].content)["estimate_id"] == est["estimate_id"]
|
|
48
60
|
|
|
49
61
|
|
|
50
62
|
def test_build_stops_at_the_keys_limit(bound):
|
|
51
|
-
|
|
63
|
+
def server_estimate(request):
|
|
64
|
+
from sdcmodeler.pricing import estimate_tree
|
|
65
|
+
body = json.loads(request.content)
|
|
66
|
+
local = estimate_tree(body["data"], body["title"])
|
|
67
|
+
return {**local, "wallet": {"balance_credits": 10000, "can_pay": True}, "key": {"remaining_credits": 500, "within_limit": False}}
|
|
68
|
+
bound.on("POST", "/api/v1/dmgen/assemble/estimate/", body=server_estimate)
|
|
52
69
|
est, _ = call("estimate_model", {"title": "Vitals", "tree": TREE})
|
|
53
70
|
assert est["within_key_limit"] is False
|
|
54
71
|
_, err = call("build_model", {"estimate_id": est["estimate_id"], "title": "Vitals", "tree": TREE})
|
|
@@ -82,3 +99,18 @@ def test_malformed_input_is_answered_not_fatal():
|
|
|
82
99
|
assert mcp_server._handle_request({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
|
|
83
100
|
_, err = call("estimate_model", {"title": "t", "tree": "not an object"})
|
|
84
101
|
assert "must be an object" in err
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_job_status_takes_the_job_id(bound):
|
|
105
|
+
bound.on("GET", "/api/v1/jobs/j1/", body={"ct_id": "j1", "status": "running", "step": "modeling", "cost_credits": 100})
|
|
106
|
+
out, _ = call("job_status", {"job_ct_id": "j1"})
|
|
107
|
+
assert out["step"] == "modeling"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_build_model_passes_the_flags_and_names_what_to_poll(bound):
|
|
111
|
+
bound.on("POST", "/api/v1/dmgen/assemble/", status=202, body={"status": "processing", "job_ct_id": "j9", "data_source_ct_id": "ds"})
|
|
112
|
+
est, _ = call("estimate_model", {"title": "Vitals", "tree": TREE})
|
|
113
|
+
built, _ = call("build_model", {"estimate_id": est["estimate_id"], "title": "Vitals", "tree": TREE, "generate": False})
|
|
114
|
+
sent = json.loads([c for c in bound.calls if c.url.path.endswith("/assemble/")][-1].content)
|
|
115
|
+
assert sent["generate"] is False and "publish" not in sent
|
|
116
|
+
assert built["requested"] == {"publish": True, "generate": False} and "j9" in built["next"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|