tamarind-cli 0.1.0__tar.gz → 0.1.2__tar.gz
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_cli-0.1.0 → tamarind_cli-0.1.2}/PKG-INFO +8 -6
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/README.md +7 -5
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/pyproject.toml +1 -1
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/__init__.py +8 -1
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/files.py +9 -6
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/rest.py +12 -3
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_cli_commands.py +41 -6
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/.github/workflows/publish.yml +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/.gitignore +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/docs/architecture.md +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/install.sh +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/catalog.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/__init__.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/__init__.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/auth.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/catalog.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/jobs.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/inputs.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/main.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/output.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/config.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/errors.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/http.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/jobs.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_config.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_inputs.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_jobs.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_rest.py +0 -0
- {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tamarind-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Command-line interface for the Tamarind Bio platform — submit, monitor, and download protein/molecule jobs from your terminal or an AI agent.
|
|
5
5
|
Project-URL: Homepage, https://tamarind.bio
|
|
6
6
|
Project-URL: Documentation, https://app.tamarind.bio/api-docs
|
|
@@ -31,15 +31,17 @@ server](https://mcp.tamarind.bio) uses, so the two stay in lockstep. See
|
|
|
31
31
|
|
|
32
32
|
## Install
|
|
33
33
|
|
|
34
|
-
Once published to PyPI:
|
|
35
|
-
|
|
36
34
|
```bash
|
|
37
|
-
curl -fsSL https://install.tamarind.bio/cli/install.sh | sh
|
|
38
|
-
# or:
|
|
39
35
|
uv tool install tamarind-cli # or: pipx install tamarind-cli
|
|
40
36
|
```
|
|
41
37
|
|
|
42
|
-
|
|
38
|
+
Or with the bootstrap installer (installs `uv` if needed):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
curl -fsSL https://app.tamarind.bio/cli/install.sh | sh
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
To track the repo directly instead of the PyPI release:
|
|
43
45
|
|
|
44
46
|
```bash
|
|
45
47
|
uv tool install "git+https://github.com/Tamarind-Bio/tamarind-cli"
|
|
@@ -11,15 +11,17 @@ server](https://mcp.tamarind.bio) uses, so the two stay in lockstep. See
|
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
|
-
Once published to PyPI:
|
|
15
|
-
|
|
16
14
|
```bash
|
|
17
|
-
curl -fsSL https://install.tamarind.bio/cli/install.sh | sh
|
|
18
|
-
# or:
|
|
19
15
|
uv tool install tamarind-cli # or: pipx install tamarind-cli
|
|
20
16
|
```
|
|
21
17
|
|
|
22
|
-
|
|
18
|
+
Or with the bootstrap installer (installs `uv` if needed):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
curl -fsSL https://app.tamarind.bio/cli/install.sh | sh
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
To track the repo directly instead of the PyPI release:
|
|
23
25
|
|
|
24
26
|
```bash
|
|
25
27
|
uv tool install "git+https://github.com/Tamarind-Bio/tamarind-cli"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tamarind-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Command-line interface for the Tamarind Bio platform — submit, monitor, and download protein/molecule jobs from your terminal or an AI agent."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -13,4 +13,11 @@ This package is a thin client over the Tamarind platform. Two surfaces:
|
|
|
13
13
|
database directly.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
# Single source of truth: the installed package version (pyproject.toml),
|
|
20
|
+
# so `tamarind --version` can never drift from the released version.
|
|
21
|
+
__version__ = _pkg_version("tamarind-cli")
|
|
22
|
+
except PackageNotFoundError: # running from a source tree with no install metadata
|
|
23
|
+
__version__ = "0.0.0+dev"
|
|
@@ -75,16 +75,19 @@ def upload(
|
|
|
75
75
|
"""Upload a local file to your workspace (two-step presigned PUT)."""
|
|
76
76
|
state = ctx.obj
|
|
77
77
|
remote = name or path.name
|
|
78
|
+
content_type = "application/octet-stream"
|
|
78
79
|
with state.rest_client() as client:
|
|
79
|
-
signed = rest.upload_file_url(client, filename=remote)
|
|
80
|
-
# The endpoint
|
|
81
|
-
#
|
|
82
|
-
url = signed.get("
|
|
80
|
+
signed = rest.upload_file_url(client, filename=remote, content_type=content_type)
|
|
81
|
+
# The endpoint returns {uploadUrl, headUrl, key, bucket}; PUT the bytes to
|
|
82
|
+
# uploadUrl. Don't crash on a non-dict error body — surface a clean error.
|
|
83
|
+
url = signed.get("uploadUrl") if isinstance(signed, dict) else None
|
|
83
84
|
if not url:
|
|
84
|
-
raise TamarindError("Upload did not return a
|
|
85
|
+
raise TamarindError("Upload did not return a presigned URL.", detail=signed)
|
|
85
86
|
output.info(f"Uploading {path} → {remote}…", state.output)
|
|
87
|
+
# Content-Type must match what the presigned URL was signed with, or S3
|
|
88
|
+
# rejects the PUT with SignatureDoesNotMatch.
|
|
86
89
|
with path.open("rb") as fh:
|
|
87
|
-
put = httpx.put(url, content=fh.read(), timeout=300.0)
|
|
90
|
+
put = httpx.put(url, content=fh.read(), headers={"Content-Type": content_type}, timeout=300.0)
|
|
88
91
|
put.raise_for_status()
|
|
89
92
|
output.emit(
|
|
90
93
|
{"ok": True, "filename": remote, "bytes": path.stat().st_size},
|
|
@@ -103,9 +103,18 @@ def get_result(
|
|
|
103
103
|
return client.post_json("result", json=body)
|
|
104
104
|
|
|
105
105
|
|
|
106
|
-
def upload_file_url(
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
def upload_file_url(
|
|
107
|
+
client: HTTPClient, *, filename: str, content_type: str = "application/octet-stream"
|
|
108
|
+
) -> dict:
|
|
109
|
+
"""POST /getPresignedUploadUrl — returns {uploadUrl, headUrl, key, bucket}.
|
|
110
|
+
|
|
111
|
+
PUT the file bytes directly to ``uploadUrl`` with a matching ``Content-Type``
|
|
112
|
+
header (the presigned signature covers the content type). This uploads
|
|
113
|
+
straight to S3, bypassing the API's request-body size limit.
|
|
114
|
+
"""
|
|
115
|
+
return client.post_json(
|
|
116
|
+
"getPresignedUploadUrl", json={"filename": filename, "contentType": content_type}
|
|
117
|
+
)
|
|
109
118
|
|
|
110
119
|
|
|
111
120
|
def cancel_job(
|
|
@@ -82,11 +82,46 @@ def test_schema_unknown_tool_exits_nonzero():
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
@respx.mock
|
|
85
|
-
def
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
def test_upload_gets_presigned_url_then_puts_to_s3(tmp_path):
|
|
86
|
+
# `files upload` is a two-step presigned PUT: POST /getPresignedUploadUrl to
|
|
87
|
+
# get a PUT-able uploadUrl, then PUT the bytes straight to S3 (not multipart
|
|
88
|
+
# through the API). The Content-Type on the PUT must match what was signed.
|
|
89
|
+
import json
|
|
90
|
+
|
|
91
|
+
f = tmp_path / "target.pdb"
|
|
92
|
+
f.write_bytes(b"ATOM 1 N MET A 1\n")
|
|
93
|
+
upload_url = "https://s3.amazonaws.com/alphafold-dbs-tamarind/user%40x.com/target.pdb"
|
|
94
|
+
|
|
95
|
+
post_route = respx.post(f"{API}getPresignedUploadUrl").mock(
|
|
96
|
+
return_value=httpx.Response(
|
|
97
|
+
200,
|
|
98
|
+
json={"uploadUrl": upload_url, "headUrl": "https://h", "key": "user@x.com/target.pdb", "bucket": "b"},
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
put_route = respx.put(upload_url).mock(return_value=httpx.Response(200))
|
|
102
|
+
|
|
103
|
+
res = runner.invoke(app, ["files", "upload", str(f)], env=ENV)
|
|
104
|
+
|
|
105
|
+
assert res.exit_code == 0, res.stdout
|
|
106
|
+
assert post_route.called and put_route.called
|
|
107
|
+
# POST carried the filename + contentType the URL is signed with
|
|
108
|
+
body = json.loads(post_route.calls.last.request.content)
|
|
109
|
+
assert body == {"filename": "target.pdb", "contentType": "application/octet-stream"}
|
|
110
|
+
# PUT streamed the exact bytes with the matching Content-Type
|
|
111
|
+
put_req = put_route.calls.last.request
|
|
112
|
+
assert put_req.content == b"ATOM 1 N MET A 1\n"
|
|
113
|
+
assert put_req.headers["content-type"] == "application/octet-stream"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@respx.mock
|
|
117
|
+
def test_upload_surfaces_clean_error_on_non_dict_response(tmp_path):
|
|
118
|
+
# An auth/sentinel failure (e.g. bare -1) must not crash on .get — it should
|
|
119
|
+
# raise a clean TamarindError and never attempt the PUT.
|
|
120
|
+
f = tmp_path / "target.pdb"
|
|
121
|
+
f.write_bytes(b"x")
|
|
122
|
+
respx.post(f"{API}getPresignedUploadUrl").mock(return_value=httpx.Response(200, json=-1))
|
|
90
123
|
res = runner.invoke(app, ["files", "upload", str(f)], env=ENV)
|
|
91
124
|
assert res.exit_code != 0
|
|
92
|
-
|
|
125
|
+
# A clean, typed error (the isinstance(dict) guard worked) — NOT an
|
|
126
|
+
# AttributeError from calling .get on an int.
|
|
127
|
+
assert isinstance(res.exception, TamarindError)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|