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 +35 -0
- mimiry/__main__.py +8 -0
- mimiry/_auth.py +188 -0
- mimiry/_cli.py +101 -0
- mimiry/_client.py +122 -0
- mimiry/_config.py +58 -0
- mimiry/_serialization.py +250 -0
- mimiry/_session.py +199 -0
- mimiry/_setup.py +212 -0
- mimiry/_ssh.py +320 -0
- mimiry/exceptions.py +47 -0
- mimiry/function.py +290 -0
- mimiry/image.py +72 -0
- mimiry/run.py +217 -0
- mimiry-0.2.1.dist-info/METADATA +130 -0
- mimiry-0.2.1.dist-info/RECORD +19 -0
- mimiry-0.2.1.dist-info/WHEEL +5 -0
- mimiry-0.2.1.dist-info/entry_points.txt +2 -0
- mimiry-0.2.1.dist-info/top_level.txt +1 -0
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
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
|