tamarind-cli 0.1.4__tar.gz → 0.2.0__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 (40) hide show
  1. tamarind_cli-0.2.0/.github/workflows/ci.yml +67 -0
  2. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/.github/workflows/publish.yml +1 -1
  3. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/.gitignore +1 -0
  4. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/PKG-INFO +29 -7
  5. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/README.md +28 -6
  6. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/docs/architecture.md +12 -9
  7. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/pyproject.toml +1 -1
  8. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/commands/auth.py +4 -8
  9. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/commands/catalog.py +36 -0
  10. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/commands/files.py +59 -6
  11. tamarind_cli-0.2.0/src/tamarind/cli/commands/jobs.py +728 -0
  12. tamarind_cli-0.2.0/src/tamarind/cli/guidance.py +51 -0
  13. tamarind_cli-0.2.0/src/tamarind/cli/main.py +223 -0
  14. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/output.py +29 -2
  15. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/errors.py +10 -0
  16. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/http.py +29 -3
  17. tamarind_cli-0.2.0/src/tamarind/jobs.py +188 -0
  18. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/rest.py +6 -5
  19. tamarind_cli-0.2.0/tests/test_agent_contract.py +962 -0
  20. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_bugbash.py +1 -1
  21. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_cli_commands.py +201 -0
  22. tamarind_cli-0.2.0/tests/test_jobs.py +190 -0
  23. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_rest.py +48 -2
  24. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/uv.lock +1 -1
  25. tamarind_cli-0.1.4/src/tamarind/cli/commands/jobs.py +0 -374
  26. tamarind_cli-0.1.4/src/tamarind/cli/main.py +0 -122
  27. tamarind_cli-0.1.4/src/tamarind/jobs.py +0 -107
  28. tamarind_cli-0.1.4/tests/test_jobs.py +0 -78
  29. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/LICENSE +0 -0
  30. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/install.sh +0 -0
  31. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/__init__.py +0 -0
  32. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/catalog.py +0 -0
  33. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/__init__.py +0 -0
  34. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/commands/__init__.py +0 -0
  35. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/inputs.py +0 -0
  36. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/config.py +0 -0
  37. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_config.py +0 -0
  38. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_file_filters.py +0 -0
  39. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_inputs.py +0 -0
  40. {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_output.py +0 -0
@@ -0,0 +1,67 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ package:
13
+ name: Build release artifacts
14
+ runs-on: ubuntu-latest
15
+ timeout-minutes: 10
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v5
19
+ - name: Build source and wheel distributions
20
+ run: uv build
21
+ - name: Reject local caches in the source distribution
22
+ run: >-
23
+ python -c "import glob, tarfile;
24
+ names = tarfile.open(glob.glob('dist/*.tar.gz')[0]).getnames();
25
+ assert not any('/.uv-cache/' in name or '/.venv/' in name for name in names)"
26
+ - name: Smoke-check the built wheel
27
+ run: uv run --isolated --no-project --with dist/*.whl tamarind --version
28
+
29
+ test:
30
+ name: Python ${{ matrix.python-version }}
31
+ runs-on: ubuntu-latest
32
+ timeout-minutes: 10
33
+ strategy:
34
+ fail-fast: false
35
+ matrix:
36
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - uses: astral-sh/setup-uv@v5
40
+ with:
41
+ enable-cache: true
42
+ python-version: ${{ matrix.python-version }}
43
+ - name: Install project and development dependencies
44
+ run: uv sync --extra dev
45
+ - name: Test
46
+ run: uv run pytest
47
+ - name: Lint
48
+ run: uv run ruff check .
49
+
50
+ cross-platform:
51
+ name: ${{ matrix.os }}
52
+ runs-on: ${{ matrix.os }}
53
+ timeout-minutes: 10
54
+ strategy:
55
+ fail-fast: false
56
+ matrix:
57
+ os: [macos-latest, windows-latest]
58
+ steps:
59
+ - uses: actions/checkout@v4
60
+ - uses: astral-sh/setup-uv@v5
61
+ with:
62
+ enable-cache: true
63
+ python-version: "3.12"
64
+ - name: Install project and development dependencies
65
+ run: uv sync --extra dev
66
+ - name: Test
67
+ run: uv run pytest
@@ -22,7 +22,7 @@ jobs:
22
22
  - name: Build sdist + wheel
23
23
  run: uv build
24
24
  - name: Smoke-check the build
25
- run: uv run --with dist/*.whl tamarind --version
25
+ run: uv run --isolated --no-project --with dist/*.whl tamarind --version
26
26
  - uses: actions/upload-artifact@v4
27
27
  with:
28
28
  name: dist
@@ -6,6 +6,7 @@ build/
6
6
  dist/
7
7
  .pytest_cache/
8
8
  .ruff_cache/
9
+ .uv-cache/
9
10
  .coverage
10
11
  htmlcov/
11
12
  .tamarind/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tamarind-cli
3
- Version: 0.1.4
3
+ Version: 0.2.0
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
@@ -39,7 +39,8 @@ server](https://mcp.tamarind.bio) uses, so the two stay in lockstep. See
39
39
  uv tool install tamarind-cli # or: pipx install tamarind-cli
40
40
  ```
41
41
 
42
- Or with the bootstrap installer (installs `uv` if needed):
42
+ Or with the bootstrap installer (uses `uv`, `pipx`, or Python/pip already on
43
+ your `PATH`):
43
44
 
44
45
  ```bash
45
46
  curl -fsSL https://app.tamarind.bio/cli/install.sh | sh
@@ -80,7 +81,8 @@ tamarind schema boltz --example > job.yaml # most tools ship an example; some
80
81
 
81
82
  # 3. Validate, then submit
82
83
  tamarind validate boltz --input job.yaml
83
- tamarind submit boltz --input job.yaml --name my-run --wait --download ./out
84
+ tamarind submit boltz --input job.yaml --name my-run \
85
+ --wait --timeout 3600 --download ./out
84
86
 
85
87
  # 4. Monitor / fetch
86
88
  tamarind jobs
@@ -99,9 +101,26 @@ tamarind submit boltz \
99
101
 
100
102
  ## Output for agents
101
103
 
102
- Every command emits JSON when stdout is not a TTY, or with `--json`. Exit codes
103
- are stable: `0` ok, `3` auth, `4` not-found, `5` validation, `6` rate-limit,
104
- `7` timeout (a `--wait`/`--timeout` deadline elapsed).
104
+ Every command emits JSON when stdout is not a TTY, or with `--json`. Result
105
+ documents are written to stdout. Typed command, transport, and usage errors are
106
+ written to stderr as
107
+ `{"error":{"type":...,"message":...,"exitCode":...,"detail":...}}`; `detail`
108
+ is omitted when unavailable. A domain verdict can deliberately combine a
109
+ nonzero exit with a result document: invalid `validate` output remains on
110
+ stdout with exit 5, and a terminal unsuccessful job remains on stdout with exit
111
+ 9 so callers can inspect its status. Exit codes are stable: `0` ok, `1`
112
+ generic failure, `2` usage, `3` auth, `4` not-found, `5` validation, `6`
113
+ rate-limit, `7` timeout (a bounded wait elapsed while the remote job may still
114
+ run), `8` budget/quota exhaustion, and `9` remote job failure.
115
+
116
+ For agent workflows, always put a deadline on blocking commands. `submit --wait`
117
+ and `results --wait` accept `--timeout`; the standalone `wait` command remains
118
+ the easiest way to reattach to a durable job name.
119
+
120
+ Starting in 0.2, `results` requires `--download DIR` by default and never prints
121
+ a presigned URL implicitly. This intentional safety boundary is why the release
122
+ uses a new pre-1.0 minor version. The explicit `--show-url` escape hatch returns
123
+ a credential-bearing, short-lived URL; do not use it in agent, CI, or shared logs.
105
124
 
106
125
  Not every tool ships a runnable example — `schema <tool> --example` exits non-zero
107
126
  (with a clear message) when one isn't available, so a `> job.yaml` redirect never
@@ -110,9 +129,12 @@ require `--yes`/`-y` when run non-interactively (piped or `--json`), so an agent
110
129
  never removes data without an explicit confirmation.
111
130
 
112
131
  ```bash
113
- tamarind jobs --json | jq '.jobs[] | select(.JobStatus=="Running")'
132
+ tamarind --json jobs | jq '.jobs[] | select(.JobStatus=="Running")'
114
133
  ```
115
134
 
135
+ `--json`, `--profile`, and the endpoint/auth overrides are global options, so
136
+ place them before the command name.
137
+
116
138
  ## Commands
117
139
 
118
140
  | Group | Commands |
@@ -18,7 +18,8 @@ server](https://mcp.tamarind.bio) uses, so the two stay in lockstep. See
18
18
  uv tool install tamarind-cli # or: pipx install tamarind-cli
19
19
  ```
20
20
 
21
- Or with the bootstrap installer (installs `uv` if needed):
21
+ Or with the bootstrap installer (uses `uv`, `pipx`, or Python/pip already on
22
+ your `PATH`):
22
23
 
23
24
  ```bash
24
25
  curl -fsSL https://app.tamarind.bio/cli/install.sh | sh
@@ -59,7 +60,8 @@ tamarind schema boltz --example > job.yaml # most tools ship an example; some
59
60
 
60
61
  # 3. Validate, then submit
61
62
  tamarind validate boltz --input job.yaml
62
- tamarind submit boltz --input job.yaml --name my-run --wait --download ./out
63
+ tamarind submit boltz --input job.yaml --name my-run \
64
+ --wait --timeout 3600 --download ./out
63
65
 
64
66
  # 4. Monitor / fetch
65
67
  tamarind jobs
@@ -78,9 +80,26 @@ tamarind submit boltz \
78
80
 
79
81
  ## Output for agents
80
82
 
81
- Every command emits JSON when stdout is not a TTY, or with `--json`. Exit codes
82
- are stable: `0` ok, `3` auth, `4` not-found, `5` validation, `6` rate-limit,
83
- `7` timeout (a `--wait`/`--timeout` deadline elapsed).
83
+ Every command emits JSON when stdout is not a TTY, or with `--json`. Result
84
+ documents are written to stdout. Typed command, transport, and usage errors are
85
+ written to stderr as
86
+ `{"error":{"type":...,"message":...,"exitCode":...,"detail":...}}`; `detail`
87
+ is omitted when unavailable. A domain verdict can deliberately combine a
88
+ nonzero exit with a result document: invalid `validate` output remains on
89
+ stdout with exit 5, and a terminal unsuccessful job remains on stdout with exit
90
+ 9 so callers can inspect its status. Exit codes are stable: `0` ok, `1`
91
+ generic failure, `2` usage, `3` auth, `4` not-found, `5` validation, `6`
92
+ rate-limit, `7` timeout (a bounded wait elapsed while the remote job may still
93
+ run), `8` budget/quota exhaustion, and `9` remote job failure.
94
+
95
+ For agent workflows, always put a deadline on blocking commands. `submit --wait`
96
+ and `results --wait` accept `--timeout`; the standalone `wait` command remains
97
+ the easiest way to reattach to a durable job name.
98
+
99
+ Starting in 0.2, `results` requires `--download DIR` by default and never prints
100
+ a presigned URL implicitly. This intentional safety boundary is why the release
101
+ uses a new pre-1.0 minor version. The explicit `--show-url` escape hatch returns
102
+ a credential-bearing, short-lived URL; do not use it in agent, CI, or shared logs.
84
103
 
85
104
  Not every tool ships a runnable example — `schema <tool> --example` exits non-zero
86
105
  (with a clear message) when one isn't available, so a `> job.yaml` redirect never
@@ -89,9 +108,12 @@ require `--yes`/`-y` when run non-interactively (piped or `--json`), so an agent
89
108
  never removes data without an explicit confirmation.
90
109
 
91
110
  ```bash
92
- tamarind jobs --json | jq '.jobs[] | select(.JobStatus=="Running")'
111
+ tamarind --json jobs | jq '.jobs[] | select(.JobStatus=="Running")'
93
112
  ```
94
113
 
114
+ `--json`, `--profile`, and the endpoint/auth overrides are global options, so
115
+ place them before the command name.
116
+
95
117
  ## Commands
96
118
 
97
119
  | Group | Commands |
@@ -10,11 +10,15 @@ the CLI and the MCP from drifting as the platform evolves.
10
10
  ### 1. Job/file REST surface — source of truth: the OpenAPI spec
11
11
 
12
12
  `submit`, `validate`, `batch`, `jobs`, `status`/`wait`, `results`, `files`,
13
- `cancel`, `delete` map 1:1 onto operations in `openapi-mcp.yaml` — the same
14
- spec the MCP server is generated from (`FastMCP.from_openapi`). The CLI's
15
- [`rest.py`](../src/tamarind/rest.py) is a literal, logic-free mapping of that
16
- spec. Because both clients derive from one spec, a contract test can fail CI if
17
- they diverge.
13
+ `cancel`, and `delete` map onto operations in `openapi-mcp.yaml` — the same
14
+ server contract used to generate the MCP surface. The CLI's
15
+ [`rest.py`](../src/tamarind/rest.py) is intentionally a small mapping of those
16
+ operations.
17
+
18
+ The CLI mapping is still client code and can drift. Unit tests cover request and
19
+ response shapes, and authenticated no-spend smoke tests should exercise auth,
20
+ catalog lookup, schema lookup, and validation before a release. Changes to the
21
+ server contract should update the CLI tests in the same change or release train.
18
22
 
19
23
  These calls go directly to the job API (`https://app.tamarind.bio/api/`) with
20
24
  the `x-api-key` header.
@@ -37,10 +41,9 @@ detail that can change without any client change and without drift.
37
41
 
38
42
  A from-scratch client in another language would re-encode the request shapes and
39
43
  the catalog logic — two copies that drift the moment the platform changes.
40
- Keeping the CLI a thin, spec-derived Python client that shares the OpenAPI
41
- contract (and, server-side, the catalog module) with the MCP makes drift a
42
- structural impossibility plus a CI-enforced check, rather than something to
43
- remember.
44
+ Keeping the CLI thin and anchored to the same OpenAPI contract (and,
45
+ server-side, the same catalog module) reduces drift and makes it testable. It
46
+ does not eliminate the need for compatibility tests and coordinated releases.
44
47
 
45
48
  ## Configuration indirection
46
49
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tamarind-cli"
3
- version = "0.1.4"
3
+ version = "0.2.0"
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"
@@ -7,7 +7,7 @@ from typing import Optional
7
7
  import typer
8
8
 
9
9
  from ... import rest
10
- from ...config import mask_key, save_profile
10
+ from ...config import save_profile
11
11
  from ...errors import AuthError
12
12
  from ...http import HTTPClient
13
13
  from .. import output
@@ -56,23 +56,19 @@ def login(
56
56
 
57
57
  @app.command()
58
58
  def status(ctx: typer.Context) -> None:
59
- """Show the active profile, endpoints, and whether the key works."""
59
+ """Show the active profile and whether its key works."""
60
60
  state = ctx.obj
61
61
  cfg = state.config()
62
62
  verified = cfg.has_key and _check_key(cfg.api_base, cfg.api_key)
63
63
  result = {
64
64
  "profile": cfg.profile,
65
- "apiKey": mask_key(cfg.api_key),
66
65
  "hasKey": cfg.has_key,
67
66
  "verified": verified,
68
- "apiBase": cfg.api_base,
69
- "catalogBase": cfg.catalog_base,
70
67
  }
71
68
  human = (
72
69
  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}"
70
+ f"api key: {'configured' if cfg.has_key else 'not configured'} "
71
+ f"({'verified' if verified else 'not verified'})"
76
72
  )
77
73
  output.emit(result, state.output, human=human)
78
74
 
@@ -8,6 +8,40 @@ import typer
8
8
 
9
9
  from ... import catalog
10
10
  from .. import output
11
+ from ..guidance import rewrite_legacy_guidance
12
+
13
+
14
+ def _with_cli_tools_hint(response: object) -> object:
15
+ """Preserve catalog guidance and add instructions valid in this CLI."""
16
+ if not isinstance(response, dict):
17
+ return response
18
+ result = dict(response)
19
+ if "hint" in result:
20
+ result["hint"] = rewrite_legacy_guidance(result["hint"])
21
+ result["cliHint"] = (
22
+ "Inspect a tool with `tamarind --json schema NAME`. Pass the exact lowercase "
23
+ "tool name, and narrow discovery with `--modality` or `--function`."
24
+ )
25
+ return result
26
+
27
+
28
+ def _with_cli_schema_hints(response: object, tool: str) -> object:
29
+ """Keep live schema data while preventing MCP-only instructions from leaking in."""
30
+ if not isinstance(response, dict) or response.get("error"):
31
+ return response
32
+ result = dict(response)
33
+ if "hint" in result:
34
+ result["hint"] = rewrite_legacy_guidance(result["hint"])
35
+ result["cliHint"] = (
36
+ f"Validate settings with `tamarind --json validate {tool} --input FILE`. "
37
+ "Upload local file inputs with `tamarind --json files upload PATH`; download "
38
+ "completed outputs with `tamarind --json results JOB --download DIR`."
39
+ )
40
+ if "exampleJobNote" in result:
41
+ result["exampleJobNote"] = rewrite_legacy_guidance(
42
+ result["exampleJobNote"]
43
+ )
44
+ return result
11
45
 
12
46
 
13
47
  def register(app: typer.Typer) -> None:
@@ -25,6 +59,7 @@ def register(app: typer.Typer) -> None:
25
59
  resp = catalog.list_tools(
26
60
  client, modality=modality, function=function, search=search, custom=custom or None
27
61
  )
62
+ resp = _with_cli_tools_hint(resp)
28
63
  rows = [
29
64
  {
30
65
  "name": t.get("name"),
@@ -74,6 +109,7 @@ def register(app: typer.Typer) -> None:
74
109
  state = ctx.obj
75
110
  with state.catalog_client() as client:
76
111
  resp = catalog.get_schema(client, tool)
112
+ resp = _with_cli_schema_hints(resp, tool)
77
113
  # The catalog returns HTTP 200 with {"error": ...} for an unknown/hidden
78
114
  # tool; turn that into a not-found exit instead of printing it as success.
79
115
  if isinstance(resp, dict) and resp.get("error"):
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Iterator
5
6
  from pathlib import Path
7
+ from typing import BinaryIO
6
8
  from typing import Optional
7
9
 
8
10
  import httpx
@@ -14,6 +16,54 @@ from .. import output
14
16
 
15
17
  app = typer.Typer(no_args_is_help=True)
16
18
 
19
+ _UPLOAD_CHUNK_SIZE = 1024 * 1024
20
+ _UPLOAD_TIMEOUT = 300.0
21
+
22
+
23
+ def _iter_file_chunks(fh: BinaryIO) -> Iterator[bytes]:
24
+ """Read an upload incrementally instead of buffering the whole file."""
25
+ while chunk := fh.read(_UPLOAD_CHUNK_SIZE):
26
+ yield chunk
27
+
28
+
29
+ def _put_presigned_upload(url: str, path: Path, *, content_type: str, remote: str) -> int:
30
+ """Stream ``path`` to a presigned URL and translate transport failures.
31
+
32
+ Presigned URLs contain temporary credentials, so error messages deliberately
33
+ omit the URL rather than echoing ``httpx``'s exception text.
34
+ """
35
+ try:
36
+ size = path.stat().st_size
37
+ headers = {
38
+ "Content-Type": content_type,
39
+ # Supplying the size avoids chunked transfer encoding, which is not
40
+ # accepted by every S3-compatible presigned PUT endpoint.
41
+ "Content-Length": str(size),
42
+ }
43
+ with path.open("rb") as fh:
44
+ put = httpx.put(
45
+ url,
46
+ content=_iter_file_chunks(fh),
47
+ headers=headers,
48
+ timeout=_UPLOAD_TIMEOUT,
49
+ )
50
+ put.raise_for_status()
51
+ except httpx.TimeoutException as exc:
52
+ raise TamarindError(
53
+ f"Upload of '{remote}' timed out after {_UPLOAD_TIMEOUT:g} seconds."
54
+ ) from exc
55
+ except httpx.HTTPStatusError as exc:
56
+ raise TamarindError(
57
+ f"Upload of '{remote}' failed with HTTP {exc.response.status_code}."
58
+ ) from exc
59
+ except (httpx.RequestError, httpx.InvalidURL, httpx.StreamError) as exc:
60
+ raise TamarindError(
61
+ f"Upload transfer failed for '{remote}' ({type(exc).__name__})."
62
+ ) from exc
63
+ except OSError as exc:
64
+ raise TamarindError(f"Could not read '{path}' during upload: {exc}.") from exc
65
+ return size
66
+
17
67
 
18
68
  def _file_name(f: object) -> str:
19
69
  """A file entry is usually a bare name string, but be tolerant of dicts."""
@@ -162,16 +212,19 @@ def upload(
162
212
  # The endpoint returns {uploadUrl, headUrl, key, bucket}; PUT the bytes to
163
213
  # uploadUrl. Don't crash on a non-dict error body — surface a clean error.
164
214
  url = signed.get("uploadUrl") if isinstance(signed, dict) else None
165
- if not url:
166
- raise TamarindError("Upload did not return a presigned URL.", detail=signed)
215
+ if not isinstance(url, str) or not url:
216
+ # Never echo the response values here: sibling fields such as headUrl
217
+ # can also be credential-bearing presigned URLs.
218
+ detail = {"responseType": type(signed).__name__}
219
+ if isinstance(signed, dict):
220
+ detail["fields"] = sorted(str(key) for key in signed)
221
+ raise TamarindError("Upload did not return a presigned URL.", detail=detail)
167
222
  output.info(f"Uploading {path} → {remote}…", state.output)
168
223
  # Content-Type must match what the presigned URL was signed with, or S3
169
224
  # rejects the PUT with SignatureDoesNotMatch.
170
- with path.open("rb") as fh:
171
- put = httpx.put(url, content=fh.read(), headers={"Content-Type": content_type}, timeout=300.0)
172
- put.raise_for_status()
225
+ size = _put_presigned_upload(url, path, content_type=content_type, remote=remote)
173
226
  output.emit(
174
- {"ok": True, "filename": remote, "bytes": path.stat().st_size},
227
+ {"ok": True, "filename": remote, "bytes": size},
175
228
  state.output,
176
229
  human=f"uploaded {remote}",
177
230
  )