tamarind-cli 0.1.0__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.
- tamarind/__init__.py +16 -0
- tamarind/catalog.py +70 -0
- tamarind/cli/__init__.py +1 -0
- tamarind/cli/commands/__init__.py +1 -0
- tamarind/cli/commands/auth.py +90 -0
- tamarind/cli/commands/catalog.py +114 -0
- tamarind/cli/commands/files.py +113 -0
- tamarind/cli/commands/jobs.py +311 -0
- tamarind/cli/inputs.py +115 -0
- tamarind/cli/main.py +122 -0
- tamarind/cli/output.py +68 -0
- tamarind/config.py +152 -0
- tamarind/errors.py +59 -0
- tamarind/http.py +160 -0
- tamarind/jobs.py +106 -0
- tamarind/rest.py +192 -0
- tamarind_cli-0.1.0.dist-info/METADATA +131 -0
- tamarind_cli-0.1.0.dist-info/RECORD +20 -0
- tamarind_cli-0.1.0.dist-info/WHEEL +4 -0
- tamarind_cli-0.1.0.dist-info/entry_points.txt +2 -0
tamarind/config.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Configuration and credential resolution.
|
|
2
|
+
|
|
3
|
+
Settings are resolved in this order (first wins):
|
|
4
|
+
|
|
5
|
+
1. an explicit value passed on the command line (``--api-key`` etc.)
|
|
6
|
+
2. an environment variable (``TAMARIND_API_KEY`` / ``TAMARIND_API_BASE`` /
|
|
7
|
+
``TAMARIND_CATALOG_BASE`` / ``TAMARIND_PROFILE``)
|
|
8
|
+
3. the selected profile in ``~/.tamarind/config.json``
|
|
9
|
+
4. a built-in default (base URLs only — there is no default API key)
|
|
10
|
+
|
|
11
|
+
This mirrors how the AWS CLI and similar tools layer flags > env > file, so it
|
|
12
|
+
is predictable for both humans and agents (an agent typically just exports
|
|
13
|
+
``TAMARIND_API_KEY``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
DEFAULT_API_BASE = "https://app.tamarind.bio/api/"
|
|
24
|
+
# Discovery (tools/schema/modalities/functions) is served by the catalog
|
|
25
|
+
# service. It is a separate host from the job API because the catalog runs
|
|
26
|
+
# behind per-org visibility logic; see the package docstring.
|
|
27
|
+
DEFAULT_CATALOG_BASE = "https://mcp.tamarind.bio"
|
|
28
|
+
|
|
29
|
+
DEFAULT_PROFILE = "default"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def config_dir() -> Path:
|
|
33
|
+
"""Resolved each call so TAMARIND_CONFIG_DIR can change between invocations."""
|
|
34
|
+
return Path(os.environ.get("TAMARIND_CONFIG_DIR", Path.home() / ".tamarind"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def config_path() -> Path:
|
|
38
|
+
return config_dir() / "config.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Config:
|
|
43
|
+
"""Resolved settings for a single invocation."""
|
|
44
|
+
|
|
45
|
+
api_key: str | None
|
|
46
|
+
api_base: str
|
|
47
|
+
catalog_base: str
|
|
48
|
+
profile: str
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def has_key(self) -> bool:
|
|
52
|
+
return bool(self.api_key)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_store() -> dict:
|
|
56
|
+
path = config_path()
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(path.read_text())
|
|
61
|
+
except (json.JSONDecodeError, OSError):
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_store(store: dict) -> None:
|
|
66
|
+
path = config_path()
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
path.write_text(json.dumps(store, indent=2) + "\n")
|
|
69
|
+
# Credentials live here — keep the file private (best effort on POSIX).
|
|
70
|
+
try:
|
|
71
|
+
path.chmod(0o600)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalize_base(url: str) -> str:
|
|
77
|
+
"""Trailing slash matters for httpx base_url + relative paths."""
|
|
78
|
+
return url if url.endswith("/") else url + "/"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_profile_name(profile: str | None) -> str:
|
|
82
|
+
if profile:
|
|
83
|
+
return profile
|
|
84
|
+
if os.environ.get("TAMARIND_PROFILE"):
|
|
85
|
+
return os.environ["TAMARIND_PROFILE"]
|
|
86
|
+
store = _read_store()
|
|
87
|
+
return store.get("current_profile", DEFAULT_PROFILE)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_config(
|
|
91
|
+
*,
|
|
92
|
+
api_key: str | None = None,
|
|
93
|
+
api_base: str | None = None,
|
|
94
|
+
catalog_base: str | None = None,
|
|
95
|
+
profile: str | None = None,
|
|
96
|
+
) -> Config:
|
|
97
|
+
"""Resolve effective settings (flags > env > profile > default)."""
|
|
98
|
+
profile_name = resolve_profile_name(profile)
|
|
99
|
+
store = _read_store()
|
|
100
|
+
prof = store.get("profiles", {}).get(profile_name, {})
|
|
101
|
+
|
|
102
|
+
resolved_key = api_key or os.environ.get("TAMARIND_API_KEY") or prof.get("api_key")
|
|
103
|
+
resolved_api_base = (
|
|
104
|
+
api_base
|
|
105
|
+
or os.environ.get("TAMARIND_API_BASE")
|
|
106
|
+
or prof.get("api_base")
|
|
107
|
+
or DEFAULT_API_BASE
|
|
108
|
+
)
|
|
109
|
+
resolved_catalog_base = (
|
|
110
|
+
catalog_base
|
|
111
|
+
or os.environ.get("TAMARIND_CATALOG_BASE")
|
|
112
|
+
or prof.get("catalog_base")
|
|
113
|
+
or DEFAULT_CATALOG_BASE
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return Config(
|
|
117
|
+
api_key=resolved_key,
|
|
118
|
+
api_base=_normalize_base(resolved_api_base),
|
|
119
|
+
catalog_base=_normalize_base(resolved_catalog_base),
|
|
120
|
+
profile=profile_name,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def save_profile(
|
|
125
|
+
profile: str,
|
|
126
|
+
*,
|
|
127
|
+
api_key: str | None = None,
|
|
128
|
+
api_base: str | None = None,
|
|
129
|
+
catalog_base: str | None = None,
|
|
130
|
+
make_current: bool = True,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Persist credentials/endpoints for a profile to ``~/.tamarind/config.json``."""
|
|
133
|
+
store = _read_store()
|
|
134
|
+
profiles = store.setdefault("profiles", {})
|
|
135
|
+
prof = profiles.setdefault(profile, {})
|
|
136
|
+
if api_key is not None:
|
|
137
|
+
prof["api_key"] = api_key
|
|
138
|
+
if api_base is not None:
|
|
139
|
+
prof["api_base"] = api_base
|
|
140
|
+
if catalog_base is not None:
|
|
141
|
+
prof["catalog_base"] = catalog_base
|
|
142
|
+
if make_current:
|
|
143
|
+
store["current_profile"] = profile
|
|
144
|
+
_write_store(store)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def mask_key(key: str | None) -> str:
|
|
148
|
+
if not key:
|
|
149
|
+
return "<none>"
|
|
150
|
+
if len(key) <= 8:
|
|
151
|
+
return "*" * len(key)
|
|
152
|
+
return f"{key[:4]}…{key[-4:]}"
|
tamarind/errors.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Exception types and exit codes for the Tamarind client.
|
|
2
|
+
|
|
3
|
+
Exit codes are stable so agents and CI can branch on them:
|
|
4
|
+
|
|
5
|
+
0 success
|
|
6
|
+
1 generic / unexpected error
|
|
7
|
+
2 usage error (bad arguments) — Typer's default
|
|
8
|
+
3 authentication error (no key, or 401)
|
|
9
|
+
4 not found (404)
|
|
10
|
+
5 validation error (a job's settings failed validate-job, or a 400)
|
|
11
|
+
6 rate limited (429)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExitCode:
|
|
18
|
+
OK = 0
|
|
19
|
+
ERROR = 1
|
|
20
|
+
USAGE = 2
|
|
21
|
+
AUTH = 3
|
|
22
|
+
NOT_FOUND = 4
|
|
23
|
+
VALIDATION = 5
|
|
24
|
+
RATE_LIMIT = 6
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TamarindError(Exception):
|
|
28
|
+
"""Base class for all client errors. Carries a stable exit code."""
|
|
29
|
+
|
|
30
|
+
exit_code: int = ExitCode.ERROR
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, *, detail: object | None = None):
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.message = message
|
|
35
|
+
self.detail = detail
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthError(TamarindError):
|
|
39
|
+
exit_code = ExitCode.AUTH
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotFoundError(TamarindError):
|
|
43
|
+
exit_code = ExitCode.NOT_FOUND
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ValidationError(TamarindError):
|
|
47
|
+
exit_code = ExitCode.VALIDATION
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RateLimitError(TamarindError):
|
|
51
|
+
exit_code = ExitCode.RATE_LIMIT
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class APIError(TamarindError):
|
|
55
|
+
"""A non-2xx response that doesn't map to a more specific error."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, message: str, *, status_code: int, detail: object | None = None):
|
|
58
|
+
super().__init__(message, detail=detail)
|
|
59
|
+
self.status_code = status_code
|
tamarind/http.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Thin HTTP transport shared by the REST and catalog clients.
|
|
2
|
+
|
|
3
|
+
Wraps ``httpx.Client`` with the ``x-api-key`` header the Tamarind API expects,
|
|
4
|
+
and maps non-2xx responses onto the typed errors in :mod:`tamarind.errors` so
|
|
5
|
+
callers (and the CLI's exit codes) get consistent behaviour.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .errors import (
|
|
15
|
+
APIError,
|
|
16
|
+
AuthError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
TamarindError,
|
|
20
|
+
ValidationError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT = 120.0
|
|
24
|
+
USER_AGENT = "tamarind-cli"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HTTPClient:
|
|
28
|
+
"""A small wrapper around ``httpx.Client`` keyed by base URL + API key."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
base_url: str,
|
|
33
|
+
api_key: str | None,
|
|
34
|
+
*,
|
|
35
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
36
|
+
):
|
|
37
|
+
self.base_url = base_url
|
|
38
|
+
self.api_key = api_key
|
|
39
|
+
headers = {
|
|
40
|
+
"Accept": "application/json",
|
|
41
|
+
# Brotli is still decoded transparently by httpx; we just don't want
|
|
42
|
+
# surprises from upstream content-encoding negotiation.
|
|
43
|
+
"Accept-Encoding": "identity",
|
|
44
|
+
"User-Agent": f"{USER_AGENT}/{_version()}",
|
|
45
|
+
}
|
|
46
|
+
if api_key:
|
|
47
|
+
headers["x-api-key"] = api_key
|
|
48
|
+
self._client = httpx.Client(base_url=base_url, headers=headers, timeout=timeout)
|
|
49
|
+
|
|
50
|
+
# -- lifecycle ---------------------------------------------------------
|
|
51
|
+
def close(self) -> None:
|
|
52
|
+
self._client.close()
|
|
53
|
+
|
|
54
|
+
def __enter__(self) -> "HTTPClient":
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *exc: object) -> None:
|
|
58
|
+
self.close()
|
|
59
|
+
|
|
60
|
+
# -- requests ----------------------------------------------------------
|
|
61
|
+
def request(
|
|
62
|
+
self,
|
|
63
|
+
method: str,
|
|
64
|
+
path: str,
|
|
65
|
+
*,
|
|
66
|
+
params: dict[str, Any] | None = None,
|
|
67
|
+
json: Any | None = None,
|
|
68
|
+
timeout: float | None = None,
|
|
69
|
+
) -> httpx.Response:
|
|
70
|
+
if not self.api_key:
|
|
71
|
+
raise AuthError(
|
|
72
|
+
"No API key configured. Set TAMARIND_API_KEY, pass --api-key, "
|
|
73
|
+
"or run `tamarind auth login`."
|
|
74
|
+
)
|
|
75
|
+
# Drop None-valued query params so we don't send `?x=None`.
|
|
76
|
+
clean_params = (
|
|
77
|
+
{k: v for k, v in params.items() if v is not None} if params else None
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
resp = self._client.request(
|
|
81
|
+
method,
|
|
82
|
+
path.lstrip("/"),
|
|
83
|
+
params=clean_params,
|
|
84
|
+
json=json,
|
|
85
|
+
timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT,
|
|
86
|
+
)
|
|
87
|
+
except httpx.HTTPError as exc:
|
|
88
|
+
raise TamarindError(f"Network error talking to {self.base_url}: {exc}") from exc
|
|
89
|
+
|
|
90
|
+
if resp.is_success:
|
|
91
|
+
return resp
|
|
92
|
+
raise _map_error(resp)
|
|
93
|
+
|
|
94
|
+
def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
|
95
|
+
return _parse_json(self.request("GET", path, params=params))
|
|
96
|
+
|
|
97
|
+
def post_json(self, path: str, *, json: Any | None = None) -> Any:
|
|
98
|
+
return _parse_json(self.request("POST", path, json=json))
|
|
99
|
+
|
|
100
|
+
def delete_json(
|
|
101
|
+
self, path: str, *, params: dict[str, Any] | None = None, json: Any | None = None
|
|
102
|
+
) -> Any:
|
|
103
|
+
return _parse_json(self.request("DELETE", path, params=params, json=json))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_json(resp: httpx.Response) -> Any:
|
|
107
|
+
text = resp.text.strip()
|
|
108
|
+
if not text:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return resp.json()
|
|
112
|
+
except ValueError:
|
|
113
|
+
# Some endpoints (e.g. /result) return a bare presigned URL string.
|
|
114
|
+
return text
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _extract_message(resp: httpx.Response) -> str:
|
|
118
|
+
try:
|
|
119
|
+
body = resp.json()
|
|
120
|
+
except ValueError:
|
|
121
|
+
return resp.text.strip() or resp.reason_phrase or f"HTTP {resp.status_code}"
|
|
122
|
+
if isinstance(body, dict):
|
|
123
|
+
for key in ("error", "message", "detail"):
|
|
124
|
+
if body.get(key):
|
|
125
|
+
return str(body[key])
|
|
126
|
+
if isinstance(body, str):
|
|
127
|
+
return body
|
|
128
|
+
return resp.reason_phrase or f"HTTP {resp.status_code}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _map_error(resp: httpx.Response) -> TamarindError:
|
|
132
|
+
msg = _extract_message(resp)
|
|
133
|
+
code = resp.status_code
|
|
134
|
+
ml = msg.lower()
|
|
135
|
+
auth_ish = "api key" in ml or "api-key" in ml or "apikey" in ml or "unauthorized" in ml
|
|
136
|
+
notfound_ish = "not found" in ml or "does not exist" in ml or "no such" in ml
|
|
137
|
+
if code == 401:
|
|
138
|
+
return AuthError(f"Unauthorized: {msg}")
|
|
139
|
+
if code == 403:
|
|
140
|
+
return AuthError(f"Access denied: {msg}")
|
|
141
|
+
if code == 404:
|
|
142
|
+
return NotFoundError(msg)
|
|
143
|
+
if code == 400:
|
|
144
|
+
# The API uses 400 for several distinct failures; classify by message so
|
|
145
|
+
# exit codes are consistent: bad/missing key -> auth (3), missing job/file
|
|
146
|
+
# -> not-found (4), otherwise a genuine validation error (5).
|
|
147
|
+
if auth_ish:
|
|
148
|
+
return AuthError(f"Unauthorized: {msg}")
|
|
149
|
+
if notfound_ish:
|
|
150
|
+
return NotFoundError(msg)
|
|
151
|
+
return ValidationError(msg)
|
|
152
|
+
if code == 429:
|
|
153
|
+
return RateLimitError(f"Rate limited: {msg}")
|
|
154
|
+
return APIError(msg, status_code=code)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _version() -> str:
|
|
158
|
+
from . import __version__
|
|
159
|
+
|
|
160
|
+
return __version__
|
tamarind/jobs.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Job-status helpers: normalization and polling.
|
|
2
|
+
|
|
3
|
+
The REST job objects use capitalized keys (``JobName``, ``JobStatus``, ...).
|
|
4
|
+
The status enum is {Complete, In Queue, Running, Stopped, Deleted}; we also
|
|
5
|
+
treat Failed/Cancelled/Error as terminal defensively in case the backend grows
|
|
6
|
+
new terminal states.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from . import rest
|
|
15
|
+
from .errors import NotFoundError
|
|
16
|
+
from .http import HTTPClient
|
|
17
|
+
|
|
18
|
+
# Compared case-insensitively.
|
|
19
|
+
TERMINAL_STATUSES = {"complete", "completed", "stopped", "deleted", "failed", "cancelled", "error"}
|
|
20
|
+
SUCCESS_STATUSES = {"complete", "completed"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def job_status(job: dict[str, Any]) -> str | None:
|
|
24
|
+
"""Read a job's status regardless of which casing the API used."""
|
|
25
|
+
for key in ("JobStatus", "status", "Status"):
|
|
26
|
+
if job.get(key):
|
|
27
|
+
return str(job[key])
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def job_name(job: dict[str, Any]) -> str | None:
|
|
32
|
+
for key in ("JobName", "jobName", "name"):
|
|
33
|
+
if job.get(key):
|
|
34
|
+
return str(job[key])
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_terminal(status: str | None) -> bool:
|
|
39
|
+
return bool(status) and status.lower() in TERMINAL_STATUSES
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_success(status: str | None) -> bool:
|
|
43
|
+
return bool(status) and status.lower() in SUCCESS_STATUSES
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fetch_job(client: HTTPClient, name: str) -> dict[str, Any]:
|
|
47
|
+
"""Fetch a single job by name. Raises NotFoundError if it doesn't exist."""
|
|
48
|
+
resp = rest.get_jobs(client, job_name=name)
|
|
49
|
+
job = _extract_single(resp, name)
|
|
50
|
+
if job is None:
|
|
51
|
+
raise NotFoundError(f"Job '{name}' not found")
|
|
52
|
+
return job
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _extract_single(resp: Any, name: str) -> dict[str, Any] | None:
|
|
56
|
+
if not isinstance(resp, dict):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Shape A: {"jobs": [...]}
|
|
60
|
+
if "jobs" in resp:
|
|
61
|
+
jobs = resp.get("jobs") or []
|
|
62
|
+
for j in jobs:
|
|
63
|
+
if job_name(j) == name:
|
|
64
|
+
return j
|
|
65
|
+
return jobs[0] if jobs else None
|
|
66
|
+
|
|
67
|
+
# Shape B: an index-keyed map {"0": {...}, "1": {...}, "statuses": {...}} —
|
|
68
|
+
# what the job API returns for a single-jobName query.
|
|
69
|
+
indexed = [v for k, v in resp.items() if k.isdigit() and isinstance(v, dict)]
|
|
70
|
+
if indexed:
|
|
71
|
+
for j in indexed:
|
|
72
|
+
if job_name(j) == name:
|
|
73
|
+
return j
|
|
74
|
+
return indexed[0]
|
|
75
|
+
|
|
76
|
+
# Shape C: a bare JobInfo object.
|
|
77
|
+
if any(k in resp for k in ("JobName", "JobStatus", "jobName", "status")):
|
|
78
|
+
return resp
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def wait_for_job(
|
|
83
|
+
client: HTTPClient,
|
|
84
|
+
name: str,
|
|
85
|
+
*,
|
|
86
|
+
poll_interval: float = 10.0,
|
|
87
|
+
timeout: float | None = None,
|
|
88
|
+
on_poll: Callable[[dict[str, Any]], None] | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""Block until ``name`` reaches a terminal status (or ``timeout`` elapses).
|
|
91
|
+
|
|
92
|
+
Returns the final job object. Raises TimeoutError if a timeout is set and
|
|
93
|
+
the job is still running when it elapses.
|
|
94
|
+
"""
|
|
95
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
96
|
+
while True:
|
|
97
|
+
job = fetch_job(client, name)
|
|
98
|
+
if on_poll is not None:
|
|
99
|
+
on_poll(job)
|
|
100
|
+
if is_terminal(job_status(job)):
|
|
101
|
+
return job
|
|
102
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
103
|
+
raise TimeoutError(
|
|
104
|
+
f"Job '{name}' still {job_status(job)!r} after {timeout:.0f}s"
|
|
105
|
+
)
|
|
106
|
+
time.sleep(poll_interval)
|
tamarind/rest.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Typed wrappers over the Tamarind REST API (the job/file surface).
|
|
2
|
+
|
|
3
|
+
Every function here maps 1:1 onto an operation in ``openapi-mcp.yaml`` — the
|
|
4
|
+
same spec the Tamarind MCP server is built from. Keeping this a thin, literal
|
|
5
|
+
mapping (no business logic) is what keeps the CLI and the MCP from drifting on
|
|
6
|
+
the REST surface. Discovery/catalog calls live in :mod:`tamarind.catalog`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import APIError
|
|
14
|
+
from .http import HTTPClient
|
|
15
|
+
|
|
16
|
+
# Query params that the API expects as the literal string "true" rather than a
|
|
17
|
+
# JSON boolean.
|
|
18
|
+
_TRUE = "true"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def submit_job(
|
|
22
|
+
client: HTTPClient, *, job_name: str, job_type: str, settings: dict[str, Any]
|
|
23
|
+
) -> Any:
|
|
24
|
+
"""POST /submit-job — submit a single job. Body: {jobName, type, settings}."""
|
|
25
|
+
return client.post_json(
|
|
26
|
+
"submit-job",
|
|
27
|
+
json={"jobName": job_name, "type": job_type, "settings": settings},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_job(
|
|
32
|
+
client: HTTPClient, *, job_name: str, job_type: str, settings: dict[str, Any]
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""POST /validate-job — returns {valid, normalized?, error?} (HTTP 200 either way)."""
|
|
35
|
+
return client.post_json(
|
|
36
|
+
"validate-job",
|
|
37
|
+
json={"jobName": job_name, "type": job_type, "settings": settings},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def submit_batch(
|
|
42
|
+
client: HTTPClient,
|
|
43
|
+
*,
|
|
44
|
+
batch_name: str,
|
|
45
|
+
job_type: str,
|
|
46
|
+
settings: list[dict[str, Any]],
|
|
47
|
+
job_names: list[str] | None = None,
|
|
48
|
+
max_runtime_seconds: int | None = None,
|
|
49
|
+
) -> Any:
|
|
50
|
+
"""POST /submit-batch — submit many jobs as one batch."""
|
|
51
|
+
body: dict[str, Any] = {
|
|
52
|
+
"batchName": batch_name,
|
|
53
|
+
"type": job_type,
|
|
54
|
+
"settings": settings,
|
|
55
|
+
}
|
|
56
|
+
if job_names is not None:
|
|
57
|
+
body["jobNames"] = job_names
|
|
58
|
+
if max_runtime_seconds is not None:
|
|
59
|
+
body["maxRuntimeSeconds"] = max_runtime_seconds
|
|
60
|
+
return client.post_json("submit-batch", json=body)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_jobs(
|
|
64
|
+
client: HTTPClient,
|
|
65
|
+
*,
|
|
66
|
+
job_name: str | None = None,
|
|
67
|
+
batch: str | None = None,
|
|
68
|
+
start_key: str | None = None,
|
|
69
|
+
limit: int | None = None,
|
|
70
|
+
organization: bool = False,
|
|
71
|
+
include_subjobs: bool = False,
|
|
72
|
+
job_email: str | None = None,
|
|
73
|
+
) -> Any:
|
|
74
|
+
"""GET /jobs — list jobs, or fetch one when ``job_name`` is given."""
|
|
75
|
+
params = {
|
|
76
|
+
"jobName": job_name,
|
|
77
|
+
"batch": batch,
|
|
78
|
+
"startKey": start_key,
|
|
79
|
+
"limit": limit,
|
|
80
|
+
"organization": _TRUE if organization else None,
|
|
81
|
+
"includeSubjobs": _TRUE if include_subjobs else None,
|
|
82
|
+
"jobEmail": job_email,
|
|
83
|
+
}
|
|
84
|
+
return client.get_json("jobs", params=params)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_result(
|
|
88
|
+
client: HTTPClient,
|
|
89
|
+
*,
|
|
90
|
+
job_name: str,
|
|
91
|
+
job_email: str | None = None,
|
|
92
|
+
file_name: str | None = None,
|
|
93
|
+
pdbs_only: bool | None = None,
|
|
94
|
+
) -> Any:
|
|
95
|
+
"""POST /result — returns an S3 presigned URL (string) for the result bundle."""
|
|
96
|
+
body: dict[str, Any] = {"jobName": job_name}
|
|
97
|
+
if job_email is not None:
|
|
98
|
+
body["jobEmail"] = job_email
|
|
99
|
+
if file_name is not None:
|
|
100
|
+
body["fileName"] = file_name
|
|
101
|
+
if pdbs_only is not None:
|
|
102
|
+
body["pdbsOnly"] = pdbs_only
|
|
103
|
+
return client.post_json("result", json=body)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def upload_file_url(client: HTTPClient, *, filename: str) -> dict:
|
|
107
|
+
"""POST /uploadFile — returns {signedUrl, filename}; PUT the bytes to signedUrl."""
|
|
108
|
+
return client.post_json("uploadFile", json={"filename": filename})
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cancel_job(
|
|
112
|
+
client: HTTPClient, *, job_name: str | None = None, job_id: str | None = None
|
|
113
|
+
) -> dict:
|
|
114
|
+
"""POST /cancelJob — soft-stop a queued/running job (preserves the row)."""
|
|
115
|
+
body: dict[str, Any] = {}
|
|
116
|
+
if job_name is not None:
|
|
117
|
+
body["jobName"] = job_name
|
|
118
|
+
if job_id is not None:
|
|
119
|
+
body["jobId"] = job_id
|
|
120
|
+
return client.post_json("cancelJob", json=body)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cancel_batch(client: HTTPClient, *, batch_name: str) -> dict:
|
|
124
|
+
"""POST /cancelBatch — soft-stop every job in a batch or pipeline."""
|
|
125
|
+
return client.post_json("cancelBatch", json={"batchName": batch_name})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def delete_job(client: HTTPClient, *, job_name: str) -> Any:
|
|
129
|
+
"""DELETE /delete-job — permanently remove a job (and subjobs, for batches).
|
|
130
|
+
|
|
131
|
+
The endpoint may return a bare string (not JSON), so parse defensively.
|
|
132
|
+
"""
|
|
133
|
+
return client.delete_json("delete-job", json={"jobName": job_name})
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def delete_file(
|
|
137
|
+
client: HTTPClient, *, file_path: str | None = None, folder: str | None = None
|
|
138
|
+
) -> Any:
|
|
139
|
+
"""Delete a file, or every file under a folder.
|
|
140
|
+
|
|
141
|
+
The API expects DELETE (a GET returns 405 "Use DELETE or POST"); some older
|
|
142
|
+
deployments may still want GET, so fall back on a 405.
|
|
143
|
+
"""
|
|
144
|
+
params = {"filePath": file_path, "folder": folder}
|
|
145
|
+
try:
|
|
146
|
+
return client.delete_json("delete-file", params=params)
|
|
147
|
+
except APIError as exc:
|
|
148
|
+
if getattr(exc, "status_code", None) == 405:
|
|
149
|
+
return client.get_json("delete-file", params=params)
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_files(
|
|
154
|
+
client: HTTPClient,
|
|
155
|
+
*,
|
|
156
|
+
limit: int | None = None,
|
|
157
|
+
offset: int | None = None,
|
|
158
|
+
types: str | None = None,
|
|
159
|
+
search: str | None = None,
|
|
160
|
+
folder: str | None = None,
|
|
161
|
+
include_folders: bool = False,
|
|
162
|
+
include_all: bool = False,
|
|
163
|
+
include_metadata: bool = False,
|
|
164
|
+
) -> Any:
|
|
165
|
+
"""GET /files — list files in the workspace, with filtering/pagination."""
|
|
166
|
+
params = {
|
|
167
|
+
"limit": limit,
|
|
168
|
+
"offset": offset,
|
|
169
|
+
"types": types,
|
|
170
|
+
"search": search,
|
|
171
|
+
"folder": folder,
|
|
172
|
+
"includeFolders": _TRUE if include_folders else None,
|
|
173
|
+
"includeAll": _TRUE if include_all else None,
|
|
174
|
+
"includeMetadata": _TRUE if include_metadata else None,
|
|
175
|
+
}
|
|
176
|
+
return client.get_json("files", params=params)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_folders(
|
|
180
|
+
client: HTTPClient,
|
|
181
|
+
*,
|
|
182
|
+
limit: int | None = None,
|
|
183
|
+
offset: int | None = None,
|
|
184
|
+
load_all: bool = False,
|
|
185
|
+
) -> Any:
|
|
186
|
+
"""GET /getFolders — list folders in the workspace."""
|
|
187
|
+
params = {
|
|
188
|
+
"limit": limit,
|
|
189
|
+
"offset": offset,
|
|
190
|
+
"loadAll": _TRUE if load_all else None,
|
|
191
|
+
}
|
|
192
|
+
return client.get_json("getFolders", params=params)
|