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/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Tamarind Bio CLI and Python client.
|
|
2
|
+
|
|
3
|
+
This package is a thin client over the Tamarind platform. Two surfaces:
|
|
4
|
+
|
|
5
|
+
- REST passthrough (``tamarind.rest``): submit/validate/batch, jobs, result,
|
|
6
|
+
files, cancel, delete — these hit the Tamarind API directly with an API key.
|
|
7
|
+
The request/response contract is the same OpenAPI spec the Tamarind MCP server
|
|
8
|
+
is built from, so the CLI and the MCP cannot drift on this surface.
|
|
9
|
+
|
|
10
|
+
- Discovery (``tamarind.catalog``): tools, schema, modalities, functions. The
|
|
11
|
+
catalog lives behind per-org visibility logic that runs server-side, so the
|
|
12
|
+
CLI consumes it over HTTP (the ``/catalog/*`` routes) rather than reading the
|
|
13
|
+
database directly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
tamarind/catalog.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Discovery / catalog client.
|
|
2
|
+
|
|
3
|
+
The tool catalog and per-tool schemas are gated by per-org visibility logic
|
|
4
|
+
that runs server-side, so the CLI consumes them over HTTP from the catalog
|
|
5
|
+
service (the ``/catalog/*`` routes) rather than reading the database directly.
|
|
6
|
+
|
|
7
|
+
These routes return exactly the JSON the MCP's discovery tools return
|
|
8
|
+
(``getAvailableTools``, ``listModalities``, ``listTags``, ``getJobSchema``),
|
|
9
|
+
because both are served by the same shared implementation. So whatever a tool
|
|
10
|
+
looks like in the MCP, it looks identical here.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .http import HTTPClient
|
|
18
|
+
|
|
19
|
+
CATALOG_PREFIX = "catalog"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_tools(
|
|
23
|
+
client: HTTPClient,
|
|
24
|
+
*,
|
|
25
|
+
modality: str | None = None,
|
|
26
|
+
function: str | None = None,
|
|
27
|
+
search: str | None = None,
|
|
28
|
+
custom: bool | None = None,
|
|
29
|
+
) -> dict:
|
|
30
|
+
"""GET /catalog/tools — the filtered tool catalog (mirrors getAvailableTools)."""
|
|
31
|
+
params = {
|
|
32
|
+
"modality": modality,
|
|
33
|
+
"function": function,
|
|
34
|
+
"search": search,
|
|
35
|
+
"custom": "true" if custom else None,
|
|
36
|
+
}
|
|
37
|
+
return client.get_json(f"{CATALOG_PREFIX}/tools", params=params)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def list_modalities(client: HTTPClient) -> dict:
|
|
41
|
+
"""GET /catalog/modalities — molecule types you can filter by."""
|
|
42
|
+
return client.get_json(f"{CATALOG_PREFIX}/modalities")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_functions(client: HTTPClient) -> dict:
|
|
46
|
+
"""GET /catalog/functions — functions (tags) you can filter by."""
|
|
47
|
+
return client.get_json(f"{CATALOG_PREFIX}/functions")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_schema(client: HTTPClient, job_type: str) -> dict:
|
|
51
|
+
"""GET /catalog/tools/{jobType}/schema — full parameter schema + example job."""
|
|
52
|
+
return client.get_json(f"{CATALOG_PREFIX}/tools/{job_type}/schema")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# -- helpers for rendering / example extraction ---------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def example_settings(schema: dict[str, Any]) -> dict[str, Any]:
|
|
59
|
+
"""Pull a runnable ``settings`` dict out of a schema's exampleJob, if present."""
|
|
60
|
+
example = schema.get("exampleJob") or {}
|
|
61
|
+
return dict(example.get("settings") or {})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def required_param_names(schema: dict[str, Any]) -> list[str]:
|
|
65
|
+
"""Names of parameters marked required (top-level; ignores task-gated ones)."""
|
|
66
|
+
out = []
|
|
67
|
+
for p in schema.get("parameters", []):
|
|
68
|
+
if p.get("required") and p.get("name"):
|
|
69
|
+
out.append(p["name"])
|
|
70
|
+
return out
|
tamarind/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typer command-line interface for Tamarind."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command groups."""
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""`tamarind auth` — credential management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ... import rest
|
|
10
|
+
from ...config import mask_key, save_profile
|
|
11
|
+
from ...errors import AuthError
|
|
12
|
+
from ...http import HTTPClient
|
|
13
|
+
from .. import output
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_key(api_base: str, api_key: str) -> bool:
|
|
19
|
+
"""Return True if the key authenticates against the job API."""
|
|
20
|
+
with HTTPClient(api_base, api_key) as client:
|
|
21
|
+
try:
|
|
22
|
+
rest.get_jobs(client, limit=1)
|
|
23
|
+
return True
|
|
24
|
+
except AuthError:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def login(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
api_key: Optional[str] = typer.Option(
|
|
32
|
+
None, "--api-key", help="API key. If omitted, you'll be prompted.", show_default=False
|
|
33
|
+
),
|
|
34
|
+
no_verify: bool = typer.Option(False, "--no-verify", help="Skip verifying the key."),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Store an API key in the current profile (~/.tamarind/config.json).
|
|
37
|
+
|
|
38
|
+
Get a key from https://app.tamarind.bio (Settings → API), or set
|
|
39
|
+
TAMARIND_API_KEY in the environment to skip storing one.
|
|
40
|
+
"""
|
|
41
|
+
state = ctx.obj
|
|
42
|
+
cfg = state.config()
|
|
43
|
+
key = api_key or typer.prompt("Tamarind API key", hide_input=True)
|
|
44
|
+
key = key.strip()
|
|
45
|
+
|
|
46
|
+
if not no_verify and not _check_key(cfg.api_base, key):
|
|
47
|
+
raise AuthError("That API key was rejected by the API. Not saved.")
|
|
48
|
+
|
|
49
|
+
save_profile(cfg.profile, api_key=key)
|
|
50
|
+
output.emit(
|
|
51
|
+
{"ok": True, "profile": cfg.profile, "verified": not no_verify},
|
|
52
|
+
state.output,
|
|
53
|
+
human=f"Saved API key to profile '{cfg.profile}'.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def status(ctx: typer.Context) -> None:
|
|
59
|
+
"""Show the active profile, endpoints, and whether the key works."""
|
|
60
|
+
state = ctx.obj
|
|
61
|
+
cfg = state.config()
|
|
62
|
+
verified = cfg.has_key and _check_key(cfg.api_base, cfg.api_key)
|
|
63
|
+
result = {
|
|
64
|
+
"profile": cfg.profile,
|
|
65
|
+
"apiKey": mask_key(cfg.api_key),
|
|
66
|
+
"hasKey": cfg.has_key,
|
|
67
|
+
"verified": verified,
|
|
68
|
+
"apiBase": cfg.api_base,
|
|
69
|
+
"catalogBase": cfg.catalog_base,
|
|
70
|
+
}
|
|
71
|
+
human = (
|
|
72
|
+
f"profile: {cfg.profile}\n"
|
|
73
|
+
f"api key: {mask_key(cfg.api_key)} ({'verified' if verified else 'not verified'})\n"
|
|
74
|
+
f"job api: {cfg.api_base}\n"
|
|
75
|
+
f"catalog api: {cfg.catalog_base}"
|
|
76
|
+
)
|
|
77
|
+
output.emit(result, state.output, human=human)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def logout(ctx: typer.Context) -> None:
|
|
82
|
+
"""Remove the stored API key from the current profile."""
|
|
83
|
+
state = ctx.obj
|
|
84
|
+
cfg = state.config()
|
|
85
|
+
save_profile(cfg.profile, api_key="", make_current=False)
|
|
86
|
+
output.emit(
|
|
87
|
+
{"ok": True, "profile": cfg.profile},
|
|
88
|
+
state.output,
|
|
89
|
+
human=f"Cleared API key for profile '{cfg.profile}'.",
|
|
90
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Discovery commands: `tamarind tools|modalities|functions|schema`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ... import catalog
|
|
10
|
+
from .. import output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(app: typer.Typer) -> None:
|
|
14
|
+
@app.command()
|
|
15
|
+
def tools(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
modality: Optional[str] = typer.Option(None, "--modality", "-m", help="Filter by molecule type (see `tamarind modalities`)."),
|
|
18
|
+
function: Optional[str] = typer.Option(None, "--function", "-f", help="Filter by function/tag (see `tamarind functions`)."),
|
|
19
|
+
search: Optional[str] = typer.Option(None, "--search", "-s", help="Free-text search in name/description."),
|
|
20
|
+
custom: bool = typer.Option(False, "--custom", help="Show only your org's custom tools."),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""List available tools. Filter to narrow the (large) catalog."""
|
|
23
|
+
state = ctx.obj
|
|
24
|
+
with state.catalog_client() as client:
|
|
25
|
+
resp = catalog.list_tools(
|
|
26
|
+
client, modality=modality, function=function, search=search, custom=custom or None
|
|
27
|
+
)
|
|
28
|
+
rows = [
|
|
29
|
+
{
|
|
30
|
+
"name": t.get("name"),
|
|
31
|
+
"displayName": t.get("displayName"),
|
|
32
|
+
"modalities": ",".join(t.get("categories", []) or []),
|
|
33
|
+
}
|
|
34
|
+
for t in resp.get("tools", [])
|
|
35
|
+
]
|
|
36
|
+
human = (
|
|
37
|
+
output.render_table(rows, ["name", "displayName", "modalities"])
|
|
38
|
+
+ f"\n\n{resp.get('totalTools', len(rows))} tools. "
|
|
39
|
+
"Use `tamarind schema <name>` for parameters."
|
|
40
|
+
)
|
|
41
|
+
output.emit(resp, state.output, human=human)
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def modalities(ctx: typer.Context) -> None:
|
|
45
|
+
"""List molecule types (modalities) you can filter tools by."""
|
|
46
|
+
state = ctx.obj
|
|
47
|
+
with state.catalog_client() as client:
|
|
48
|
+
resp = catalog.list_modalities(client)
|
|
49
|
+
rows = [
|
|
50
|
+
{"value": m.get("value"), "label": m.get("label"), "tools": m.get("toolCount")}
|
|
51
|
+
for m in resp.get("modalities", [])
|
|
52
|
+
]
|
|
53
|
+
output.emit(resp, state.output, human=output.render_table(rows, ["value", "label", "tools"]))
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def functions(ctx: typer.Context) -> None:
|
|
57
|
+
"""List functions (tags) you can filter tools by."""
|
|
58
|
+
state = ctx.obj
|
|
59
|
+
with state.catalog_client() as client:
|
|
60
|
+
resp = catalog.list_functions(client)
|
|
61
|
+
rows = [
|
|
62
|
+
{"value": f.get("value"), "label": f.get("label"), "tools": f.get("toolCount")}
|
|
63
|
+
for f in resp.get("functions", [])
|
|
64
|
+
]
|
|
65
|
+
output.emit(resp, state.output, human=output.render_table(rows, ["value", "label", "tools"]))
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def schema(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
tool: str = typer.Argument(..., help="Tool name (lowercase, e.g. 'boltz')."),
|
|
71
|
+
example: bool = typer.Option(False, "--example", help="Print only the runnable example settings (YAML)."),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Show a tool's parameters and a runnable example job."""
|
|
74
|
+
state = ctx.obj
|
|
75
|
+
with state.catalog_client() as client:
|
|
76
|
+
resp = catalog.get_schema(client, tool)
|
|
77
|
+
# The catalog returns HTTP 200 with {"error": ...} for an unknown/hidden
|
|
78
|
+
# tool; turn that into a not-found exit instead of printing it as success.
|
|
79
|
+
if isinstance(resp, dict) and resp.get("error"):
|
|
80
|
+
from ...errors import NotFoundError
|
|
81
|
+
|
|
82
|
+
raise NotFoundError(resp["error"])
|
|
83
|
+
|
|
84
|
+
if example:
|
|
85
|
+
import yaml
|
|
86
|
+
|
|
87
|
+
settings = catalog.example_settings(resp)
|
|
88
|
+
output.emit(
|
|
89
|
+
{"type": tool, "settings": settings},
|
|
90
|
+
state.output,
|
|
91
|
+
human=yaml.safe_dump(settings, sort_keys=False).rstrip(),
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
param_rows = []
|
|
96
|
+
for p in resp.get("parameters", []):
|
|
97
|
+
param_rows.append(
|
|
98
|
+
{
|
|
99
|
+
"name": p.get("name"),
|
|
100
|
+
"type": p.get("type"),
|
|
101
|
+
"required": "yes" if p.get("required") else "",
|
|
102
|
+
"default": p.get("default"),
|
|
103
|
+
"description": (p.get("descr") or p.get("displayName") or "")[:60],
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
human = (
|
|
107
|
+
f"{resp.get('displayName', tool)} [{tool}]\n"
|
|
108
|
+
f"{resp.get('description', '')}\n\n"
|
|
109
|
+
+ output.render_table(param_rows, ["name", "type", "required", "default", "description"])
|
|
110
|
+
+ "\n\nRun `tamarind schema "
|
|
111
|
+
+ tool
|
|
112
|
+
+ " --example` for runnable example settings."
|
|
113
|
+
)
|
|
114
|
+
output.emit(resp, state.output, human=human)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""`tamarind files` — workspace file management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ... import rest
|
|
12
|
+
from ...errors import TamarindError
|
|
13
|
+
from .. import output
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("list")
|
|
19
|
+
def list_files(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
types: Optional[str] = typer.Option(None, "--types", help="Comma-separated extensions, e.g. 'pdb,cif'."),
|
|
22
|
+
search: Optional[str] = typer.Option(None, "--search", "-s", help="Filter filenames by substring."),
|
|
23
|
+
folder: Optional[str] = typer.Option(None, "--folder", help="List within this folder."),
|
|
24
|
+
limit: int = typer.Option(50, "--limit", help="Max files to return."),
|
|
25
|
+
offset: int = typer.Option(0, "--offset", help="Pagination offset."),
|
|
26
|
+
metadata: bool = typer.Option(False, "--metadata", help="Include size/lastModified."),
|
|
27
|
+
all_dirs: bool = typer.Option(False, "--all", help="Recurse into subdirectories."),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""List files in your workspace."""
|
|
30
|
+
state = ctx.obj
|
|
31
|
+
with state.rest_client() as client:
|
|
32
|
+
resp = rest.get_files(
|
|
33
|
+
client,
|
|
34
|
+
limit=limit,
|
|
35
|
+
offset=offset,
|
|
36
|
+
types=types,
|
|
37
|
+
search=search,
|
|
38
|
+
folder=folder,
|
|
39
|
+
include_all=all_dirs,
|
|
40
|
+
include_metadata=metadata,
|
|
41
|
+
)
|
|
42
|
+
files = resp.get("files") if isinstance(resp, dict) else resp
|
|
43
|
+
if not isinstance(files, list):
|
|
44
|
+
files = []
|
|
45
|
+
if files and isinstance(files[0], dict):
|
|
46
|
+
rows = [{"name": f.get("name"), "size": f.get("size"), "lastModified": f.get("lastModified")} for f in files]
|
|
47
|
+
human = output.render_table(rows, ["name", "size", "lastModified"])
|
|
48
|
+
else:
|
|
49
|
+
human = "\n".join(str(f) for f in files) or "(none)"
|
|
50
|
+
output.emit(resp, state.output, human=human)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def folders(
|
|
55
|
+
ctx: typer.Context,
|
|
56
|
+
limit: int = typer.Option(50, "--limit", help="Max folders to return."),
|
|
57
|
+
all_folders: bool = typer.Option(False, "--all", help="Load all folders."),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""List folders in your workspace."""
|
|
60
|
+
state = ctx.obj
|
|
61
|
+
with state.rest_client() as client:
|
|
62
|
+
resp = rest.get_folders(client, limit=limit, load_all=all_folders)
|
|
63
|
+
folder_list = resp.get("folders") if isinstance(resp, dict) else resp
|
|
64
|
+
if not isinstance(folder_list, list):
|
|
65
|
+
folder_list = []
|
|
66
|
+
output.emit(resp, state.output, human="\n".join(str(f) for f in folder_list) or "(none)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def upload(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
path: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True, help="Local file to upload."),
|
|
73
|
+
name: Optional[str] = typer.Option(None, "--name", help="Remote filename (default: the local basename)."),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Upload a local file to your workspace (two-step presigned PUT)."""
|
|
76
|
+
state = ctx.obj
|
|
77
|
+
remote = name or path.name
|
|
78
|
+
with state.rest_client() as client:
|
|
79
|
+
signed = rest.upload_file_url(client, filename=remote)
|
|
80
|
+
# The endpoint may return a non-dict error sentinel (e.g. -1) instead of an
|
|
81
|
+
# object; don't crash on .get — surface a clean error.
|
|
82
|
+
url = signed.get("signedUrl") if isinstance(signed, dict) else None
|
|
83
|
+
if not url:
|
|
84
|
+
raise TamarindError("Upload did not return a signed URL.", detail=signed)
|
|
85
|
+
output.info(f"Uploading {path} → {remote}…", state.output)
|
|
86
|
+
with path.open("rb") as fh:
|
|
87
|
+
put = httpx.put(url, content=fh.read(), timeout=300.0)
|
|
88
|
+
put.raise_for_status()
|
|
89
|
+
output.emit(
|
|
90
|
+
{"ok": True, "filename": remote, "bytes": path.stat().st_size},
|
|
91
|
+
state.output,
|
|
92
|
+
human=f"uploaded {remote}",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def delete(
|
|
98
|
+
ctx: typer.Context,
|
|
99
|
+
path: Optional[str] = typer.Argument(None, help="File path to delete."),
|
|
100
|
+
folder: Optional[str] = typer.Option(None, "--folder", help="Delete every file under this folder instead."),
|
|
101
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Delete a file, or every file under a folder."""
|
|
104
|
+
state = ctx.obj
|
|
105
|
+
if not path and not folder:
|
|
106
|
+
raise TamarindError("Provide a file path or --folder <name>.")
|
|
107
|
+
target = path or f"folder {folder}"
|
|
108
|
+
if not yes and not state.output.json:
|
|
109
|
+
typer.confirm(f"Delete {target}?", abort=True)
|
|
110
|
+
with state.rest_client() as client:
|
|
111
|
+
resp = rest.delete_file(client, file_path=path, folder=folder)
|
|
112
|
+
human = resp.get("message", resp) if isinstance(resp, dict) else resp
|
|
113
|
+
output.emit(resp, state.output, human=str(human))
|