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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Job lifecycle commands: submit/validate/batch/jobs/status/wait/results/logs/cancel/delete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from ... import jobs as jobs_helpers
|
|
14
|
+
from ... import rest
|
|
15
|
+
from ...errors import NotFoundError, TamarindError, ValidationError
|
|
16
|
+
from .. import output
|
|
17
|
+
from ..inputs import resolve_job_input
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _gen_name(tool: str) -> str:
|
|
21
|
+
return f"{tool}-{uuid.uuid4().hex[:8]}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _message(resp: object) -> str:
|
|
25
|
+
"""Best-effort human message from a response that may be a dict or a string."""
|
|
26
|
+
if isinstance(resp, dict):
|
|
27
|
+
return str(resp.get("message", resp))
|
|
28
|
+
return str(resp)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _download(url: str, dest: Path) -> int:
|
|
32
|
+
"""Stream a presigned URL to ``dest``. Returns bytes written."""
|
|
33
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
total = 0
|
|
35
|
+
with httpx.stream("GET", url, follow_redirects=True, timeout=300.0) as resp:
|
|
36
|
+
resp.raise_for_status()
|
|
37
|
+
with dest.open("wb") as fh:
|
|
38
|
+
for chunk in resp.iter_bytes():
|
|
39
|
+
fh.write(chunk)
|
|
40
|
+
total += len(chunk)
|
|
41
|
+
return total
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register(app: typer.Typer) -> None:
|
|
45
|
+
@app.command()
|
|
46
|
+
def validate(
|
|
47
|
+
ctx: typer.Context,
|
|
48
|
+
tool: str = typer.Argument(..., help="Tool name (e.g. 'boltz')."),
|
|
49
|
+
input: Optional[str] = typer.Option(None, "--input", "-i", help="Settings file (YAML/JSON), '-' for stdin, or @yaml://path."),
|
|
50
|
+
set_: list[str] = typer.Option([], "--set", help="Override a setting: key=value (repeatable)."),
|
|
51
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name (default: auto)."),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Validate a job's settings without submitting (catches errors early)."""
|
|
54
|
+
state = ctx.obj
|
|
55
|
+
job = resolve_job_input(input, set_)
|
|
56
|
+
job_name = name or job.job_name or _gen_name(tool)
|
|
57
|
+
with state.rest_client() as client:
|
|
58
|
+
result = rest.validate_job(
|
|
59
|
+
client, job_name=job_name, job_type=job.job_type or tool, settings=job.settings
|
|
60
|
+
)
|
|
61
|
+
valid = bool(result.get("valid"))
|
|
62
|
+
human = "valid ✓" if valid else f"invalid ✗ {result.get('error', '')}"
|
|
63
|
+
output.emit(result, state.output, human=human)
|
|
64
|
+
if not valid:
|
|
65
|
+
raise typer.Exit(ValidationError.exit_code)
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def submit(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
tool: str = typer.Argument(..., help="Tool name (e.g. 'boltz'). See `tamarind tools`."),
|
|
71
|
+
input: Optional[str] = typer.Option(None, "--input", "-i", help="Settings file (YAML/JSON), '-' for stdin, or @yaml://path."),
|
|
72
|
+
set_: list[str] = typer.Option([], "--set", help="Override a setting: key=value (repeatable)."),
|
|
73
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name (default: auto-generated)."),
|
|
74
|
+
skip_validate: bool = typer.Option(False, "--skip-validate", help="Skip the pre-submit validate-job check."),
|
|
75
|
+
wait: bool = typer.Option(False, "--wait", help="Block until the job reaches a terminal state."),
|
|
76
|
+
poll_interval: float = typer.Option(10.0, "--poll-interval", help="Seconds between polls when --wait."),
|
|
77
|
+
download: Optional[Path] = typer.Option(None, "--download", help="With --wait, download results to this directory."),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Submit a single job. Validates first unless --skip-validate."""
|
|
80
|
+
state = ctx.obj
|
|
81
|
+
job = resolve_job_input(input, set_)
|
|
82
|
+
job_type = job.job_type or tool
|
|
83
|
+
job_name = name or job.job_name or _gen_name(tool)
|
|
84
|
+
|
|
85
|
+
with state.rest_client() as client:
|
|
86
|
+
if not skip_validate:
|
|
87
|
+
v = rest.validate_job(client, job_name=job_name, job_type=job_type, settings=job.settings)
|
|
88
|
+
if not v.get("valid"):
|
|
89
|
+
raise ValidationError(f"Settings invalid: {v.get('error', 'unknown error')}", detail=v)
|
|
90
|
+
# NB: submit the user's original settings, NOT validate-job's
|
|
91
|
+
# `normalized` output — the normalizer injects backend-internal
|
|
92
|
+
# fields (e.g. submit_method, msa) that submit-job rejects.
|
|
93
|
+
|
|
94
|
+
output.info(f"Submitting {job_type} job '{job_name}'…", state.output)
|
|
95
|
+
submit_resp = rest.submit_job(client, job_name=job_name, job_type=job_type, settings=job.settings)
|
|
96
|
+
|
|
97
|
+
result = {"jobName": job_name, "type": job_type, "submit": submit_resp}
|
|
98
|
+
|
|
99
|
+
if wait:
|
|
100
|
+
output.info("Waiting for completion…", state.output)
|
|
101
|
+
final = jobs_helpers.wait_for_job(
|
|
102
|
+
client,
|
|
103
|
+
job_name,
|
|
104
|
+
poll_interval=poll_interval,
|
|
105
|
+
on_poll=lambda j: output.info(f" status: {jobs_helpers.job_status(j)}", state.output),
|
|
106
|
+
)
|
|
107
|
+
result["final"] = final
|
|
108
|
+
status = jobs_helpers.job_status(final)
|
|
109
|
+
if download and jobs_helpers.is_success(status):
|
|
110
|
+
url = rest.get_result(client, job_name=job_name)
|
|
111
|
+
dest = download / f"{job_name}.zip"
|
|
112
|
+
written = _download(url, dest)
|
|
113
|
+
result["download"] = {"path": str(dest), "bytes": written}
|
|
114
|
+
output.info(f" downloaded {written} bytes → {dest}", state.output)
|
|
115
|
+
|
|
116
|
+
human = f"submitted: {job_name}" + (
|
|
117
|
+
f" ({jobs_helpers.job_status(result['final'])})" if "final" in result else ""
|
|
118
|
+
)
|
|
119
|
+
output.emit(result, state.output, human=human)
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def batch(
|
|
123
|
+
ctx: typer.Context,
|
|
124
|
+
tool: str = typer.Argument(..., help="Tool name applied to every job in the batch."),
|
|
125
|
+
input: str = typer.Option(..., "--input", "-i", help="YAML/JSON list of per-job settings, or a {batchName,type,settings[],jobNames} object."),
|
|
126
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="Batch name (default: auto)."),
|
|
127
|
+
max_runtime: Optional[int] = typer.Option(None, "--max-runtime", help="Max runtime seconds per job."),
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Submit many jobs as one batch (preferred over looping submit)."""
|
|
130
|
+
state = ctx.obj
|
|
131
|
+
from ..inputs import _load_text, _parse_document # internal reuse
|
|
132
|
+
|
|
133
|
+
doc = _parse_document(_load_text(input))
|
|
134
|
+
batch_name = name or _gen_name(tool)
|
|
135
|
+
job_type = tool
|
|
136
|
+
job_names = None
|
|
137
|
+
if isinstance(doc, list):
|
|
138
|
+
settings_list = doc
|
|
139
|
+
elif isinstance(doc, dict) and isinstance(doc.get("settings"), list):
|
|
140
|
+
settings_list = doc["settings"]
|
|
141
|
+
batch_name = name or doc.get("batchName") or batch_name
|
|
142
|
+
job_type = doc.get("type") or tool
|
|
143
|
+
job_names = doc.get("jobNames")
|
|
144
|
+
else:
|
|
145
|
+
raise TamarindError("Batch --input must be a list of settings or a {settings:[...]} object.")
|
|
146
|
+
|
|
147
|
+
with state.rest_client() as client:
|
|
148
|
+
resp = rest.submit_batch(
|
|
149
|
+
client,
|
|
150
|
+
batch_name=batch_name,
|
|
151
|
+
job_type=job_type,
|
|
152
|
+
settings=settings_list,
|
|
153
|
+
job_names=job_names,
|
|
154
|
+
max_runtime_seconds=max_runtime,
|
|
155
|
+
)
|
|
156
|
+
result = {"batchName": batch_name, "type": job_type, "count": len(settings_list), "submit": resp}
|
|
157
|
+
output.emit(result, state.output, human=f"submitted batch '{batch_name}' ({len(settings_list)} jobs)")
|
|
158
|
+
|
|
159
|
+
@app.command()
|
|
160
|
+
def jobs(
|
|
161
|
+
ctx: typer.Context,
|
|
162
|
+
status: Optional[str] = typer.Option(None, "--status", help="Filter by status (client-side)."),
|
|
163
|
+
batch: Optional[str] = typer.Option(None, "--batch", help="Only jobs in this batch."),
|
|
164
|
+
limit: int = typer.Option(50, "--limit", help="Max jobs to return."),
|
|
165
|
+
organization: bool = typer.Option(False, "--organization", help="All jobs across your org."),
|
|
166
|
+
include_subjobs: bool = typer.Option(False, "--include-subjobs", help="Include batch subjobs."),
|
|
167
|
+
email: Optional[str] = typer.Option(None, "--email", help="Jobs for another org member."),
|
|
168
|
+
) -> None:
|
|
169
|
+
"""List your jobs."""
|
|
170
|
+
state = ctx.obj
|
|
171
|
+
with state.rest_client() as client:
|
|
172
|
+
resp = rest.get_jobs(
|
|
173
|
+
client,
|
|
174
|
+
batch=batch,
|
|
175
|
+
limit=limit,
|
|
176
|
+
organization=organization,
|
|
177
|
+
include_subjobs=include_subjobs,
|
|
178
|
+
job_email=email,
|
|
179
|
+
)
|
|
180
|
+
job_list = resp.get("jobs", resp if isinstance(resp, list) else [])
|
|
181
|
+
if status:
|
|
182
|
+
job_list = [j for j in job_list if (jobs_helpers.job_status(j) or "").lower() == status.lower()]
|
|
183
|
+
rows = [
|
|
184
|
+
{
|
|
185
|
+
"JobName": jobs_helpers.job_name(j),
|
|
186
|
+
"Type": j.get("Type"),
|
|
187
|
+
"JobStatus": jobs_helpers.job_status(j),
|
|
188
|
+
"Created": j.get("Created"),
|
|
189
|
+
"Score": j.get("Score"),
|
|
190
|
+
}
|
|
191
|
+
for j in job_list
|
|
192
|
+
]
|
|
193
|
+
out = {"jobs": job_list, "count": len(job_list)}
|
|
194
|
+
if isinstance(resp, dict) and resp.get("statuses"):
|
|
195
|
+
out["statuses"] = resp["statuses"]
|
|
196
|
+
output.emit(out, state.output, human=output.render_table(rows, ["JobName", "Type", "JobStatus", "Created", "Score"]))
|
|
197
|
+
|
|
198
|
+
@app.command()
|
|
199
|
+
def status(
|
|
200
|
+
ctx: typer.Context,
|
|
201
|
+
job_name: str = typer.Argument(..., help="Job name."),
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Show one job's current status and metadata."""
|
|
204
|
+
state = ctx.obj
|
|
205
|
+
with state.rest_client() as client:
|
|
206
|
+
job = jobs_helpers.fetch_job(client, job_name)
|
|
207
|
+
output.emit(job, state.output, human=f"{job_name}: {jobs_helpers.job_status(job)}")
|
|
208
|
+
|
|
209
|
+
@app.command()
|
|
210
|
+
def wait(
|
|
211
|
+
ctx: typer.Context,
|
|
212
|
+
job_name: str = typer.Argument(..., help="Job name."),
|
|
213
|
+
poll_interval: float = typer.Option(10.0, "--poll-interval", help="Seconds between polls."),
|
|
214
|
+
timeout: Optional[float] = typer.Option(None, "--timeout", help="Give up after N seconds."),
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Block until a job reaches a terminal state."""
|
|
217
|
+
state = ctx.obj
|
|
218
|
+
with state.rest_client() as client:
|
|
219
|
+
final = jobs_helpers.wait_for_job(
|
|
220
|
+
client,
|
|
221
|
+
job_name,
|
|
222
|
+
poll_interval=poll_interval,
|
|
223
|
+
timeout=timeout,
|
|
224
|
+
on_poll=lambda j: output.info(f" status: {jobs_helpers.job_status(j)}", state.output),
|
|
225
|
+
)
|
|
226
|
+
output.emit(final, state.output, human=f"{job_name}: {jobs_helpers.job_status(final)}")
|
|
227
|
+
|
|
228
|
+
@app.command()
|
|
229
|
+
def results(
|
|
230
|
+
ctx: typer.Context,
|
|
231
|
+
job_name: str = typer.Argument(..., help="Job name."),
|
|
232
|
+
download: Optional[Path] = typer.Option(None, "--download", help="Download the results bundle to this directory."),
|
|
233
|
+
file: Optional[str] = typer.Option(None, "--file", help="A specific file within the results."),
|
|
234
|
+
pdbs_only: bool = typer.Option(False, "--pdbs-only", help="Only PDB outputs."),
|
|
235
|
+
wait: bool = typer.Option(False, "--wait", help="Wait for the job to finish first."),
|
|
236
|
+
poll_interval: float = typer.Option(10.0, "--poll-interval", help="Seconds between polls when --wait."),
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Get a presigned results URL, or download the results bundle."""
|
|
239
|
+
state = ctx.obj
|
|
240
|
+
with state.rest_client() as client:
|
|
241
|
+
if wait:
|
|
242
|
+
output.info("Waiting for completion…", state.output)
|
|
243
|
+
jobs_helpers.wait_for_job(client, job_name, poll_interval=poll_interval)
|
|
244
|
+
url = rest.get_result(
|
|
245
|
+
client, job_name=job_name, file_name=file, pdbs_only=pdbs_only or None
|
|
246
|
+
)
|
|
247
|
+
if not isinstance(url, str):
|
|
248
|
+
# Defensive: some deployments may wrap the URL in an object.
|
|
249
|
+
url = url.get("url") if isinstance(url, dict) else str(url)
|
|
250
|
+
result = {"jobName": job_name, "url": url}
|
|
251
|
+
if download:
|
|
252
|
+
suffix = file or f"{job_name}.zip"
|
|
253
|
+
dest = download / Path(suffix).name
|
|
254
|
+
written = _download(url, dest)
|
|
255
|
+
result["download"] = {"path": str(dest), "bytes": written}
|
|
256
|
+
human = result.get("download", {}).get("path") if download else url
|
|
257
|
+
output.emit(result, state.output, human=str(human))
|
|
258
|
+
|
|
259
|
+
@app.command()
|
|
260
|
+
def logs(
|
|
261
|
+
ctx: typer.Context,
|
|
262
|
+
job_name: str = typer.Argument(..., help="Job name."),
|
|
263
|
+
max_lines: int = typer.Option(500, "--max-lines", help="Tail at most this many lines."),
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Fetch a job's run logs (served by the catalog/gateway service)."""
|
|
266
|
+
state = ctx.obj
|
|
267
|
+
with state.catalog_client() as client:
|
|
268
|
+
resp = client.get_json(f"catalog/jobs/{job_name}/logs", params={"maxLines": max_lines})
|
|
269
|
+
if isinstance(resp, dict):
|
|
270
|
+
# getJobLogs returns {"log": "..."} on success, {"error": "..."} otherwise.
|
|
271
|
+
if resp.get("error"):
|
|
272
|
+
msg = str(resp["error"])
|
|
273
|
+
ml = msg.lower()
|
|
274
|
+
if "not found" in ml or "no such" in ml or "does not exist" in ml:
|
|
275
|
+
raise NotFoundError(msg)
|
|
276
|
+
raise TamarindError(msg)
|
|
277
|
+
text = resp.get("log") or resp.get("hint") or json.dumps(resp, indent=2)
|
|
278
|
+
else:
|
|
279
|
+
text = resp
|
|
280
|
+
output.emit(resp, state.output, human=str(text))
|
|
281
|
+
|
|
282
|
+
@app.command()
|
|
283
|
+
def cancel(
|
|
284
|
+
ctx: typer.Context,
|
|
285
|
+
job_name: Optional[str] = typer.Argument(None, help="Job name to cancel."),
|
|
286
|
+
batch: Optional[str] = typer.Option(None, "--batch", help="Cancel an entire batch/pipeline instead."),
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Cancel a running/queued job, or an entire batch."""
|
|
289
|
+
state = ctx.obj
|
|
290
|
+
if not job_name and not batch:
|
|
291
|
+
raise TamarindError("Provide a job name or --batch <name>.")
|
|
292
|
+
with state.rest_client() as client:
|
|
293
|
+
if batch:
|
|
294
|
+
resp = rest.cancel_batch(client, batch_name=batch)
|
|
295
|
+
else:
|
|
296
|
+
resp = rest.cancel_job(client, job_name=job_name)
|
|
297
|
+
output.emit(resp, state.output, human=_message(resp))
|
|
298
|
+
|
|
299
|
+
@app.command()
|
|
300
|
+
def delete(
|
|
301
|
+
ctx: typer.Context,
|
|
302
|
+
job_name: str = typer.Argument(..., help="Job name to permanently delete."),
|
|
303
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Permanently delete a job (and its subjobs, for batches)."""
|
|
306
|
+
state = ctx.obj
|
|
307
|
+
if not yes and not state.output.json:
|
|
308
|
+
typer.confirm(f"Permanently delete job '{job_name}'?", abort=True)
|
|
309
|
+
with state.rest_client() as client:
|
|
310
|
+
resp = rest.delete_job(client, job_name=job_name)
|
|
311
|
+
output.emit(resp, state.output, human=_message(resp))
|
tamarind/cli/inputs.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Resolve job inputs from files, stdin, or inline ``--set`` overrides.
|
|
2
|
+
|
|
3
|
+
A job's ``settings`` can come from:
|
|
4
|
+
|
|
5
|
+
- ``--input job.yaml`` (YAML or JSON, by content) — the file holds the
|
|
6
|
+
``settings`` object (the same shape as a schema's ``exampleJob.settings``),
|
|
7
|
+
or a full ``{jobName, type, settings}`` envelope.
|
|
8
|
+
- ``--input -`` to read that document from stdin.
|
|
9
|
+
- ``@yaml://./job.yaml`` / ``@json://./job.json`` reference syntax, matching the
|
|
10
|
+
convention other agent CLIs use.
|
|
11
|
+
- ``--set key=value`` (repeatable) to set/override individual settings inline;
|
|
12
|
+
the value is parsed as a YAML scalar (so ``--set numSamples=5`` is an int).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from ..errors import ValidationError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class JobInput:
|
|
30
|
+
settings: dict[str, Any]
|
|
31
|
+
job_type: str | None = None
|
|
32
|
+
job_name: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_text(source: str) -> str:
|
|
36
|
+
"""Read raw text from a path, stdin (``-``), or an ``@scheme://path`` ref."""
|
|
37
|
+
if source == "-":
|
|
38
|
+
return sys.stdin.read()
|
|
39
|
+
if source.startswith("@"):
|
|
40
|
+
# @yaml://./file.yaml or @json://./file.json or @./file
|
|
41
|
+
body = source[1:]
|
|
42
|
+
for scheme in ("yaml://", "json://", "file://"):
|
|
43
|
+
if body.startswith(scheme):
|
|
44
|
+
body = body[len(scheme) :]
|
|
45
|
+
break
|
|
46
|
+
source = body
|
|
47
|
+
path = Path(source).expanduser()
|
|
48
|
+
if not path.exists():
|
|
49
|
+
raise ValidationError(f"Input file not found: {path}")
|
|
50
|
+
return path.read_text()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_document(text: str) -> Any:
|
|
54
|
+
text = text.strip()
|
|
55
|
+
if not text:
|
|
56
|
+
return {}
|
|
57
|
+
# YAML is a superset of JSON, so safe_load handles both.
|
|
58
|
+
try:
|
|
59
|
+
return yaml.safe_load(text)
|
|
60
|
+
except yaml.YAMLError as exc:
|
|
61
|
+
raise ValidationError(f"Could not parse input as YAML/JSON: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _coerce_scalar(raw: str) -> Any:
|
|
65
|
+
try:
|
|
66
|
+
return yaml.safe_load(raw)
|
|
67
|
+
except yaml.YAMLError:
|
|
68
|
+
return raw
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _apply_sets(settings: dict[str, Any], pairs: list[str]) -> None:
|
|
72
|
+
for pair in pairs:
|
|
73
|
+
if "=" not in pair:
|
|
74
|
+
raise ValidationError(f"--set expects key=value, got: {pair!r}")
|
|
75
|
+
key, raw = pair.split("=", 1)
|
|
76
|
+
settings[key.strip()] = _coerce_scalar(raw)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _looks_like_envelope(doc: dict[str, Any]) -> bool:
|
|
80
|
+
return "settings" in doc and ("type" in doc or "jobName" in doc)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_job_input(
|
|
84
|
+
input_source: str | None,
|
|
85
|
+
set_pairs: list[str] | None,
|
|
86
|
+
) -> JobInput:
|
|
87
|
+
"""Build a :class:`JobInput` from ``--input`` and ``--set`` options."""
|
|
88
|
+
settings: dict[str, Any] = {}
|
|
89
|
+
job_type: str | None = None
|
|
90
|
+
job_name: str | None = None
|
|
91
|
+
|
|
92
|
+
if input_source:
|
|
93
|
+
doc = _parse_document(_load_text(input_source))
|
|
94
|
+
if doc is None:
|
|
95
|
+
doc = {}
|
|
96
|
+
if not isinstance(doc, dict):
|
|
97
|
+
raise ValidationError(
|
|
98
|
+
"Input must be a mapping (the job settings, or a "
|
|
99
|
+
"{jobName, type, settings} object)."
|
|
100
|
+
)
|
|
101
|
+
if _looks_like_envelope(doc):
|
|
102
|
+
settings = dict(doc.get("settings") or {})
|
|
103
|
+
job_type = doc.get("type")
|
|
104
|
+
job_name = doc.get("jobName")
|
|
105
|
+
else:
|
|
106
|
+
settings = dict(doc)
|
|
107
|
+
|
|
108
|
+
if set_pairs:
|
|
109
|
+
_apply_sets(settings, set_pairs)
|
|
110
|
+
|
|
111
|
+
return JobInput(settings=settings, job_type=job_type, job_name=job_name)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def dump_settings(settings: dict[str, Any]) -> str:
|
|
115
|
+
return json.dumps(settings, indent=2, default=str)
|
tamarind/cli/main.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""``tamarind`` command-line entry point.
|
|
2
|
+
|
|
3
|
+
Layout: a global callback resolves config (key, endpoints, profile, output
|
|
4
|
+
mode) onto ``ctx.obj``; each command builds a short-lived HTTP client from it.
|
|
5
|
+
All Tamarind errors propagate to :func:`run`, which prints them and exits with
|
|
6
|
+
the error's stable exit code (see :mod:`tamarind.errors`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from .. import __version__
|
|
17
|
+
from ..config import Config, load_config
|
|
18
|
+
from ..errors import TamarindError
|
|
19
|
+
from ..http import HTTPClient
|
|
20
|
+
from . import output
|
|
21
|
+
from .output import OutputMode
|
|
22
|
+
from .commands import auth as auth_cmds
|
|
23
|
+
from .commands import catalog as catalog_cmds
|
|
24
|
+
from .commands import files as files_cmds
|
|
25
|
+
from .commands import jobs as jobs_cmds
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class State:
|
|
30
|
+
"""Per-invocation state stored on the Typer context."""
|
|
31
|
+
|
|
32
|
+
output: OutputMode
|
|
33
|
+
_kwargs: dict
|
|
34
|
+
|
|
35
|
+
def config(self) -> Config:
|
|
36
|
+
return load_config(**self._kwargs)
|
|
37
|
+
|
|
38
|
+
def rest_client(self) -> HTTPClient:
|
|
39
|
+
cfg = self.config()
|
|
40
|
+
return HTTPClient(cfg.api_base, cfg.api_key)
|
|
41
|
+
|
|
42
|
+
def catalog_client(self) -> HTTPClient:
|
|
43
|
+
cfg = self.config()
|
|
44
|
+
return HTTPClient(cfg.catalog_base, cfg.api_key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
app = typer.Typer(
|
|
48
|
+
name="tamarind",
|
|
49
|
+
help=(
|
|
50
|
+
"Tamarind Bio CLI — discover tools, submit and monitor protein/molecule "
|
|
51
|
+
"jobs, and download results.\n\n"
|
|
52
|
+
"Auth: export TAMARIND_API_KEY, or run `tamarind auth login`.\n"
|
|
53
|
+
"Agents: pass --json (the default when stdout is not a terminal)."
|
|
54
|
+
),
|
|
55
|
+
no_args_is_help=True,
|
|
56
|
+
add_completion=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _version_callback(value: bool) -> None:
|
|
61
|
+
if value:
|
|
62
|
+
typer.echo(f"tamarind {__version__}")
|
|
63
|
+
raise typer.Exit()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.callback()
|
|
67
|
+
def main(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
api_key: Optional[str] = typer.Option(
|
|
70
|
+
None, "--api-key", envvar="TAMARIND_API_KEY", help="API key (overrides env/profile).", show_default=False
|
|
71
|
+
),
|
|
72
|
+
api_base: Optional[str] = typer.Option(
|
|
73
|
+
None, "--api-base", envvar="TAMARIND_API_BASE", help="Job API base URL.", show_default=False
|
|
74
|
+
),
|
|
75
|
+
catalog_base: Optional[str] = typer.Option(
|
|
76
|
+
None, "--catalog-base", envvar="TAMARIND_CATALOG_BASE", help="Catalog (discovery) base URL.", show_default=False
|
|
77
|
+
),
|
|
78
|
+
profile: Optional[str] = typer.Option(
|
|
79
|
+
None, "--profile", envvar="TAMARIND_PROFILE", help="Named profile in ~/.tamarind/config.json.", show_default=False
|
|
80
|
+
),
|
|
81
|
+
json_output: Optional[bool] = typer.Option(
|
|
82
|
+
None, "--json/--no-json", help="Machine JSON output. Defaults on when stdout isn't a TTY.", show_default=False
|
|
83
|
+
),
|
|
84
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status lines."),
|
|
85
|
+
_version: Optional[bool] = typer.Option(
|
|
86
|
+
None, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
87
|
+
),
|
|
88
|
+
) -> None:
|
|
89
|
+
resolved_json = json_output if json_output is not None else (not output.is_tty())
|
|
90
|
+
ctx.obj = State(
|
|
91
|
+
output=OutputMode(json=resolved_json, quiet=quiet),
|
|
92
|
+
_kwargs={
|
|
93
|
+
"api_key": api_key,
|
|
94
|
+
"api_base": api_base,
|
|
95
|
+
"catalog_base": catalog_base,
|
|
96
|
+
"profile": profile,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Sub-apps (grouped commands)
|
|
102
|
+
app.add_typer(auth_cmds.app, name="auth", help="Manage credentials.")
|
|
103
|
+
app.add_typer(files_cmds.app, name="files", help="List, upload, and delete workspace files.")
|
|
104
|
+
|
|
105
|
+
# Flat commands
|
|
106
|
+
catalog_cmds.register(app)
|
|
107
|
+
jobs_cmds.register(app)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run() -> None:
|
|
111
|
+
"""Console-script entry point with global error→exit-code mapping."""
|
|
112
|
+
try:
|
|
113
|
+
app()
|
|
114
|
+
except TamarindError as exc:
|
|
115
|
+
output.error(exc.message)
|
|
116
|
+
if exc.detail is not None:
|
|
117
|
+
typer.echo(typer.style(str(exc.detail), dim=True), err=True)
|
|
118
|
+
raise SystemExit(exc.exit_code)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
run()
|
tamarind/cli/output.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Output helpers.
|
|
2
|
+
|
|
3
|
+
Every command can emit either machine JSON (``--json``, the default when stdout
|
|
4
|
+
is not a TTY) or a compact human rendering. Agents should pass ``--json`` (or
|
|
5
|
+
just rely on the non-TTY default) and parse stdout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Sequence
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class OutputMode:
|
|
20
|
+
json: bool
|
|
21
|
+
quiet: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def emit(obj: Any, mode: OutputMode, *, human: str | None = None) -> None:
|
|
25
|
+
"""Emit a result. In JSON mode print ``obj`` as JSON; otherwise ``human``."""
|
|
26
|
+
if mode.json:
|
|
27
|
+
typer.echo(json.dumps(obj, indent=2, default=str))
|
|
28
|
+
elif human is not None:
|
|
29
|
+
typer.echo(human)
|
|
30
|
+
else:
|
|
31
|
+
typer.echo(json.dumps(obj, indent=2, default=str))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def info(message: str, mode: OutputMode) -> None:
|
|
35
|
+
"""A status line for humans; suppressed in JSON/quiet mode (goes to stderr)."""
|
|
36
|
+
if mode.json or mode.quiet:
|
|
37
|
+
return
|
|
38
|
+
typer.secho(message, err=True, fg=typer.colors.BRIGHT_BLACK)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def error(message: str) -> None:
|
|
42
|
+
typer.secho(f"error: {message}", err=True, fg=typer.colors.RED)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_table(rows: Sequence[dict[str, Any]], columns: Sequence[str]) -> str:
|
|
46
|
+
"""Render a fixed-width text table. ``columns`` are the dict keys to show."""
|
|
47
|
+
if not rows:
|
|
48
|
+
return "(none)"
|
|
49
|
+
widths = {c: len(c) for c in columns}
|
|
50
|
+
str_rows: list[dict[str, str]] = []
|
|
51
|
+
for r in rows:
|
|
52
|
+
sr = {}
|
|
53
|
+
for c in columns:
|
|
54
|
+
val = r.get(c)
|
|
55
|
+
text = "" if val is None else str(val)
|
|
56
|
+
sr[c] = text
|
|
57
|
+
widths[c] = max(widths[c], len(text))
|
|
58
|
+
str_rows.append(sr)
|
|
59
|
+
header = " ".join(c.ljust(widths[c]) for c in columns)
|
|
60
|
+
sep = " ".join("-" * widths[c] for c in columns)
|
|
61
|
+
lines = [header, sep]
|
|
62
|
+
for sr in str_rows:
|
|
63
|
+
lines.append(" ".join(sr[c].ljust(widths[c]) for c in columns))
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_tty() -> bool:
|
|
68
|
+
return sys.stdout.isatty()
|