sdcmodeler 4.2.0__tar.gz → 4.3.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.2.0 → sdcmodeler-4.3.0}/PKG-INFO +5 -2
  2. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/README.md +4 -1
  3. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/pyproject.toml +1 -1
  4. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler/__init__.py +1 -1
  5. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler/mcp_server.py +43 -3
  6. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler/pricing.py +9 -5
  7. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler.egg-info/PKG-INFO +5 -2
  8. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/tests/test_mcp_server.py +24 -2
  9. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/tests/test_pricing.py +15 -3
  10. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/LICENSE +0 -0
  11. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/setup.cfg +0 -0
  12. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler/cli.py +0 -0
  13. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler/client.py +0 -0
  14. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler.egg-info/SOURCES.txt +0 -0
  15. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler.egg-info/dependency_links.txt +0 -0
  16. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler.egg-info/entry_points.txt +0 -0
  17. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler.egg-info/requires.txt +0 -0
  18. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/src/sdcmodeler.egg-info/top_level.txt +0 -0
  19. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/tests/test_cli.py +0 -0
  20. {sdcmodeler-4.2.0 → sdcmodeler-4.3.0}/tests/test_client.py +0 -0
  21. {sdcmodeler-4.2.0 → sdcmodeler-4.3.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.2.0
3
+ Version: 4.3.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
@@ -84,7 +84,9 @@ sdcmodeler models "patient"
84
84
  sdcmodeler model <ct_id>
85
85
  sdcmodeler artifact <ct_id> xsd --dir ./schemas
86
86
  sdcmodeler estimate tree.json --title "Vitals"
87
- sdcmodeler build tree.json --title "Vitals"
87
+ sdcmodeler build tree.json --title "Vitals" --wait
88
+ sdcmodeler job <job_ct_id>
89
+ sdcmodeler package <ct_id> --dir ./packages
88
90
  sdcmodeler app <ct_id> --profile lightweight --dir ./apps
89
91
  sdcmodeler wallet
90
92
  ```
@@ -108,6 +110,7 @@ A JSON-RPC 2.0 stdio server, no SDK, the way sdcvalidator and sdcgovernance are
108
110
  | `estimate_model` | what a tree would reuse, mint and cost; returns `estimate_id` | no |
109
111
  | `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
112
  | `job_status` | one job by id: status, step, cost so far, the model's links | no |
113
+ | `download_package` | a model's package ZIP with the key, including a model in a private project; builds a missing package first (free, once) unless `generate` is false | no |
111
114
  | `generate_app` | generate and download the application; needs `confirm_charge`; 1,500 credits the first time, free after | yes |
112
115
  | `wallet` | balance in credits and the key's policy | no |
113
116
 
@@ -58,7 +58,9 @@ sdcmodeler models "patient"
58
58
  sdcmodeler model <ct_id>
59
59
  sdcmodeler artifact <ct_id> xsd --dir ./schemas
60
60
  sdcmodeler estimate tree.json --title "Vitals"
61
- sdcmodeler build tree.json --title "Vitals"
61
+ sdcmodeler build tree.json --title "Vitals" --wait
62
+ sdcmodeler job <job_ct_id>
63
+ sdcmodeler package <ct_id> --dir ./packages
62
64
  sdcmodeler app <ct_id> --profile lightweight --dir ./apps
63
65
  sdcmodeler wallet
64
66
  ```
@@ -82,6 +84,7 @@ A JSON-RPC 2.0 stdio server, no SDK, the way sdcvalidator and sdcgovernance are
82
84
  | `estimate_model` | what a tree would reuse, mint and cost; returns `estimate_id` | no |
83
85
  | `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
86
  | `job_status` | one job by id: status, step, cost so far, the model's links | no |
87
+ | `download_package` | a model's package ZIP with the key, including a model in a private project; builds a missing package first (free, once) unless `generate` is false | no |
85
88
  | `generate_app` | generate and download the application; needs `confirm_charge`; 1,500 credits the first time, free after | yes |
86
89
  | `wallet` | balance in credits and the key's policy | no |
87
90
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sdcmodeler"
7
- version = "4.2.0"
7
+ version = "4.3.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.2.0"
20
+ __version__ = "4.3.0"
21
21
 
22
22
  from sdcmodeler.client import ( # noqa: E402
23
23
  AssemblyRefused,
@@ -157,6 +157,25 @@ TOOLS: list[dict[str, Any]] = [
157
157
  ),
158
158
  "inputSchema": {"type": "object", "properties": {"job_ct_id": {"type": "string"}}, "required": ["job_ct_id"]},
159
159
  },
160
+ {
161
+ "name": "download_package",
162
+ "description": (
163
+ "Download a model's package (the ZIP: schema, XML template and JSON instance of the "
164
+ "Governed Data Record, JSON-LD, HTML, RDF, SHACL, GQL) into the server's output directory, "
165
+ "with this key: it works for a model in a private project, such as the one build_model "
166
+ "just made, where get_model and the public catalog do not. When the model is published "
167
+ "and its package not yet built, it builds it first (free, once; it needs the right to "
168
+ "publish the model); generate false only downloads. Free."
169
+ ),
170
+ "inputSchema": {
171
+ "type": "object",
172
+ "properties": {
173
+ "ct_id": {"type": "string"},
174
+ "generate": {"type": "boolean", "default": True},
175
+ },
176
+ "required": ["ct_id"],
177
+ },
178
+ },
160
179
  {
161
180
  "name": "generate_app",
162
181
  "description": (
@@ -290,6 +309,25 @@ def _handle_job_status(args: dict) -> Any:
290
309
  return get_client().job_status(_string(args, "job_ct_id", required=True))
291
310
 
292
311
 
312
+ def _handle_download_package(args: dict) -> Any:
313
+ ct_id = _string(args, "ct_id", required=True)
314
+ generate = args.get("generate", True)
315
+ if not isinstance(generate, bool):
316
+ raise ValueError("`generate` must be a boolean.")
317
+ c = get_client()
318
+ built_now = False
319
+ try:
320
+ path = c.download_package(ct_id, _output_dir)
321
+ except SDCStudioError as exc:
322
+ # 404 until the package exists (and for a draft); a published model's package is built once, free.
323
+ if exc.status != 404 or not generate:
324
+ raise
325
+ c.generate_package(ct_id)
326
+ built_now = True
327
+ path = c.download_package(ct_id, _output_dir)
328
+ return {"path": str(path), "built_now": built_now}
329
+
330
+
293
331
  def _handle_generate_app(args: dict) -> Any:
294
332
  ct_id = _string(args, "ct_id", required=True)
295
333
  profile = _string(args, "profile", default="lightweight") or "lightweight"
@@ -316,6 +354,7 @@ TOOL_HANDLERS = {
316
354
  "estimate_model": _handle_estimate_model,
317
355
  "build_model": _handle_build_model,
318
356
  "job_status": _handle_job_status,
357
+ "download_package": _handle_download_package,
319
358
  "generate_app": _handle_generate_app,
320
359
  "wallet": _handle_wallet,
321
360
  }
@@ -364,8 +403,9 @@ def _handle_request(request: Any) -> dict | None:
364
403
  "The modeler's client for SDCStudio. Search the public catalog first and reuse published "
365
404
  "components; estimate before you build and show the person the cost; build only with the "
366
405
  "estimate_id you showed; prefer the model package over the generated application when the "
367
- "feature lives inside an existing application. A 402 means the person funds the wallet at "
368
- f"{get_client().base_url}/app/wallet; never try to pay. "
406
+ "feature lives inside an existing application; fetch a model's package, including one you just built "
407
+ "in a private project, with download_package. A 402 means the person funds the wallet at "
408
+ f"{get_client().base_url}/app/settings?tab=wallet; never try to pay. "
369
409
  + ("A key is configured, so building and downloading are available within its policy."
370
410
  if has_key else "No key is configured: the catalog and estimates work; building needs SDCSTUDIO_API_KEY.")
371
411
  ),
@@ -388,7 +428,7 @@ def _handle_request(request: Any) -> dict | None:
388
428
  except InsufficientCredits as exc:
389
429
  return _tool_error(req_id, (
390
430
  f"Insufficient credits: this needs {exc.estimated_cost_credits} credits and the wallet has "
391
- f"{exc.balance_credits}. The person funds the wallet at {get_client().base_url}/app/wallet; stop here."
431
+ f"{exc.balance_credits}. The person funds the wallet at {get_client().base_url}/app/settings?tab=wallet; stop here."
392
432
  ))
393
433
  except KeyPolicyRefused as exc:
394
434
  return _tool_error(req_id, f"The API key refused this ({exc.error_type}): {exc}")
@@ -5,10 +5,10 @@ SDCStudio's prices (Terms of Service, section 6; ``docs/user-guides/wallet-billi
5
5
  1,000 credits per dollar; minting a component 100 credits; assembling a model
6
6
  500; downloading an application 1,500, once per user per model since 4.16.0;
7
7
  settling a record 1,000. The estimate below resolves an assembly tree the way
8
- the server does: a leaf with ``ct_id`` is reused and free, a leaf with ``label``
9
- and ``data_type`` is minted and billable. The server's own estimate endpoint,
10
- when it exists, confirms this; until then this is the number an agent shows
11
- the person before it builds.
8
+ the server does (SDCStudio 4.17.5, one rule on every path): 500 for the model,
9
+ which covers its root cluster; 100 for every other new cluster; 100 for every
10
+ new component; anything with a ``ct_id`` is reused and free. With a key the
11
+ server's estimate endpoint is used; this is the fallback without one.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
@@ -39,6 +39,8 @@ def _walk(node: dict[str, Any], path: str, reuse: list, mint: list, problems: li
39
39
  continue
40
40
  if not name:
41
41
  problems.append(f"{path}: a cluster needs a label")
42
+ # A new cluster below the root is minted and billed like a component.
43
+ mint.append({"path": f"{path}/{name}", "label": name, "data_type": "Cluster"})
42
44
  _walk(cluster, f"{path}/{name}", reuse, mint, problems)
43
45
 
44
46
 
@@ -57,6 +59,7 @@ def estimate_tree(tree: dict[str, Any], title: str = "") -> dict[str, Any]:
57
59
  problems: list = []
58
60
  _walk(tree, tree.get("label", "root"), reuse, mint, problems)
59
61
  mint_credits = MINT_COMPONENT_CREDITS * len(mint)
62
+ title = (title or "").strip() # the server hashes the title as its serializer trims it
60
63
  canonical = json.dumps({"title": title, "tree": tree}, sort_keys=True, separators=(",", ":"), default=str)
61
64
  return {
62
65
  "estimate_id": hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12],
@@ -67,5 +70,6 @@ def estimate_tree(tree: dict[str, Any], title: str = "") -> dict[str, Any]:
67
70
  "model_credits": ASSEMBLE_MODEL_CREDITS,
68
71
  "mint_credits": mint_credits,
69
72
  "total_credits": ASSEMBLE_MODEL_CREDITS + mint_credits,
70
- "mode": "mixed" if mint else "reuse",
73
+ # The path assemble/ takes: a new component makes it mixed, a new cluster alone does not.
74
+ "mode": "mixed" if any(m["data_type"] != "Cluster" for m in mint) else "reuse",
71
75
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sdcmodeler
3
- Version: 4.2.0
3
+ Version: 4.3.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
@@ -84,7 +84,9 @@ sdcmodeler models "patient"
84
84
  sdcmodeler model <ct_id>
85
85
  sdcmodeler artifact <ct_id> xsd --dir ./schemas
86
86
  sdcmodeler estimate tree.json --title "Vitals"
87
- sdcmodeler build tree.json --title "Vitals"
87
+ sdcmodeler build tree.json --title "Vitals" --wait
88
+ sdcmodeler job <job_ct_id>
89
+ sdcmodeler package <ct_id> --dir ./packages
88
90
  sdcmodeler app <ct_id> --profile lightweight --dir ./apps
89
91
  sdcmodeler wallet
90
92
  ```
@@ -108,6 +110,7 @@ A JSON-RPC 2.0 stdio server, no SDK, the way sdcvalidator and sdcgovernance are
108
110
  | `estimate_model` | what a tree would reuse, mint and cost; returns `estimate_id` | no |
109
111
  | `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
112
  | `job_status` | one job by id: status, step, cost so far, the model's links | no |
113
+ | `download_package` | a model's package ZIP with the key, including a model in a private project; builds a missing package first (free, once) unless `generate` is false | no |
111
114
  | `generate_app` | generate and download the application; needs `confirm_charge`; 1,500 credits the first time, free after | yes |
112
115
  | `wallet` | balance in credits and the key's policy | no |
113
116
 
@@ -42,7 +42,7 @@ def test_initialize_and_tools_list():
42
42
  assert r["result"]["protocolVersion"] == "2025-06-18"
43
43
  assert "wallet" in r["result"]["instructions"]
44
44
  tools = rpc("tools/list")["result"]["tools"]
45
- assert [t["name"] for t in tools] == ["search_components", "list_models", "get_model", "estimate_model", "build_model", "job_status", "generate_app", "wallet"]
45
+ assert [t["name"] for t in tools] == ["search_components", "list_models", "get_model", "estimate_model", "build_model", "job_status", "download_package", "generate_app", "wallet"]
46
46
 
47
47
 
48
48
  def test_build_needs_the_estimate_the_person_saw(bound):
@@ -77,7 +77,7 @@ def test_a_402_names_the_wallet_page_and_stops(bound):
77
77
  bound.on("POST", "/api/v1/dmgen/assemble/", status=402, body={"error": "x", "estimated_cost_credits": 600, "balance_credits": 20})
78
78
  est, _ = call("estimate_model", {"title": "Vitals", "tree": TREE})
79
79
  _, err = call("build_model", {"estimate_id": est["estimate_id"], "title": "Vitals", "tree": TREE})
80
- assert "600 credits" in err and f"{BASE}/app/wallet" in err
80
+ assert "600 credits" in err and f"{BASE}/app/settings?tab=wallet" in err
81
81
 
82
82
 
83
83
  def test_generate_app_needs_confirmation_and_reports_the_charge(bound, tmp_path):
@@ -114,3 +114,25 @@ def test_build_model_passes_the_flags_and_names_what_to_poll(bound):
114
114
  sent = json.loads([c for c in bound.calls if c.url.path.endswith("/assemble/")][-1].content)
115
115
  assert sent["generate"] is False and "publish" not in sent
116
116
  assert built["requested"] == {"publish": True, "generate": False} and "j9" in built["next"]
117
+
118
+
119
+ def test_download_package_fetches_a_private_models_package_with_the_key(bound, tmp_path):
120
+ bound.on("GET", "/api/v1/dmgen/dms/m1/download_package/", body={"download_url": "https://storage.example/dm-m1.zip", "filename": "dm-m1.zip"})
121
+ bound.on("GET", "https://storage.example/dm-m1.zip", body=b"PK")
122
+ out, _ = call("download_package", {"ct_id": "m1"})
123
+ assert out == {"path": str(tmp_path / "dm-m1.zip"), "built_now": False} and (tmp_path / "dm-m1.zip").exists()
124
+ assert "authorization" not in bound.calls[-1].headers, "the storage pointer is followed without the key"
125
+
126
+
127
+ def test_download_package_builds_a_missing_package_first_unless_told_not_to(bound, tmp_path):
128
+ bound.on("GET", "/api/v1/dmgen/dms/m2/download_package/", status=404, body={"error": "No package"})
129
+ _, err = call("download_package", {"ct_id": "m2", "generate": False})
130
+ assert err and "404" in err
131
+
132
+ def built(_request):
133
+ bound.on("GET", "/api/v1/dmgen/dms/m2/download_package/", body=b"PK", headers={"content-disposition": 'attachment; filename="dm-m2.zip"'})
134
+ return {"success": True}
135
+
136
+ bound.on("POST", "/api/v1/dmgen/dms/m2/generate/", body=built)
137
+ out, _ = call("download_package", {"ct_id": "m2"})
138
+ assert out["built_now"] is True and (tmp_path / "dm-m2.zip").exists()
@@ -13,9 +13,10 @@ TREE = {
13
13
  def test_reuse_is_free_and_mints_are_counted():
14
14
  e = estimate_tree(TREE, "Vitals")
15
15
  assert [r["ct_id"] for r in e["reuse"]] == ["abc123abc123abc123abc123", "def456def456def456def456"]
16
- assert [m["label"] for m in e["mint"]] == ["Pulse", "Note"]
17
- assert e["mint"][1]["path"] == "Vitals/Context/Note"
18
- assert e["total_credits"] == ASSEMBLE_MODEL_CREDITS + 2 * MINT_COMPONENT_CREDITS
16
+ # The new nested cluster Context is minted too; the root cluster is the model's (SDCStudio 4.17.5).
17
+ assert [(m["label"], m["data_type"]) for m in e["mint"]] == [("Pulse", "XdCount"), ("Context", "Cluster"), ("Note", "XdString")]
18
+ assert e["mint"][2]["path"] == "Vitals/Context/Note"
19
+ assert e["total_credits"] == ASSEMBLE_MODEL_CREDITS + 3 * MINT_COMPONENT_CREDITS == 800
19
20
  assert e["mode"] == "mixed" and e["problems"] == []
20
21
 
21
22
 
@@ -31,3 +32,14 @@ def test_problems_are_named_not_hidden():
31
32
  e = estimate_tree({"label": "Root", "components": [{"label": "No type"}], "clusters": [{"components": []}]}, "t")
32
33
  assert len(e["problems"]) == 2 and "ct_id (reuse) or label and data_type (mint)" in e["problems"][0]
33
34
  assert e["mode"] == "reuse"
35
+
36
+
37
+ def test_a_new_cluster_alone_is_billed_but_takes_the_reuse_path():
38
+ tree = {"label": "Root", "components": [{"ct_id": "a" * 24}],
39
+ "clusters": [{"label": "Group", "components": [{"ct_id": "b" * 24}], "clusters": []}]}
40
+ e = estimate_tree(tree, "Grouped")
41
+ assert (e["mode"], e["total_credits"]) == ("reuse", 600)
42
+
43
+
44
+ def test_the_title_is_trimmed_before_hashing_as_the_server_does():
45
+ assert estimate_tree(TREE, " Vitals ")["estimate_id"] == estimate_tree(TREE, "Vitals")["estimate_id"]
File without changes
File without changes
File without changes