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.
- tamarind_cli-0.2.0/.github/workflows/ci.yml +67 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/.github/workflows/publish.yml +1 -1
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/.gitignore +1 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/PKG-INFO +29 -7
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/README.md +28 -6
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/docs/architecture.md +12 -9
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/pyproject.toml +1 -1
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/commands/auth.py +4 -8
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/commands/catalog.py +36 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/commands/files.py +59 -6
- tamarind_cli-0.2.0/src/tamarind/cli/commands/jobs.py +728 -0
- tamarind_cli-0.2.0/src/tamarind/cli/guidance.py +51 -0
- tamarind_cli-0.2.0/src/tamarind/cli/main.py +223 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/output.py +29 -2
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/errors.py +10 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/http.py +29 -3
- tamarind_cli-0.2.0/src/tamarind/jobs.py +188 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/rest.py +6 -5
- tamarind_cli-0.2.0/tests/test_agent_contract.py +962 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_bugbash.py +1 -1
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_cli_commands.py +201 -0
- tamarind_cli-0.2.0/tests/test_jobs.py +190 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_rest.py +48 -2
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/uv.lock +1 -1
- tamarind_cli-0.1.4/src/tamarind/cli/commands/jobs.py +0 -374
- tamarind_cli-0.1.4/src/tamarind/cli/main.py +0 -122
- tamarind_cli-0.1.4/src/tamarind/jobs.py +0 -107
- tamarind_cli-0.1.4/tests/test_jobs.py +0 -78
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/LICENSE +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/install.sh +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/__init__.py +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/catalog.py +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/__init__.py +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/commands/__init__.py +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/cli/inputs.py +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/src/tamarind/config.py +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_config.py +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_file_filters.py +0 -0
- {tamarind_cli-0.1.4 → tamarind_cli-0.2.0}/tests/test_inputs.py +0 -0
- {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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tamarind-cli
|
|
3
|
-
Version: 0.
|
|
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 (
|
|
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
|
|
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`.
|
|
103
|
-
are
|
|
104
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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`.
|
|
82
|
-
are
|
|
83
|
-
|
|
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
|
|
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
|
|
14
|
-
|
|
15
|
-
[`rest.py`](../src/tamarind/rest.py) is a
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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
|
|
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
|
|
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: {
|
|
74
|
-
f"
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
227
|
+
{"ok": True, "filename": remote, "bytes": size},
|
|
175
228
|
state.output,
|
|
176
229
|
human=f"uploaded {remote}",
|
|
177
230
|
)
|