matprops-sdk 0.1.1__py3-none-any.whl
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.
- matprops_sdk/__init__.py +14 -0
- matprops_sdk/auth.py +227 -0
- matprops_sdk/bundle.py +64 -0
- matprops_sdk/cli.py +358 -0
- matprops_sdk/context.py +104 -0
- matprops_sdk/credentials.py +93 -0
- matprops_sdk/manifest.py +70 -0
- matprops_sdk/models.py +38 -0
- matprops_sdk/publish.py +165 -0
- matprops_sdk/remote.py +297 -0
- matprops_sdk/runtime.py +176 -0
- matprops_sdk/simulation.py +163 -0
- matprops_sdk-0.1.1.dist-info/METADATA +165 -0
- matprops_sdk-0.1.1.dist-info/RECORD +17 -0
- matprops_sdk-0.1.1.dist-info/WHEEL +4 -0
- matprops_sdk-0.1.1.dist-info/entry_points.txt +2 -0
- matprops_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
matprops_sdk/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from matprops_sdk.context import RunContext
|
|
2
|
+
from matprops_sdk.models import Field, InputModel, OutputModel
|
|
3
|
+
from matprops_sdk.simulation import SimulationDefinition, simulation
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Field",
|
|
7
|
+
"InputModel",
|
|
8
|
+
"OutputModel",
|
|
9
|
+
"RunContext",
|
|
10
|
+
"SimulationDefinition",
|
|
11
|
+
"simulation",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.1"
|
matprops_sdk/auth.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
import webbrowser
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from matprops_sdk.credentials import (
|
|
12
|
+
Credentials,
|
|
13
|
+
default_api_url,
|
|
14
|
+
default_app_url,
|
|
15
|
+
delete_credentials,
|
|
16
|
+
load_credentials,
|
|
17
|
+
save_credentials,
|
|
18
|
+
)
|
|
19
|
+
from matprops_sdk.runtime import MatpropsError
|
|
20
|
+
|
|
21
|
+
_TOKEN_ERRORS = {
|
|
22
|
+
"authorization_pending": "Waiting for approval in the browser.",
|
|
23
|
+
"slow_down": "Polling too quickly.",
|
|
24
|
+
"access_denied": "The CLI login was denied.",
|
|
25
|
+
"expired_token": "The CLI login expired. Run matprops login again.",
|
|
26
|
+
"invalid_grant": "This CLI login is no longer valid.",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def login(
|
|
31
|
+
app_url: str | None = None,
|
|
32
|
+
api_url: str | None = None,
|
|
33
|
+
*,
|
|
34
|
+
open_browser: bool = True,
|
|
35
|
+
sleep: Any = time.sleep,
|
|
36
|
+
) -> Credentials:
|
|
37
|
+
base = (app_url or default_app_url()).rstrip("/")
|
|
38
|
+
api = (api_url or default_api_url()).rstrip("/")
|
|
39
|
+
started = _post_json(f"{base}/api/cli/device/authorize", {})
|
|
40
|
+
device_code = started.get("device_code")
|
|
41
|
+
verification = started.get("verification_uri_complete") or started.get(
|
|
42
|
+
"verification_uri"
|
|
43
|
+
)
|
|
44
|
+
interval = int(started.get("interval") or 5)
|
|
45
|
+
expires_in = int(started.get("expires_in") or 600)
|
|
46
|
+
if not isinstance(device_code, str) or not device_code:
|
|
47
|
+
raise MatpropsError("The login server did not return a device code.")
|
|
48
|
+
if not isinstance(verification, str) or not verification:
|
|
49
|
+
raise MatpropsError("The login server did not return a verification URL.")
|
|
50
|
+
|
|
51
|
+
print(f"Open {verification} to authorize the CLI.")
|
|
52
|
+
if started.get("user_code"):
|
|
53
|
+
print(f"Confirm the code {started['user_code']} if asked.")
|
|
54
|
+
if open_browser:
|
|
55
|
+
opened = webbrowser.open(verification)
|
|
56
|
+
if not opened:
|
|
57
|
+
print("Could not open a browser automatically.")
|
|
58
|
+
|
|
59
|
+
deadline = time.monotonic() + expires_in
|
|
60
|
+
wait = max(interval, 1)
|
|
61
|
+
while time.monotonic() < deadline:
|
|
62
|
+
status, body = _post_json_with_status(
|
|
63
|
+
f"{base}/api/cli/device/token",
|
|
64
|
+
{"device_code": device_code},
|
|
65
|
+
)
|
|
66
|
+
if status == 200 and "api_key" in body:
|
|
67
|
+
credentials = Credentials(
|
|
68
|
+
api_key=str(body["api_key"]),
|
|
69
|
+
api_key_id=str(body.get("api_key_id") or ""),
|
|
70
|
+
expires_at=(
|
|
71
|
+
str(body["expires_at"])
|
|
72
|
+
if isinstance(body.get("expires_at"), str)
|
|
73
|
+
else None
|
|
74
|
+
),
|
|
75
|
+
user=body.get("user") if isinstance(body.get("user"), dict) else {},
|
|
76
|
+
organization=(
|
|
77
|
+
body.get("organization")
|
|
78
|
+
if isinstance(body.get("organization"), dict)
|
|
79
|
+
else {}
|
|
80
|
+
),
|
|
81
|
+
app_url=base,
|
|
82
|
+
api_url=api,
|
|
83
|
+
)
|
|
84
|
+
path = save_credentials(credentials)
|
|
85
|
+
print(f"Logged in. Credentials saved to {path}")
|
|
86
|
+
return credentials
|
|
87
|
+
|
|
88
|
+
error = body.get("error")
|
|
89
|
+
if error == "authorization_pending":
|
|
90
|
+
wait = max(interval, 1)
|
|
91
|
+
sleep(wait)
|
|
92
|
+
continue
|
|
93
|
+
if error == "slow_down":
|
|
94
|
+
wait = max(interval, 1) + 5
|
|
95
|
+
sleep(wait)
|
|
96
|
+
continue
|
|
97
|
+
message = _TOKEN_ERRORS.get(
|
|
98
|
+
str(error),
|
|
99
|
+
str(body.get("error") or body.get("message") or "CLI login failed."),
|
|
100
|
+
)
|
|
101
|
+
raise MatpropsError(message)
|
|
102
|
+
|
|
103
|
+
raise MatpropsError("The CLI login expired. Run matprops login again.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def logout(app_url: str | None = None) -> None:
|
|
107
|
+
stored = load_credentials()
|
|
108
|
+
base = (app_url or (stored.app_url if stored else default_app_url())).rstrip("/")
|
|
109
|
+
if stored:
|
|
110
|
+
with contextlib.suppress(MatpropsError):
|
|
111
|
+
_post_json(
|
|
112
|
+
f"{base}/api/cli/logout",
|
|
113
|
+
{},
|
|
114
|
+
headers={"x-api-key": stored.api_key},
|
|
115
|
+
)
|
|
116
|
+
delete_credentials()
|
|
117
|
+
print("Logged out.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_WHOAMI_QUERY = """
|
|
121
|
+
query {
|
|
122
|
+
me {
|
|
123
|
+
actor {
|
|
124
|
+
... on UserActor { kind userId }
|
|
125
|
+
... on OrgActor { kind userId organizationId role }
|
|
126
|
+
... on ApiKeyActor { kind organizationId keyId userId }
|
|
127
|
+
... on SystemAdminActor { kind userId }
|
|
128
|
+
}
|
|
129
|
+
user { id name email image }
|
|
130
|
+
session { id expiresAt activeOrganizationId systemAdminMode }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def whoami(api_url: str | None = None) -> dict[str, Any]:
|
|
137
|
+
stored = load_credentials()
|
|
138
|
+
if stored is None:
|
|
139
|
+
raise MatpropsError("Not logged in. Run matprops login first.")
|
|
140
|
+
api = (api_url or stored.api_url or default_api_url()).rstrip("/")
|
|
141
|
+
status, body = _request_json(
|
|
142
|
+
"POST",
|
|
143
|
+
f"{api}/graphql",
|
|
144
|
+
{"query": _WHOAMI_QUERY},
|
|
145
|
+
headers={"x-api-key": stored.api_key},
|
|
146
|
+
)
|
|
147
|
+
if status >= 400:
|
|
148
|
+
raise MatpropsError(_graphql_error_message(body, status))
|
|
149
|
+
errors = body.get("errors")
|
|
150
|
+
if errors:
|
|
151
|
+
raise MatpropsError(_graphql_error_message(body, status))
|
|
152
|
+
me = (body.get("data") or {}).get("me")
|
|
153
|
+
if not isinstance(me, dict):
|
|
154
|
+
raise MatpropsError("The API did not return an identity.")
|
|
155
|
+
return me
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _post_json(
|
|
159
|
+
url: str,
|
|
160
|
+
payload: dict[str, Any],
|
|
161
|
+
headers: dict[str, str] | None = None,
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
status, body = _post_json_with_status(url, payload, headers)
|
|
164
|
+
if status >= 400:
|
|
165
|
+
raise MatpropsError(
|
|
166
|
+
str(
|
|
167
|
+
body.get("error")
|
|
168
|
+
or body.get("message")
|
|
169
|
+
or f"Request failed ({status})."
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return body
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _post_json_with_status(
|
|
176
|
+
url: str,
|
|
177
|
+
payload: dict[str, Any],
|
|
178
|
+
headers: dict[str, str] | None = None,
|
|
179
|
+
) -> tuple[int, dict[str, Any]]:
|
|
180
|
+
return _request_json("POST", url, payload, headers)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _request_json(
|
|
184
|
+
method: str,
|
|
185
|
+
url: str,
|
|
186
|
+
payload: dict[str, Any] | None = None,
|
|
187
|
+
headers: dict[str, str] | None = None,
|
|
188
|
+
) -> tuple[int, dict[str, Any]]:
|
|
189
|
+
request_headers = {"Accept": "application/json", **(headers or {})}
|
|
190
|
+
data = None
|
|
191
|
+
if payload is not None:
|
|
192
|
+
request_headers["Content-Type"] = "application/json"
|
|
193
|
+
data = json.dumps(payload).encode("utf-8")
|
|
194
|
+
request = urllib.request.Request(
|
|
195
|
+
url,
|
|
196
|
+
data=data,
|
|
197
|
+
headers=request_headers,
|
|
198
|
+
method=method,
|
|
199
|
+
)
|
|
200
|
+
try:
|
|
201
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
202
|
+
return response.status, _decode_json(response.read())
|
|
203
|
+
except urllib.error.HTTPError as error:
|
|
204
|
+
return error.code, _decode_json(error.read())
|
|
205
|
+
except urllib.error.URLError as error:
|
|
206
|
+
raise MatpropsError(f"Could not reach {url}: {error.reason}") from error
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _graphql_error_message(body: dict[str, Any], status: int) -> str:
|
|
210
|
+
errors = body.get("errors")
|
|
211
|
+
if isinstance(errors, list) and errors:
|
|
212
|
+
first = errors[0]
|
|
213
|
+
if isinstance(first, dict) and first.get("message"):
|
|
214
|
+
return str(first["message"])
|
|
215
|
+
return str(
|
|
216
|
+
body.get("error") or body.get("message") or f"Request failed ({status})."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _decode_json(raw: bytes) -> dict[str, Any]:
|
|
221
|
+
if not raw:
|
|
222
|
+
return {}
|
|
223
|
+
try:
|
|
224
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
225
|
+
except json.JSONDecodeError:
|
|
226
|
+
return {"error": raw.decode("utf-8", errors="replace")}
|
|
227
|
+
return parsed if isinstance(parsed, dict) else {"error": parsed}
|
matprops_sdk/bundle.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Helpers for unpacking uploaded simulation bundles (.zip).
|
|
2
|
+
|
|
3
|
+
Used by sim-builder to turn a published zip back into a project directory,
|
|
4
|
+
applying a path-traversal guard and the same wrapper-directory flattening
|
|
5
|
+
rule sim-server uses when it locates ``manifest.json`` at upload time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
JUNK_ENTRIES = frozenset({"__MACOSX"})
|
|
15
|
+
MAX_WRAPPER_DEPTH = 10
|
|
16
|
+
#: Either of these at a directory's top level marks it as the project root.
|
|
17
|
+
ROOT_MARKERS = ("manifest.json", "pyproject.toml")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def safe_extract(archive: zipfile.ZipFile, dest_dir: Path) -> None:
|
|
21
|
+
"""Extract ``archive`` into ``dest_dir``, refusing entries that escape it."""
|
|
22
|
+
dest_dir = dest_dir.resolve()
|
|
23
|
+
for member in archive.namelist():
|
|
24
|
+
target = (dest_dir / member).resolve()
|
|
25
|
+
if target != dest_dir and dest_dir not in target.parents:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Refusing to extract entry outside destination: {member!r}"
|
|
28
|
+
)
|
|
29
|
+
archive.extractall(dest_dir)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def flatten_wrapper_dirs(dest_dir: Path) -> None:
|
|
33
|
+
"""Hoist contents up when the archive was zipped with a wrapper folder.
|
|
34
|
+
|
|
35
|
+
Some tools (e.g. macOS Finder's "Compress") zip a project as
|
|
36
|
+
``<name>/manifest.json`` instead of ``manifest.json``, plus a stray
|
|
37
|
+
``__MACOSX/`` directory. matprops only looks for the project's marker
|
|
38
|
+
files in the current directory and its parents, never its children, so
|
|
39
|
+
drill down through single-directory wrappers until the project root is
|
|
40
|
+
found. sim-server's manifest check applies the same rule.
|
|
41
|
+
"""
|
|
42
|
+
for _ in range(MAX_WRAPPER_DEPTH):
|
|
43
|
+
if any((dest_dir / marker).is_file() for marker in ROOT_MARKERS):
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
for junk in list(dest_dir.iterdir()):
|
|
47
|
+
if junk.name in JUNK_ENTRIES:
|
|
48
|
+
shutil.rmtree(junk) if junk.is_dir() else junk.unlink()
|
|
49
|
+
|
|
50
|
+
entries = list(dest_dir.iterdir())
|
|
51
|
+
if len(entries) != 1 or not entries[0].is_dir():
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
wrapper = entries[0]
|
|
55
|
+
for item in wrapper.iterdir():
|
|
56
|
+
shutil.move(str(item), str(dest_dir / item.name))
|
|
57
|
+
wrapper.rmdir()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract_bundle(zip_path: Path, dest_dir: Path) -> None:
|
|
61
|
+
"""Extract a simulation zip into ``dest_dir`` and normalise its layout."""
|
|
62
|
+
with zipfile.ZipFile(zip_path) as archive:
|
|
63
|
+
safe_extract(archive, dest_dir)
|
|
64
|
+
flatten_wrapper_dirs(dest_dir)
|
matprops_sdk/cli.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import zipfile
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from matprops_sdk.manifest import build_manifest
|
|
15
|
+
from matprops_sdk.publish import (
|
|
16
|
+
post_simulation_zip,
|
|
17
|
+
resolve_api_key,
|
|
18
|
+
resolve_api_url,
|
|
19
|
+
zip_project_directory,
|
|
20
|
+
)
|
|
21
|
+
from matprops_sdk.runtime import (
|
|
22
|
+
Entrypoint,
|
|
23
|
+
MatpropsError,
|
|
24
|
+
load_simulation,
|
|
25
|
+
resolve_entrypoint,
|
|
26
|
+
run_local,
|
|
27
|
+
schema_document,
|
|
28
|
+
utc_run_timestamp,
|
|
29
|
+
)
|
|
30
|
+
from matprops_sdk.simulation import SimulationDefinition
|
|
31
|
+
|
|
32
|
+
_PACKAGE_OUTPUT_DIR = "dist"
|
|
33
|
+
|
|
34
|
+
_SIMULATION_TEMPLATE = """from matprops_sdk import (
|
|
35
|
+
Field,
|
|
36
|
+
InputModel,
|
|
37
|
+
OutputModel,
|
|
38
|
+
RunContext,
|
|
39
|
+
simulation,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Inputs(InputModel):
|
|
44
|
+
var1: float = Field(description="First addend")
|
|
45
|
+
var2: float = Field(description="Second addend")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Outputs(OutputModel):
|
|
49
|
+
sum: float
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@simulation(name="add-numbers", inputs=Inputs, outputs=Outputs)
|
|
53
|
+
def run(inputs: Inputs, context: RunContext) -> Outputs:
|
|
54
|
+
trace = context.artifact("trace.txt", "Calculation trace")
|
|
55
|
+
trace.write_text(f"{inputs.var1} + {inputs.var2}\\n", encoding="utf-8")
|
|
56
|
+
return Outputs(sum=inputs.var1 + inputs.var2)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
_PYPROJECT_TEMPLATE = """[build-system]
|
|
60
|
+
requires = ["hatchling"]
|
|
61
|
+
build-backend = "hatchling.build"
|
|
62
|
+
|
|
63
|
+
[project]
|
|
64
|
+
name = "{project_name}"
|
|
65
|
+
version = "0.1.0"
|
|
66
|
+
requires-python = ">=3.11"
|
|
67
|
+
dependencies = ["matprops-sdk"]
|
|
68
|
+
|
|
69
|
+
[tool.matprops]
|
|
70
|
+
entrypoint = "simulation:run"
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_INPUT_TEMPLATE = """{
|
|
74
|
+
"var1": 1.0,
|
|
75
|
+
"var2": 2.0
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _add_entrypoint_argument(parser: argparse.ArgumentParser) -> None:
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--entrypoint",
|
|
83
|
+
help='Override [tool.matprops].entrypoint, for example "simulation:run".',
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
88
|
+
parser = argparse.ArgumentParser(
|
|
89
|
+
prog="matprops",
|
|
90
|
+
description="Define, run, and authenticate Matprops simulations.",
|
|
91
|
+
)
|
|
92
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
93
|
+
|
|
94
|
+
init_parser = subparsers.add_parser("init", help="Scaffold a simulation project.")
|
|
95
|
+
init_parser.add_argument("directory", nargs="?", default=".")
|
|
96
|
+
|
|
97
|
+
validate_parser = subparsers.add_parser(
|
|
98
|
+
"validate",
|
|
99
|
+
help="Validate a simulation entrypoint and its schemas.",
|
|
100
|
+
)
|
|
101
|
+
_add_entrypoint_argument(validate_parser)
|
|
102
|
+
|
|
103
|
+
schema_parser = subparsers.add_parser(
|
|
104
|
+
"schema",
|
|
105
|
+
help="Generate input and output JSON Schema.",
|
|
106
|
+
)
|
|
107
|
+
_add_entrypoint_argument(schema_parser)
|
|
108
|
+
schema_parser.add_argument(
|
|
109
|
+
"--output-dir",
|
|
110
|
+
type=Path,
|
|
111
|
+
help="Write input.schema.json and output.schema.json instead of stdout.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
run_parser = subparsers.add_parser("run", help="Run a simulation locally.")
|
|
115
|
+
_add_entrypoint_argument(run_parser)
|
|
116
|
+
run_parser.add_argument(
|
|
117
|
+
"--input",
|
|
118
|
+
required=True,
|
|
119
|
+
help="Input JSON file path, or an inline JSON object/array string.",
|
|
120
|
+
)
|
|
121
|
+
run_parser.add_argument(
|
|
122
|
+
"--output-dir",
|
|
123
|
+
type=Path,
|
|
124
|
+
default=Path(".run-results"),
|
|
125
|
+
help="Parent directory for timestamped run folders.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
run_remote_parser = subparsers.add_parser(
|
|
129
|
+
"run-remote",
|
|
130
|
+
help="Execute a platform-scheduled run and report results to sim-server "
|
|
131
|
+
"(the container entrypoint; reads MATPROPS_RUN_ID, MATPROPS_RUN_TOKEN, "
|
|
132
|
+
"MATPROPS_API_URL). You probably don't need to use this.",
|
|
133
|
+
)
|
|
134
|
+
_add_entrypoint_argument(run_remote_parser)
|
|
135
|
+
run_remote_parser.add_argument("--run-id", help="Overrides MATPROPS_RUN_ID.")
|
|
136
|
+
run_remote_parser.add_argument("--token", help="Overrides MATPROPS_RUN_TOKEN.")
|
|
137
|
+
run_remote_parser.add_argument(
|
|
138
|
+
"--url",
|
|
139
|
+
help="sim-server base URL. Overrides MATPROPS_API_URL "
|
|
140
|
+
"(default https://api.matprops.com).",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
login_parser = subparsers.add_parser(
|
|
144
|
+
"login",
|
|
145
|
+
help="Sign in through the Matprops web app and store credentials.",
|
|
146
|
+
)
|
|
147
|
+
login_parser.add_argument(
|
|
148
|
+
"--app-url",
|
|
149
|
+
help="Matprops web app URL. Defaults to MATPROPS_APP_URL or "
|
|
150
|
+
"https://matprops.com.",
|
|
151
|
+
)
|
|
152
|
+
login_parser.add_argument(
|
|
153
|
+
"--api-url",
|
|
154
|
+
help="Matprops API URL. Defaults to MATPROPS_API_URL or "
|
|
155
|
+
"https://api.matprops.com.",
|
|
156
|
+
)
|
|
157
|
+
login_parser.add_argument(
|
|
158
|
+
"--no-browser",
|
|
159
|
+
action="store_true",
|
|
160
|
+
help="Print the login URL without opening a browser.",
|
|
161
|
+
)
|
|
162
|
+
subparsers.add_parser("logout", help="Revoke and delete stored CLI credentials.")
|
|
163
|
+
subparsers.add_parser("whoami", help="Show the signed-in user and organization.")
|
|
164
|
+
|
|
165
|
+
package_parser = subparsers.add_parser(
|
|
166
|
+
"package",
|
|
167
|
+
help="Validate the simulation and write an uploadable zip (with "
|
|
168
|
+
"manifest.json) to dist/<name>.zip. No login required.",
|
|
169
|
+
)
|
|
170
|
+
_add_entrypoint_argument(package_parser)
|
|
171
|
+
package_parser.add_argument(
|
|
172
|
+
"--output",
|
|
173
|
+
type=Path,
|
|
174
|
+
help=f"Zip path to write. Defaults to {_PACKAGE_OUTPUT_DIR}/<name>.zip.",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
publish_parser = subparsers.add_parser(
|
|
178
|
+
"publish",
|
|
179
|
+
help="Package the current simulation and upload it to sim-server.",
|
|
180
|
+
)
|
|
181
|
+
_add_entrypoint_argument(publish_parser)
|
|
182
|
+
publish_parser.add_argument(
|
|
183
|
+
"--api-key",
|
|
184
|
+
help="API key. Overrides MATPROPS_API_KEY.",
|
|
185
|
+
)
|
|
186
|
+
publish_parser.add_argument(
|
|
187
|
+
"--url",
|
|
188
|
+
help="sim-server base URL. Overrides MATPROPS_API_URL "
|
|
189
|
+
"(default https://api.matprops.com).",
|
|
190
|
+
)
|
|
191
|
+
return parser
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def project_name_for(directory: Path) -> str:
|
|
195
|
+
slug = re.sub(r"[^a-z0-9]+", "-", directory.resolve().name.lower()).strip("-")
|
|
196
|
+
if not slug:
|
|
197
|
+
slug = "simulation"
|
|
198
|
+
return f"matprops-sim-{slug}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def init_project(directory: Path) -> None:
|
|
202
|
+
files = {
|
|
203
|
+
"simulation.py": _SIMULATION_TEMPLATE,
|
|
204
|
+
"pyproject.toml": _PYPROJECT_TEMPLATE.format(
|
|
205
|
+
project_name=project_name_for(directory)
|
|
206
|
+
),
|
|
207
|
+
"example.input.json": _INPUT_TEMPLATE,
|
|
208
|
+
}
|
|
209
|
+
existing = [name for name in files if (directory / name).exists()]
|
|
210
|
+
if existing:
|
|
211
|
+
raise MatpropsError(
|
|
212
|
+
"Refusing to overwrite existing files: " + ", ".join(existing)
|
|
213
|
+
)
|
|
214
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
for name, contents in files.items():
|
|
216
|
+
(directory / name).write_text(contents, encoding="utf-8")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def package_simulation(
|
|
220
|
+
definition: SimulationDefinition, entrypoint: Entrypoint
|
|
221
|
+
) -> bytes:
|
|
222
|
+
"""Build the upload bundle: project files plus a freshly computed manifest.
|
|
223
|
+
|
|
224
|
+
Shared by ``package`` and ``publish`` so both produce byte-identical zips.
|
|
225
|
+
"""
|
|
226
|
+
definition.input_schema()
|
|
227
|
+
definition.output_schema()
|
|
228
|
+
manifest = build_manifest(definition, entrypoint)
|
|
229
|
+
return zip_project_directory(entrypoint.project_dir, manifest)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_run_input(value: str) -> object:
|
|
233
|
+
stripped = value.strip()
|
|
234
|
+
if stripped.startswith("{") or stripped.startswith("["):
|
|
235
|
+
try:
|
|
236
|
+
return json.loads(stripped)
|
|
237
|
+
except json.JSONDecodeError as error:
|
|
238
|
+
raise MatpropsError(f"Invalid JSON input: {error}") from error
|
|
239
|
+
return json.loads(Path(value).read_text(encoding="utf-8"))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _write_schema_files(output_dir: Path, document: dict[str, object]) -> None:
|
|
243
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
for key, filename in (
|
|
245
|
+
("input_schema", "input.schema.json"),
|
|
246
|
+
("output_schema", "output.schema.json"),
|
|
247
|
+
):
|
|
248
|
+
(output_dir / filename).write_text(
|
|
249
|
+
json.dumps(document[key], indent=2) + "\n",
|
|
250
|
+
encoding="utf-8",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
255
|
+
args = build_parser().parse_args(argv)
|
|
256
|
+
try:
|
|
257
|
+
if args.command == "init":
|
|
258
|
+
target = Path(args.directory).resolve()
|
|
259
|
+
init_project(target)
|
|
260
|
+
print(f"Created Matprops simulation in {target}")
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
if args.command == "login":
|
|
264
|
+
from matprops_sdk.auth import login
|
|
265
|
+
|
|
266
|
+
login(
|
|
267
|
+
app_url=args.app_url,
|
|
268
|
+
api_url=args.api_url,
|
|
269
|
+
open_browser=not args.no_browser,
|
|
270
|
+
)
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
if args.command == "logout":
|
|
274
|
+
from matprops_sdk.auth import logout
|
|
275
|
+
|
|
276
|
+
logout()
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
if args.command == "whoami":
|
|
280
|
+
from matprops_sdk.auth import whoami
|
|
281
|
+
|
|
282
|
+
print(json.dumps(whoami(), indent=2))
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
if args.command == "run-remote":
|
|
286
|
+
from matprops_sdk.remote import reporter_from_env, run_remote
|
|
287
|
+
|
|
288
|
+
reporter = reporter_from_env(
|
|
289
|
+
api_url=args.url,
|
|
290
|
+
run_id=args.run_id,
|
|
291
|
+
token=args.token,
|
|
292
|
+
)
|
|
293
|
+
return run_remote(reporter, entrypoint=args.entrypoint)
|
|
294
|
+
|
|
295
|
+
entrypoint = resolve_entrypoint(args.entrypoint)
|
|
296
|
+
definition = load_simulation(entrypoint)
|
|
297
|
+
if args.command == "validate":
|
|
298
|
+
definition.input_schema()
|
|
299
|
+
definition.output_schema()
|
|
300
|
+
print(f"Simulation {definition.name!r} is valid.")
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
if args.command == "schema":
|
|
304
|
+
document = {**schema_document(definition), "entrypoint": entrypoint.value}
|
|
305
|
+
if args.output_dir is not None:
|
|
306
|
+
_write_schema_files(args.output_dir, document)
|
|
307
|
+
print(f"Wrote schemas to {args.output_dir.resolve()}")
|
|
308
|
+
else:
|
|
309
|
+
print(json.dumps(document, indent=2))
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
if args.command == "run":
|
|
313
|
+
input_data = load_run_input(args.input)
|
|
314
|
+
folder, started_at = utc_run_timestamp()
|
|
315
|
+
run_dir = args.output_dir / folder
|
|
316
|
+
output_data = run_local(
|
|
317
|
+
definition,
|
|
318
|
+
input_data,
|
|
319
|
+
run_dir,
|
|
320
|
+
started_at=started_at,
|
|
321
|
+
)
|
|
322
|
+
print(json.dumps(output_data, indent=2))
|
|
323
|
+
print(f"Run files written to {run_dir.resolve()}")
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
if args.command == "package":
|
|
327
|
+
zip_bytes = package_simulation(definition, entrypoint)
|
|
328
|
+
output = args.output or (
|
|
329
|
+
entrypoint.project_dir / _PACKAGE_OUTPUT_DIR / f"{definition.name}.zip"
|
|
330
|
+
)
|
|
331
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
output.write_bytes(zip_bytes)
|
|
333
|
+
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
|
|
334
|
+
entry_count = len(archive.namelist())
|
|
335
|
+
print(
|
|
336
|
+
f"Packaged {definition.name!r} ({entry_count} files, "
|
|
337
|
+
f"{len(zip_bytes)} bytes) to {output.resolve()}"
|
|
338
|
+
)
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
if args.command == "publish":
|
|
342
|
+
zip_bytes = package_simulation(definition, entrypoint)
|
|
343
|
+
upload = post_simulation_zip(
|
|
344
|
+
resolve_api_url(args.url),
|
|
345
|
+
resolve_api_key(args.api_key),
|
|
346
|
+
zip_bytes,
|
|
347
|
+
)
|
|
348
|
+
print(json.dumps(upload, indent=2))
|
|
349
|
+
return 0
|
|
350
|
+
except (MatpropsError, ValidationError, OSError, ValueError, TypeError) as error:
|
|
351
|
+
print(f"error: {error}", file=sys.stderr)
|
|
352
|
+
return 1
|
|
353
|
+
|
|
354
|
+
raise AssertionError(f"Unhandled command: {args.command}")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__":
|
|
358
|
+
raise SystemExit(main())
|