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.
Files changed (29) hide show
  1. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/PKG-INFO +8 -6
  2. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/README.md +7 -5
  3. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/pyproject.toml +1 -1
  4. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/__init__.py +8 -1
  5. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/files.py +9 -6
  6. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/rest.py +12 -3
  7. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_cli_commands.py +41 -6
  8. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/.github/workflows/publish.yml +0 -0
  9. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/.gitignore +0 -0
  10. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/docs/architecture.md +0 -0
  11. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/install.sh +0 -0
  12. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/catalog.py +0 -0
  13. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/__init__.py +0 -0
  14. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/__init__.py +0 -0
  15. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/auth.py +0 -0
  16. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/catalog.py +0 -0
  17. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/commands/jobs.py +0 -0
  18. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/inputs.py +0 -0
  19. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/main.py +0 -0
  20. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/cli/output.py +0 -0
  21. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/config.py +0 -0
  22. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/errors.py +0 -0
  23. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/http.py +0 -0
  24. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/src/tamarind/jobs.py +0 -0
  25. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_config.py +0 -0
  26. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_inputs.py +0 -0
  27. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_jobs.py +0 -0
  28. {tamarind_cli-0.1.0 → tamarind_cli-0.1.2}/tests/test_rest.py +0 -0
  29. {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.0
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
- Before then (or to track the repo directly), install from git:
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
- Before then (or to track the repo directly), install from git:
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.0"
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
- __version__ = "0.1.0"
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 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
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 signed URL.", detail=signed)
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(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})
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 test_upload_handles_non_dict_sentinel(tmp_path):
86
- # Staging /uploadFile can return a bare -1; the command must fail cleanly, not crash.
87
- f = tmp_path / "x.txt"
88
- f.write_text("hi")
89
- respx.post(f"{API}uploadFile").mock(return_value=httpx.Response(200, text="-1"))
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
- assert not isinstance(res.exception, AttributeError)
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