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.
Files changed (21) hide show
  1. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/PKG-INFO +6 -6
  2. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/README.md +5 -5
  3. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/pyproject.toml +1 -1
  4. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/__init__.py +1 -1
  5. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/cli.py +16 -5
  6. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/client.py +102 -17
  7. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/mcp_server.py +49 -22
  8. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/PKG-INFO +6 -6
  9. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/tests/test_client.py +69 -0
  10. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/tests/test_mcp_server.py +33 -1
  11. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/LICENSE +0 -0
  12. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/setup.cfg +0 -0
  13. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler/pricing.py +0 -0
  14. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/SOURCES.txt +0 -0
  15. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/dependency_links.txt +0 -0
  16. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/entry_points.txt +0 -0
  17. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/requires.txt +0 -0
  18. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/src/sdcmodeler.egg-info/top_level.txt +0 -0
  19. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/tests/test_cli.py +0 -0
  20. {sdcmodeler-4.0.0 → sdcmodeler-4.2.0}/tests/test_pricing.py +0 -0
  21. {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.0.0
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
- print(c.estimate(tree, "Vitals")) # reuse, mint, total_credits, estimate_id; no call that spends
72
- result = c.assemble("Vitals", tree) # 500 credits plus the mints
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["data_source_ct_id"]` is polled with `c.job_status(...)`.
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` | the pipeline for a mixed build | no |
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
- print(c.estimate(tree, "Vitals")) # reuse, mint, total_credits, estimate_id; no call that spends
46
- result = c.assemble("Vitals", tree) # 500 credits plus the mints
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["data_source_ct_id"]` is polled with `c.job_status(...)`.
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` | the pipeline for a mixed build | no |
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.0.0"
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.0.0"
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.set_defaults(func=lambda c, a: c.assemble(a.title, _load_tree(a.tree), description=a.description))
54
-
55
- s = sub.add_parser("job", help="status of a mixed build or an upload")
56
- s.add_argument("data_source_ct_id")
57
- s.set_defaults(func=lambda c, a: c.job_status(a.data_source_ct_id))
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
- """What the assembly would reuse, mint and cost, computed locally (``sdcmodeler.pricing``)."""
198
- return pricing.estimate_tree(tree, title)
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) -> dict[str, Any]:
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, publishes and generates
206
- synchronously and answers 200 with the model (``mode`` ``"reuse"``).
207
- Some leaves new: 202 with ``task_id`` and ``data_source_ct_id`` to poll
208
- with ``job_status`` (``mode`` ``"mixed"``; the result is a draft model).
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
- payload: dict[str, Any] = {"title": title, "description": description, "data": tree}
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, data_source_ct_id: str) -> dict[str, Any]:
225
- """The pipeline's status for a mixed assembly or an upload (``status``: uploaded, parsing, parsed, agent_processing, modeling, completed, error, agent_error)."""
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; synchronous)."""
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; synchronous). ``profile`` is ``lightweight`` or ``enterprise``."""
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
- return self._post(f"/api/v1/dmgen/dms/{ct_id}/generate_app/", {"profile": profile})
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, and refuses when the "
129
- "estimate exceeds what the key may still spend. Spends credits: 500 for the model plus "
130
- "100 per minted component. With every leaf existing the model is published and its "
131
- "package generated at once (result has ct_id and artifacts); with new leaves the "
132
- "pipeline runs and the result has a data_source_ct_id to poll with job_status. A 402 "
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": "The pipeline status of a mixed build (data_source_ct_id from build_model): uploaded, parsing, parsed, agent_processing, modeling, completed, error, agent_error. Free.",
150
- "inputSchema": {"type": "object", "properties": {"data_source_ct_id": {"type": "string"}}, "required": ["data_source_ct_id"]},
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
- policy = _policy_or_none()
222
- if policy and policy.get("spend_limit_credits") is not None:
223
- remaining = max(0, policy["spend_limit_credits"] - policy.get("spent_credits", 0))
224
- estimate["key_remaining_credits"] = remaining
225
- estimate["within_key_limit"] = estimate["total_credits"] <= remaining
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
- policy = _policy_or_none()
250
- if policy and policy.get("spend_limit_credits") is not None:
251
- remaining = max(0, policy["spend_limit_credits"] - policy.get("spent_credits", 0))
252
- if current["total_credits"] > remaining:
253
- raise ValueError(
254
- f"This key may still spend {remaining} credits and the build needs {current['total_credits']}. "
255
- "Ask the person for a key with a higher limit, or to run the build themselves."
256
- )
257
- result = get_client().assemble(title, tree, description=_string(args, "description"), contextual=_object(args, "contextual"))
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, "data_source_ct_id", required=True))
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.0.0
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
- print(c.estimate(tree, "Vitals")) # reuse, mint, total_credits, estimate_id; no call that spends
72
- result = c.assemble("Vitals", tree) # 500 credits plus the mints
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["data_source_ct_id"]` is polled with `c.job_status(...)`.
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` | the pipeline for a mixed build | no |
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
- bound.on("GET", "/api/v1/auth/api-tokens/", body={"tokens": [{"prefix": KEY[:12], "scopes": ["read", "build", "spend"], "spend_limit_credits": 1000, "spent_credits": 500}]})
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