Mimiry 0.2.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.
mimiry/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """mimiry — Python SDK for Mimiry GPU compute (softlaunch).
2
+
3
+ v0.2.0 — wraps the existing /api/compute/v1/sessions API. See README.md.
4
+ """
5
+
6
+ from mimiry._config import configure, get_config
7
+ from mimiry.exceptions import (
8
+ MimiryError,
9
+ AuthError,
10
+ SessionError,
11
+ SessionFailed,
12
+ SessionTimeout,
13
+ ResultParseError,
14
+ )
15
+ from mimiry.function import function, Function
16
+ from mimiry.image import Image
17
+ from mimiry.run import run
18
+
19
+ __version__ = "0.2.0"
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "configure",
24
+ "get_config",
25
+ "function",
26
+ "Function",
27
+ "Image",
28
+ "run",
29
+ "MimiryError",
30
+ "AuthError",
31
+ "SessionError",
32
+ "SessionFailed",
33
+ "SessionTimeout",
34
+ "ResultParseError",
35
+ ]
mimiry/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enables ``python -m mimiry ...`` (notably ``python -m mimiry setup``)."""
2
+
3
+ import sys
4
+
5
+ from mimiry._cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
mimiry/_auth.py ADDED
@@ -0,0 +1,188 @@
1
+ """SSH-JWT authentication against softlaunch.mimiry.com.
2
+
3
+ Ports the algorithm from .claude/skills/mimiry-softlaunch/scripts/mimiry-auth.sh.
4
+ We shell out to ``ssh-keygen`` for signing — implementing the SSH signature
5
+ format in pure Python would mean re-deriving an OpenSSH-compatible format from
6
+ the ``cryptography`` library, and ``ssh-keygen`` is universally available on
7
+ the systems Mimiry users actually run on.
8
+
9
+ Tokens last 1 hour (Mimiry default). The Token class refreshes itself when
10
+ within 5 minutes of expiry.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import os
17
+ import secrets
18
+ import shutil
19
+ import subprocess
20
+ import tempfile
21
+ import time
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ import httpx
26
+
27
+ from mimiry.exceptions import AuthError
28
+
29
+ _TOKEN_REFRESH_BUFFER_SECONDS = 300 # refresh if < 5 min left
30
+ _DEFAULT_TOKEN_TTL_SECONDS = 3600
31
+
32
+
33
+ @dataclass
34
+ class Token:
35
+ """A short-lived JWT issued by Mimiry. Self-refreshes when near expiry."""
36
+
37
+ access_token: str
38
+ expires_at: float # unix seconds
39
+ fingerprint: str
40
+ ssh_key_path: Path
41
+ api_base: str
42
+
43
+ @property
44
+ def is_expired(self) -> bool:
45
+ return time.time() >= self.expires_at
46
+
47
+ @property
48
+ def is_near_expiry(self) -> bool:
49
+ return time.time() + _TOKEN_REFRESH_BUFFER_SECONDS >= self.expires_at
50
+
51
+ def refresh(self) -> "Token":
52
+ """Re-exchange the SSH signature for a fresh JWT. Mutates self."""
53
+ new = exchange_ssh_for_token(self.ssh_key_path, self.api_base)
54
+ self.access_token = new.access_token
55
+ self.expires_at = new.expires_at
56
+ return self
57
+
58
+ def get(self) -> str:
59
+ """Return the bearer string, refreshing if near expiry."""
60
+ if self.is_near_expiry:
61
+ self.refresh()
62
+ return self.access_token
63
+
64
+
65
+ def _normalize_key_path(key_path: str | Path) -> Path:
66
+ """Accept either the private key path or the .pub path; return the private path."""
67
+ p = Path(key_path).expanduser()
68
+ if p.suffix == ".pub":
69
+ p = p.with_suffix("")
70
+ if not p.is_file():
71
+ raise AuthError(f"SSH private key not found: {p}")
72
+ if not p.with_suffix(p.suffix + ".pub").is_file() and not Path(f"{p}.pub").is_file():
73
+ raise AuthError(f"SSH public key not found: {p}.pub")
74
+ return p
75
+
76
+
77
+ def _fingerprint(public_key_path: Path) -> str:
78
+ """Run ``ssh-keygen -lf`` to get the SHA256 fingerprint of a public key."""
79
+ if shutil.which("ssh-keygen") is None:
80
+ raise AuthError("ssh-keygen not found on PATH — required for Mimiry auth")
81
+ try:
82
+ out = subprocess.run(
83
+ ["ssh-keygen", "-lf", str(public_key_path)],
84
+ capture_output=True,
85
+ text=True,
86
+ check=True,
87
+ )
88
+ except subprocess.CalledProcessError as e:
89
+ raise AuthError(f"ssh-keygen -lf failed: {e.stderr.strip()}") from e
90
+ # Format: "<bits> <fingerprint> <comment> (<type>)"
91
+ parts = out.stdout.strip().split()
92
+ if len(parts) < 2:
93
+ raise AuthError(f"unexpected ssh-keygen output: {out.stdout!r}")
94
+ return parts[1]
95
+
96
+
97
+ def _sign(message: bytes, private_key_path: Path) -> bytes:
98
+ """Sign ``message`` with the SSH key under the ``mimiry-auth`` namespace.
99
+
100
+ Returns the raw .sig file contents (OpenSSH SSHSIG format), as Mimiry expects.
101
+ """
102
+ with tempfile.TemporaryDirectory() as tmpdir:
103
+ msg_path = Path(tmpdir) / "msg"
104
+ msg_path.write_bytes(message)
105
+ try:
106
+ subprocess.run(
107
+ ["ssh-keygen", "-Y", "sign", "-f", str(private_key_path), "-n", "mimiry-auth",
108
+ str(msg_path)],
109
+ capture_output=True,
110
+ check=True,
111
+ )
112
+ except subprocess.CalledProcessError as e:
113
+ raise AuthError(
114
+ f"ssh-keygen -Y sign failed: {e.stderr.decode(errors='replace').strip()}"
115
+ ) from e
116
+ sig_path = Path(f"{msg_path}.sig")
117
+ if not sig_path.is_file():
118
+ raise AuthError("ssh-keygen did not produce a .sig file")
119
+ return sig_path.read_bytes()
120
+
121
+
122
+ def exchange_ssh_for_token(
123
+ ssh_key_path: str | Path,
124
+ api_base: str = "https://softlaunch.mimiry.com",
125
+ ) -> Token:
126
+ """Do the SSH-signature → JWT exchange. Returns a Token."""
127
+ api_base = api_base.rstrip("/")
128
+ priv = _normalize_key_path(ssh_key_path)
129
+ pub = Path(f"{priv}.pub")
130
+
131
+ fingerprint = _fingerprint(pub)
132
+ timestamp = str(int(time.time()))
133
+ nonce = secrets.token_hex(16)
134
+
135
+ message = f"{fingerprint}\n{timestamp}\n{nonce}".encode()
136
+ signature_bytes = _sign(message, priv)
137
+ signature_b64 = base64.b64encode(signature_bytes).decode()
138
+
139
+ try:
140
+ resp = httpx.post(
141
+ f"{api_base}/api/v1/auth/token",
142
+ headers={
143
+ "X-SSH-Fingerprint": fingerprint,
144
+ "X-SSH-Signature": signature_b64,
145
+ "X-SSH-Timestamp": timestamp,
146
+ "X-SSH-Nonce": nonce,
147
+ "Content-Type": "application/json",
148
+ },
149
+ json={"expires_in": _DEFAULT_TOKEN_TTL_SECONDS},
150
+ timeout=30.0,
151
+ )
152
+ except httpx.HTTPError as e:
153
+ raise AuthError(f"token exchange request failed: {e}") from e
154
+
155
+ if resp.status_code != 200:
156
+ raise AuthError(
157
+ f"token exchange returned {resp.status_code}: {resp.text[:500]}"
158
+ )
159
+
160
+ body = resp.json()
161
+ access_token = body.get("access_token")
162
+ if not access_token:
163
+ raise AuthError(f"token exchange response missing access_token: {body}")
164
+
165
+ expires_in = body.get("expires_in", _DEFAULT_TOKEN_TTL_SECONDS)
166
+ return Token(
167
+ access_token=access_token,
168
+ expires_at=time.time() + float(expires_in),
169
+ fingerprint=fingerprint,
170
+ ssh_key_path=priv,
171
+ api_base=api_base,
172
+ )
173
+
174
+
175
+ def get_token(
176
+ ssh_key_path: str | Path | None = None,
177
+ api_base: str = "https://softlaunch.mimiry.com",
178
+ ) -> Token:
179
+ """Get a fresh Token. Falls back to ``MIMIRY_SSH_KEY`` env var when ``ssh_key_path`` is None."""
180
+ if ssh_key_path is None:
181
+ env_key = os.environ.get("MIMIRY_SSH_KEY")
182
+ if not env_key:
183
+ raise AuthError(
184
+ "no SSH key provided — pass ssh_key_path=, set MIMIRY_SSH_KEY env var, "
185
+ "or call mimiry.configure(ssh_key_path=...) first"
186
+ )
187
+ ssh_key_path = env_key
188
+ return exchange_ssh_for_token(ssh_key_path, api_base)
mimiry/_cli.py ADDED
@@ -0,0 +1,101 @@
1
+ """Minimal CLI for sanity-checking SDK auth + API connectivity.
2
+
3
+ This is intentionally tiny in v1 — the SDK's primary surface is the Python
4
+ decorator. The CLI exists so users can verify their auth setup before
5
+ writing code:
6
+
7
+ $ mimiry balance
8
+ {"account_type": "user", "balance": 49.95, ...}
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+
17
+ from mimiry._auth import get_token
18
+ from mimiry._client import MimiryClient
19
+ from mimiry._config import configure, get_config
20
+
21
+
22
+ def _client() -> MimiryClient:
23
+ cfg = get_config()
24
+ token = get_token(cfg.ssh_key_path, cfg.api_base)
25
+ return MimiryClient(token)
26
+
27
+
28
+ def cmd_balance(_: argparse.Namespace) -> int:
29
+ with _client() as c:
30
+ print(json.dumps(c.get_balance(), indent=2))
31
+ return 0
32
+
33
+
34
+ def cmd_quota(_: argparse.Namespace) -> int:
35
+ with _client() as c:
36
+ print(json.dumps(c.get_quota(), indent=2))
37
+ return 0
38
+
39
+
40
+ def cmd_availability(args: argparse.Namespace) -> int:
41
+ with _client() as c:
42
+ params = {}
43
+ if args.gpu_family:
44
+ params["gpu_family"] = args.gpu_family
45
+ print(json.dumps(c.get_availability(**params), indent=2))
46
+ return 0
47
+
48
+
49
+ def cmd_token(_: argparse.Namespace) -> int:
50
+ """Print a fresh JWT — useful for piping into curl during debugging."""
51
+ cfg = get_config()
52
+ token = get_token(cfg.ssh_key_path, cfg.api_base)
53
+ print(token.access_token)
54
+ return 0
55
+
56
+
57
+ def cmd_setup(args: argparse.Namespace) -> int:
58
+ """Interactive first-time auth wizard (also aliased as ``mimiry init``)."""
59
+ from mimiry._setup import run_setup_wizard
60
+
61
+ return run_setup_wizard(ssh_key_path=args.ssh_key, api_base=args.api_base)
62
+
63
+
64
+ def main(argv: list[str] | None = None) -> int:
65
+ parser = argparse.ArgumentParser(prog="mimiry", description=__doc__.splitlines()[0])
66
+ parser.add_argument(
67
+ "--ssh-key",
68
+ help="Path to SSH private key (overrides MIMIRY_SSH_KEY env var).",
69
+ )
70
+ parser.add_argument("--api-base", help="API base URL (default: softlaunch.mimiry.com).")
71
+ subs = parser.add_subparsers(dest="cmd", required=True)
72
+
73
+ subs.add_parser("balance", help="Show account balance.").set_defaults(func=cmd_balance)
74
+ subs.add_parser("quota", help="Show account quota / usage.").set_defaults(func=cmd_quota)
75
+
76
+ avail = subs.add_parser("availability", help="Show GPU availability (no auth).")
77
+ avail.add_argument("--gpu-family", help="Filter, e.g. T4 or H100.")
78
+ avail.set_defaults(func=cmd_availability)
79
+
80
+ subs.add_parser("token", help="Print a fresh JWT (for debugging).").set_defaults(func=cmd_token)
81
+
82
+ setup_help = "Interactive auth setup wizard (generate/register SSH key, verify)."
83
+ subs.add_parser("setup", help=setup_help).set_defaults(func=cmd_setup)
84
+ subs.add_parser("init", help="Alias for `setup`.").set_defaults(func=cmd_setup)
85
+
86
+ args = parser.parse_args(argv)
87
+
88
+ if args.ssh_key or args.api_base:
89
+ configure(ssh_key_path=args.ssh_key, api_base=args.api_base)
90
+
91
+ try:
92
+ return args.func(args)
93
+ except KeyboardInterrupt:
94
+ return 130
95
+ except Exception as e:
96
+ print(f"error: {e}", file=sys.stderr)
97
+ return 1
98
+
99
+
100
+ if __name__ == "__main__":
101
+ sys.exit(main())
mimiry/_client.py ADDED
@@ -0,0 +1,122 @@
1
+ """Thin HTTP wrapper over the Mimiry compute API.
2
+
3
+ Stateless except for the Token. Higher-level lifecycle helpers (polling for
4
+ state transitions, scanning logs for sentinels) live in ``_session.py``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from mimiry._auth import Token
14
+ from mimiry.exceptions import SessionError
15
+
16
+
17
+ class MimiryClient:
18
+ """Thin client for /api/v1/* and /api/compute/v1/*."""
19
+
20
+ def __init__(self, token: Token, http_timeout: float = 30.0) -> None:
21
+ self._token = token
22
+ self._compute_base = f"{token.api_base}/api/compute/v1"
23
+ self._http = httpx.Client(timeout=http_timeout)
24
+
25
+ def close(self) -> None:
26
+ self._http.close()
27
+
28
+ def __enter__(self) -> "MimiryClient":
29
+ return self
30
+
31
+ def __exit__(self, *exc: Any) -> None:
32
+ self.close()
33
+
34
+ @property
35
+ def token(self) -> Token:
36
+ return self._token
37
+
38
+ def _headers(self) -> dict[str, str]:
39
+ return {
40
+ "Authorization": f"Bearer {self._token.get()}",
41
+ "Content-Type": "application/json",
42
+ }
43
+
44
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
45
+ url = f"{self._compute_base}{path}"
46
+ try:
47
+ resp = self._http.request(method, url, headers=self._headers(), **kwargs)
48
+ except httpx.HTTPError as e:
49
+ raise SessionError(f"{method} {path} request failed: {e}") from e
50
+ return resp
51
+
52
+ @staticmethod
53
+ def _json_or_raise(resp: httpx.Response) -> Any:
54
+ if resp.status_code >= 400:
55
+ try:
56
+ body = resp.json()
57
+ except Exception:
58
+ body = resp.text[:500]
59
+ raise SessionError(f"HTTP {resp.status_code} on {resp.request.url}: {body}")
60
+ return resp.json()
61
+
62
+ # ────────── balance / quota / availability ──────────
63
+
64
+ def get_balance(self) -> dict:
65
+ return self._json_or_raise(self._request("GET", "/balance"))
66
+
67
+ def get_quota(self) -> dict:
68
+ return self._json_or_raise(self._request("GET", "/quota"))
69
+
70
+ def get_availability(self, **params: Any) -> dict:
71
+ """Public endpoint — no auth required, but it's convenient to call from the client."""
72
+ return self._json_or_raise(self._http.get(f"{self._compute_base}/availability", params=params))
73
+
74
+ # ────────── sessions ──────────
75
+
76
+ def create_session(self, payload: dict) -> dict:
77
+ """POST /sessions. Returns the initial session object (state=submitted)."""
78
+ return self._json_or_raise(self._request("POST", "/sessions", json=payload))
79
+
80
+ def get_session(self, session_id: str, *, events_tail: int | None = None) -> dict:
81
+ params = {}
82
+ if events_tail is not None:
83
+ params["events_tail"] = events_tail
84
+ return self._json_or_raise(self._request("GET", f"/sessions/{session_id}", params=params))
85
+
86
+ def list_sessions(self, **params: Any) -> list[dict]:
87
+ body = self._json_or_raise(self._request("GET", "/sessions", params=params))
88
+ return body.get("sessions", body) if isinstance(body, dict) else body
89
+
90
+ def terminate_session(self, session_id: str) -> dict | None:
91
+ """DELETE /sessions/{id}. Returns the response body or None on 202/204."""
92
+ resp = self._request("DELETE", f"/sessions/{session_id}")
93
+ if resp.status_code in (202, 204):
94
+ return None
95
+ return self._json_or_raise(resp)
96
+
97
+ def get_logs(self, session_id: str, *, tail: int = 200, timestamps: bool = False) -> dict:
98
+ """GET /sessions/{id}/logs.
99
+
100
+ Returns a dict like ``{"logs": "<text>"}`` on 200, or
101
+ ``{"retry_after_seconds": N}`` on 503 (container still booting).
102
+
103
+ Caller is responsible for retry/backoff loops — see :func:`mimiry._session.wait_for_marker`.
104
+ """
105
+ resp = self._request(
106
+ "GET",
107
+ f"/sessions/{session_id}/logs",
108
+ params={"tail": tail, "timestamps": "true" if timestamps else "false"},
109
+ )
110
+ if resp.status_code == 503:
111
+ try:
112
+ body = resp.json()
113
+ except Exception:
114
+ body = {"retry_after_seconds": 5}
115
+ return {"_status": 503, **body}
116
+ if resp.status_code == 409:
117
+ try:
118
+ body = resp.json()
119
+ except Exception:
120
+ body = {"message": resp.text}
121
+ return {"_status": 409, **body}
122
+ return {"_status": 200, **self._json_or_raise(resp)}
mimiry/_config.py ADDED
@@ -0,0 +1,58 @@
1
+ """Process-global SDK configuration. Holds auth + API base URL.
2
+
3
+ Users either set MIMIRY_SSH_KEY env var or call ``mimiry.configure(ssh_key_path=...)``
4
+ once. Subsequent calls override.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ DEFAULT_API_BASE = "https://softlaunch.mimiry.com"
14
+ DEFAULT_TIMEOUT_SECONDS = 1800 # 30 min — must accommodate ~2 min cold start + work
15
+
16
+
17
+ @dataclass
18
+ class Config:
19
+ ssh_key_path: Path | None = None
20
+ api_base: str = DEFAULT_API_BASE
21
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
22
+ poll_interval_seconds: float = 5.0
23
+ # Tighter than state polling — the marker-emission → auto_terminate window is
24
+ # only the wrapper's tail sleep (~20s), so we need ~2s cadence to catch it reliably.
25
+ log_poll_interval_seconds: float = 2.0
26
+ extras: dict = field(default_factory=dict)
27
+
28
+
29
+ _config = Config()
30
+
31
+
32
+ def configure(
33
+ ssh_key_path: str | Path | None = None,
34
+ api_base: str | None = None,
35
+ timeout_seconds: int | None = None,
36
+ poll_interval_seconds: float | None = None,
37
+ ) -> Config:
38
+ """Set SDK-wide configuration. Any argument left as ``None`` is unchanged."""
39
+ if ssh_key_path is not None:
40
+ _config.ssh_key_path = Path(ssh_key_path).expanduser()
41
+ if api_base is not None:
42
+ _config.api_base = api_base.rstrip("/")
43
+ if timeout_seconds is not None:
44
+ _config.timeout_seconds = timeout_seconds
45
+ if poll_interval_seconds is not None:
46
+ _config.poll_interval_seconds = poll_interval_seconds
47
+ return _config
48
+
49
+
50
+ def get_config() -> Config:
51
+ """Return the active configuration. Auto-bootstraps from env vars on first read."""
52
+ if _config.ssh_key_path is None:
53
+ env_key = os.environ.get("MIMIRY_SSH_KEY")
54
+ if env_key:
55
+ _config.ssh_key_path = Path(env_key).expanduser()
56
+ if "MIMIRY_API_BASE" in os.environ:
57
+ _config.api_base = os.environ["MIMIRY_API_BASE"].rstrip("/")
58
+ return _config