proteworks 0.1.4__tar.gz → 0.1.10__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.
- {proteworks-0.1.4 → proteworks-0.1.10}/PKG-INFO +78 -21
- {proteworks-0.1.4 → proteworks-0.1.10}/README.md +76 -19
- {proteworks-0.1.4 → proteworks-0.1.10}/pyproject.toml +3 -2
- proteworks-0.1.10/src/proteworks/__init__.py +13 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/api.py +215 -13
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/auth.py +10 -1
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/browse.py +5 -4
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/cli.py +172 -7
- proteworks-0.1.10/src/proteworks/config.py +185 -0
- proteworks-0.1.10/src/proteworks/folders.py +117 -0
- proteworks-0.1.10/src/proteworks/mcp/errors.py +87 -0
- proteworks-0.1.10/src/proteworks/mcp/paths.py +128 -0
- proteworks-0.1.10/src/proteworks/mcp/profile.py +78 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/mcp/server.py +16 -5
- proteworks-0.1.10/src/proteworks/mcp/tools_files.py +211 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/mcp/tools_orgs.py +66 -27
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/mcp/tools_studio.py +3 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/mcp/tools_workflows.py +151 -5
- proteworks-0.1.10/src/proteworks/mcp_host.py +971 -0
- proteworks-0.1.10/src/proteworks/push.py +890 -0
- proteworks-0.1.10/src/proteworks/redact.py +97 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/results.py +23 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/session.py +12 -1
- proteworks-0.1.10/src/proteworks/studio/kinds/rfd4_proteina.py +102 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio/registry.py +7 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/workflows.py +131 -2
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks.egg-info/PKG-INFO +78 -21
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks.egg-info/SOURCES.txt +11 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks.egg-info/requires.txt +1 -1
- {proteworks-0.1.4 → proteworks-0.1.10}/tests/test_cli.py +371 -28
- proteworks-0.1.10/tests/test_config_dir.py +34 -0
- proteworks-0.1.10/tests/test_folders.py +147 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/tests/test_mcp_adversarial.py +3 -1
- proteworks-0.1.10/tests/test_mcp_errors.py +31 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/tests/test_mcp_files.py +47 -1
- proteworks-0.1.10/tests/test_mcp_host.py +317 -0
- proteworks-0.1.10/tests/test_mcp_paths.py +45 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/tests/test_mcp_studio.py +29 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/tests/test_mcp_workflows.py +49 -0
- proteworks-0.1.10/tests/test_redact.py +31 -0
- proteworks-0.1.10/tests/test_rfd4_proteina_workflow.py +78 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/tests/test_session.py +5 -1
- proteworks-0.1.10/tests/test_token_store.py +69 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/tests/test_transfer_result.py +50 -4
- proteworks-0.1.4/src/proteworks/__init__.py +0 -3
- proteworks-0.1.4/src/proteworks/config.py +0 -88
- proteworks-0.1.4/src/proteworks/mcp/errors.py +0 -32
- proteworks-0.1.4/src/proteworks/mcp/paths.py +0 -47
- proteworks-0.1.4/src/proteworks/mcp/profile.py +0 -30
- proteworks-0.1.4/src/proteworks/mcp/tools_files.py +0 -131
- proteworks-0.1.4/src/proteworks/push.py +0 -147
- proteworks-0.1.4/src/proteworks/redact.py +0 -47
- {proteworks-0.1.4 → proteworks-0.1.10}/setup.cfg +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/index.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/mcp/__init__.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/mcp/runtime.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/orgs.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/pull.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio/__init__.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio/common.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio/kinds/__init__.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio/kinds/base.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio/kinds/proteinmpnn.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio/kinds/refolding.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio/kinds/rfd3.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio/kinds/structure_prediction.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks/studio_proteinmpnn.py +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks.egg-info/dependency_links.txt +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks.egg-info/entry_points.txt +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/src/proteworks.egg-info/top_level.txt +0 -0
- {proteworks-0.1.4 → proteworks-0.1.10}/tests/test_browse.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proteworks
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.10
|
|
4
4
|
Summary: Sync files with Proteworks using file-browser paths.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
-
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: httpx[socks]>=0.27
|
|
8
8
|
Requires-Dist: keyring>=25
|
|
9
9
|
Provides-Extra: dev
|
|
10
10
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -16,37 +16,94 @@ Requires-Dist: pyarrow>=14; extra == "mcp"
|
|
|
16
16
|
|
|
17
17
|
Command line client and local MCP server for syncing Proteworks files and submitting allowlisted workflows. The default API is production. Set `PROTEWORKS_API_URL` only when testing against staging or a local stack.
|
|
18
18
|
|
|
19
|
+
## One install (CLI + Claude Science)
|
|
20
|
+
|
|
21
|
+
There is a **single supported install**:
|
|
22
|
+
|
|
23
|
+
| Path | Role |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `/opt/proteworks` | Package + `proteworks-mcp` |
|
|
26
|
+
| `/usr/local/bin/proteworks` | CLI symlink |
|
|
27
|
+
| `/usr/local/bin/proteworks-mcp` | MCP symlink (Science **Command**) |
|
|
28
|
+
| `~/.config/proteworks/` | Login token file + org config |
|
|
29
|
+
|
|
30
|
+
Do **not** keep a second copy from `uv tool` / pipx / a repo `.venv` on your PATH for daily use. Those diverge and break Claude Science.
|
|
31
|
+
|
|
32
|
+
### Bootstrap once, then use only `/usr/local/bin`
|
|
33
|
+
|
|
19
34
|
```bash
|
|
20
|
-
|
|
35
|
+
# 1) Temporary bootstrap (any machine with uv)
|
|
36
|
+
uv tool install 'proteworks[mcp]'
|
|
37
|
+
|
|
38
|
+
# 2) Canonical install (needs sudo). Uses distro python3.12+ under /usr (not uv under $HOME).
|
|
39
|
+
sudo "$(which proteworks)" mcp install-system
|
|
40
|
+
# Developers working from a git checkout before publishing:
|
|
41
|
+
# sudo "$(which proteworks)" mcp install-system --package-spec "$PWD[mcp]"
|
|
42
|
+
|
|
43
|
+
# 3) Remove the bootstrap copy so PATH has one binary
|
|
44
|
+
uv tool uninstall proteworks
|
|
45
|
+
hash -r
|
|
46
|
+
which -a proteworks # expect only /usr/local/bin/proteworks
|
|
47
|
+
|
|
48
|
+
# 4) Login + Science grants
|
|
21
49
|
proteworks login
|
|
22
|
-
proteworks organization list
|
|
23
50
|
proteworks organization set "My Org"
|
|
24
|
-
proteworks
|
|
25
|
-
proteworks
|
|
26
|
-
proteworks push ./run '~/My Files/campaigns/run/'
|
|
51
|
+
proteworks mcp setup-claude-science
|
|
52
|
+
proteworks mcp doctor
|
|
27
53
|
```
|
|
28
54
|
|
|
29
|
-
|
|
55
|
+
After any upgrade: run step 2 again (same `install-system` command), then `doctor`. Do not `uv tool install` again unless `/usr/local/bin/proteworks` is missing.
|
|
30
56
|
|
|
31
|
-
|
|
57
|
+
### Claude Science connector
|
|
32
58
|
|
|
33
|
-
|
|
59
|
+
`setup-claude-science` prints the fields. Always:
|
|
34
60
|
|
|
35
|
-
|
|
61
|
+
| Field | Value |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| **Name** | `proteworks` |
|
|
64
|
+
| **Command** | `/opt/proteworks/bin/proteworks-mcp` |
|
|
65
|
+
| **Arguments** | leave empty |
|
|
66
|
+
| **Env** (if shown) | `PROTEWORKS_CONFIG_DIR=$HOME/.config/proteworks` |
|
|
67
|
+
|
|
68
|
+
Paste **only** that Command path. Do not use `python -m …`, `uvx`, or a path under `~/.local`. Science’s one-field UI can drop `-m` and hang for 180s; sandboxed `HOME` also cannot see a second user-level install’s credentials unless you use the system layout above.
|
|
69
|
+
|
|
70
|
+
Fully quit and relaunch Claude Science after setup so it reloads `config.toml`.
|
|
71
|
+
Also add each host **by name** under Science **Settings → Domain Allowlist**
|
|
72
|
+
(Science rejects wildcards). `setup-claude-science` prints the list — typically
|
|
73
|
+
`api.studio.proteworks.com` plus virtual-hosted GCS hosts such as
|
|
74
|
+
`proteworks-production.storage.googleapis.com`. The connector’s `config.toml`
|
|
75
|
+
grants are necessary but Science’s UI allowlist often gates egress separately.
|
|
76
|
+
Bare `storage.googleapis.com` is hard-denylisted (path-style GCS). If pull still
|
|
77
|
+
fails for a custom org bucket, add `<bucket>.storage.googleapis.com`. Science may
|
|
78
|
+
set `ALL_PROXY` (SOCKS); `proteworks` depends on `httpx[socks]` so that works out
|
|
79
|
+
of the box. If a direct GCS PUT is still blocked, push falls back to uploading
|
|
80
|
+
through the Proteworks API host.
|
|
81
|
+
|
|
82
|
+
Allow Claude to push/pull from a real project (like the CLI — no staging copy):
|
|
36
83
|
|
|
37
84
|
```bash
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
85
|
+
proteworks mcp setup-claude-science --mcp-root ~/projects/my-campaign
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
That grants the Science sandbox **and** writes `~/.config/proteworks/mcp_roots`.
|
|
89
|
+
Restart the connector afterward. Absolute paths under that project then work with
|
|
90
|
+
MCP `push` / `pull`.
|
|
91
|
+
|
|
92
|
+
`proteworks --version` / `proteworks-mcp --version` print the installed version.
|
|
93
|
+
|
|
94
|
+
Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
proteworks pull '~/My Files/campaigns/run/' ./run
|
|
98
|
+
proteworks push ./1UBQ.pdb '~/My Files/test/'
|
|
99
|
+
proteworks push '*.pdb' '~/My Files/pdbs/'
|
|
100
|
+
proteworks push designs.zip '~/My Files/dest/' # auto-extracts
|
|
101
|
+
proteworks mkdir '~/My Files/campaigns/round6/'
|
|
42
102
|
```
|
|
43
103
|
|
|
44
|
-
|
|
104
|
+
MCP also exposes `create_folder` for the same empty-directory create (returns `folder_id` for `output_folder_id` / push).
|
|
45
105
|
|
|
46
|
-
1
|
|
47
|
-
2. Command: `proteworks-mcp`
|
|
48
|
-
(or `uvx --from 'proteworks[mcp]' proteworks-mcp`)
|
|
49
|
-
3. Approve tools as needed
|
|
106
|
+
Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs.
|
|
50
107
|
|
|
51
108
|
Example prompts: list `~/My Files/`, dry-run pull a folder, pull it, push results back.
|
|
52
109
|
|
|
@@ -76,7 +133,7 @@ Import via Settings → Skills → Import from GitHub (or upload the folder). Ho
|
|
|
76
133
|
| `get_execution` / `list_execution_results` / `cancel_execution` | Status / results / cancel (`cancel_execution` needs `confirm=true`) |
|
|
77
134
|
| `estimate_workflow_cost` | Cost estimate from merged params (same validation as prepare) |
|
|
78
135
|
|
|
79
|
-
Auth is shared with the CLI
|
|
136
|
+
Auth is shared with the CLI. The access token is stored in the OS keychain and mirrored to `~/.config/proteworks/credentials` (mode `0600`) so Claude Science’s sandbox can read it. There is no separate MCP login: run `proteworks login` in a terminal, then restart the connector.
|
|
80
137
|
|
|
81
138
|
### MCP safety env
|
|
82
139
|
|
|
@@ -2,37 +2,94 @@
|
|
|
2
2
|
|
|
3
3
|
Command line client and local MCP server for syncing Proteworks files and submitting allowlisted workflows. The default API is production. Set `PROTEWORKS_API_URL` only when testing against staging or a local stack.
|
|
4
4
|
|
|
5
|
+
## One install (CLI + Claude Science)
|
|
6
|
+
|
|
7
|
+
There is a **single supported install**:
|
|
8
|
+
|
|
9
|
+
| Path | Role |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `/opt/proteworks` | Package + `proteworks-mcp` |
|
|
12
|
+
| `/usr/local/bin/proteworks` | CLI symlink |
|
|
13
|
+
| `/usr/local/bin/proteworks-mcp` | MCP symlink (Science **Command**) |
|
|
14
|
+
| `~/.config/proteworks/` | Login token file + org config |
|
|
15
|
+
|
|
16
|
+
Do **not** keep a second copy from `uv tool` / pipx / a repo `.venv` on your PATH for daily use. Those diverge and break Claude Science.
|
|
17
|
+
|
|
18
|
+
### Bootstrap once, then use only `/usr/local/bin`
|
|
19
|
+
|
|
5
20
|
```bash
|
|
6
|
-
|
|
21
|
+
# 1) Temporary bootstrap (any machine with uv)
|
|
22
|
+
uv tool install 'proteworks[mcp]'
|
|
23
|
+
|
|
24
|
+
# 2) Canonical install (needs sudo). Uses distro python3.12+ under /usr (not uv under $HOME).
|
|
25
|
+
sudo "$(which proteworks)" mcp install-system
|
|
26
|
+
# Developers working from a git checkout before publishing:
|
|
27
|
+
# sudo "$(which proteworks)" mcp install-system --package-spec "$PWD[mcp]"
|
|
28
|
+
|
|
29
|
+
# 3) Remove the bootstrap copy so PATH has one binary
|
|
30
|
+
uv tool uninstall proteworks
|
|
31
|
+
hash -r
|
|
32
|
+
which -a proteworks # expect only /usr/local/bin/proteworks
|
|
33
|
+
|
|
34
|
+
# 4) Login + Science grants
|
|
7
35
|
proteworks login
|
|
8
|
-
proteworks organization list
|
|
9
36
|
proteworks organization set "My Org"
|
|
10
|
-
proteworks
|
|
11
|
-
proteworks
|
|
12
|
-
proteworks push ./run '~/My Files/campaigns/run/'
|
|
37
|
+
proteworks mcp setup-claude-science
|
|
38
|
+
proteworks mcp doctor
|
|
13
39
|
```
|
|
14
40
|
|
|
15
|
-
|
|
41
|
+
After any upgrade: run step 2 again (same `install-system` command), then `doctor`. Do not `uv tool install` again unless `/usr/local/bin/proteworks` is missing.
|
|
16
42
|
|
|
17
|
-
|
|
43
|
+
### Claude Science connector
|
|
18
44
|
|
|
19
|
-
|
|
45
|
+
`setup-claude-science` prints the fields. Always:
|
|
20
46
|
|
|
21
|
-
|
|
47
|
+
| Field | Value |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| **Name** | `proteworks` |
|
|
50
|
+
| **Command** | `/opt/proteworks/bin/proteworks-mcp` |
|
|
51
|
+
| **Arguments** | leave empty |
|
|
52
|
+
| **Env** (if shown) | `PROTEWORKS_CONFIG_DIR=$HOME/.config/proteworks` |
|
|
53
|
+
|
|
54
|
+
Paste **only** that Command path. Do not use `python -m …`, `uvx`, or a path under `~/.local`. Science’s one-field UI can drop `-m` and hang for 180s; sandboxed `HOME` also cannot see a second user-level install’s credentials unless you use the system layout above.
|
|
55
|
+
|
|
56
|
+
Fully quit and relaunch Claude Science after setup so it reloads `config.toml`.
|
|
57
|
+
Also add each host **by name** under Science **Settings → Domain Allowlist**
|
|
58
|
+
(Science rejects wildcards). `setup-claude-science` prints the list — typically
|
|
59
|
+
`api.studio.proteworks.com` plus virtual-hosted GCS hosts such as
|
|
60
|
+
`proteworks-production.storage.googleapis.com`. The connector’s `config.toml`
|
|
61
|
+
grants are necessary but Science’s UI allowlist often gates egress separately.
|
|
62
|
+
Bare `storage.googleapis.com` is hard-denylisted (path-style GCS). If pull still
|
|
63
|
+
fails for a custom org bucket, add `<bucket>.storage.googleapis.com`. Science may
|
|
64
|
+
set `ALL_PROXY` (SOCKS); `proteworks` depends on `httpx[socks]` so that works out
|
|
65
|
+
of the box. If a direct GCS PUT is still blocked, push falls back to uploading
|
|
66
|
+
through the Proteworks API host.
|
|
67
|
+
|
|
68
|
+
Allow Claude to push/pull from a real project (like the CLI — no staging copy):
|
|
22
69
|
|
|
23
70
|
```bash
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
71
|
+
proteworks mcp setup-claude-science --mcp-root ~/projects/my-campaign
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That grants the Science sandbox **and** writes `~/.config/proteworks/mcp_roots`.
|
|
75
|
+
Restart the connector afterward. Absolute paths under that project then work with
|
|
76
|
+
MCP `push` / `pull`.
|
|
77
|
+
|
|
78
|
+
`proteworks --version` / `proteworks-mcp --version` print the installed version.
|
|
79
|
+
|
|
80
|
+
Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
proteworks pull '~/My Files/campaigns/run/' ./run
|
|
84
|
+
proteworks push ./1UBQ.pdb '~/My Files/test/'
|
|
85
|
+
proteworks push '*.pdb' '~/My Files/pdbs/'
|
|
86
|
+
proteworks push designs.zip '~/My Files/dest/' # auto-extracts
|
|
87
|
+
proteworks mkdir '~/My Files/campaigns/round6/'
|
|
28
88
|
```
|
|
29
89
|
|
|
30
|
-
|
|
90
|
+
MCP also exposes `create_folder` for the same empty-directory create (returns `folder_id` for `output_folder_id` / push).
|
|
31
91
|
|
|
32
|
-
1
|
|
33
|
-
2. Command: `proteworks-mcp`
|
|
34
|
-
(or `uvx --from 'proteworks[mcp]' proteworks-mcp`)
|
|
35
|
-
3. Approve tools as needed
|
|
92
|
+
Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs.
|
|
36
93
|
|
|
37
94
|
Example prompts: list `~/My Files/`, dry-run pull a folder, pull it, push results back.
|
|
38
95
|
|
|
@@ -62,7 +119,7 @@ Import via Settings → Skills → Import from GitHub (or upload the folder). Ho
|
|
|
62
119
|
| `get_execution` / `list_execution_results` / `cancel_execution` | Status / results / cancel (`cancel_execution` needs `confirm=true`) |
|
|
63
120
|
| `estimate_workflow_cost` | Cost estimate from merged params (same validation as prepare) |
|
|
64
121
|
|
|
65
|
-
Auth is shared with the CLI
|
|
122
|
+
Auth is shared with the CLI. The access token is stored in the OS keychain and mirrored to `~/.config/proteworks/credentials` (mode `0600`) so Claude Science’s sandbox can read it. There is no separate MCP login: run `proteworks login` in a terminal, then restart the connector.
|
|
66
123
|
|
|
67
124
|
### MCP safety env
|
|
68
125
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "proteworks"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.10"
|
|
4
4
|
description = "Sync files with Proteworks using file-browser paths."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
7
|
dependencies = [
|
|
8
|
-
|
|
8
|
+
# socks: Claude Science sets ALL_PROXY; httpx needs socksio for that backend.
|
|
9
|
+
"httpx[socks]>=0.27",
|
|
9
10
|
"keyring>=25",
|
|
10
11
|
]
|
|
11
12
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Proteworks sync client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("proteworks")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
__version__ = "0.1.10"
|
|
12
|
+
except Exception: # pragma: no cover
|
|
13
|
+
__version__ = "0.1.10"
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import os
|
|
6
7
|
import time
|
|
8
|
+
from pathlib import Path
|
|
7
9
|
from typing import Any, Optional
|
|
8
10
|
from urllib.parse import urlencode, urlparse
|
|
9
11
|
|
|
@@ -35,13 +37,21 @@ class ApiError(Exception):
|
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
def assert_allowed_storage_url(url: str) -> None:
|
|
38
|
-
"""Reject signed URLs whose host is outside the storage allowlist (SSRF defense).
|
|
40
|
+
"""Reject signed URLs whose host is outside the storage allowlist (SSRF defense).
|
|
41
|
+
|
|
42
|
+
Production GCS signed URLs must be HTTPS. Plain HTTP is allowed only for
|
|
43
|
+
localhost / 127.0.0.1 (FakeApi and local integration tests).
|
|
44
|
+
"""
|
|
39
45
|
parsed = urlparse(str(url or ""))
|
|
40
|
-
if parsed.scheme not in {"https", "http"}:
|
|
41
|
-
raise ApiError("Storage URL must use http(s)", 400)
|
|
42
46
|
host = (parsed.hostname or "").lower()
|
|
43
47
|
if not host:
|
|
44
48
|
raise ApiError("Storage URL missing host", 400)
|
|
49
|
+
if parsed.scheme == "https":
|
|
50
|
+
pass
|
|
51
|
+
elif parsed.scheme == "http" and host in {"localhost", "127.0.0.1"}:
|
|
52
|
+
pass
|
|
53
|
+
else:
|
|
54
|
+
raise ApiError("Storage URL must use https", 400)
|
|
45
55
|
suffixes = allowed_storage_host_suffixes()
|
|
46
56
|
if any(host == suffix or host.endswith("." + suffix) for suffix in suffixes):
|
|
47
57
|
return
|
|
@@ -199,26 +209,162 @@ class ApiClient:
|
|
|
199
209
|
data={"name": name, "parent_id": parent_id},
|
|
200
210
|
)
|
|
201
211
|
|
|
202
|
-
def upload_init(
|
|
203
|
-
|
|
212
|
+
def upload_init(
|
|
213
|
+
self,
|
|
214
|
+
filename: str,
|
|
215
|
+
folder_id: str,
|
|
216
|
+
size_bytes: int,
|
|
217
|
+
content_type: str,
|
|
218
|
+
*,
|
|
219
|
+
upload_purpose: str = "default",
|
|
220
|
+
extraction_job_id: Optional[str] = None,
|
|
221
|
+
skip_existing: bool = False,
|
|
222
|
+
) -> dict:
|
|
223
|
+
"""Initialize a direct upload. Retries on HTTP 429 (shared upload rate limit)."""
|
|
224
|
+
body: dict[str, Any] = {
|
|
225
|
+
"filename": filename,
|
|
226
|
+
"folder_id": folder_id,
|
|
227
|
+
"content_type": content_type,
|
|
228
|
+
"size_bytes": size_bytes,
|
|
229
|
+
"upload_purpose": upload_purpose,
|
|
230
|
+
"skip_existing": skip_existing,
|
|
231
|
+
}
|
|
232
|
+
if extraction_job_id:
|
|
233
|
+
body["extraction_job_id"] = extraction_job_id
|
|
234
|
+
return self._request_with_backoff(
|
|
204
235
|
"POST",
|
|
205
236
|
"/api/v1/files/upload-init",
|
|
206
|
-
json_body=
|
|
207
|
-
"filename": filename,
|
|
208
|
-
"folder_id": folder_id,
|
|
209
|
-
"content_type": content_type,
|
|
210
|
-
"size_bytes": size_bytes,
|
|
211
|
-
"skip_existing": False,
|
|
212
|
-
},
|
|
237
|
+
json_body=body,
|
|
213
238
|
)
|
|
214
239
|
|
|
215
240
|
def upload_complete(self, session_id: str, size_bytes: int) -> dict:
|
|
216
|
-
|
|
241
|
+
"""Finalize a direct upload. Retries on HTTP 429."""
|
|
242
|
+
return self._request_with_backoff(
|
|
217
243
|
"POST",
|
|
218
244
|
"/api/v1/files/upload-complete",
|
|
219
245
|
json_body={"session_id": session_id, "size_bytes": size_bytes},
|
|
220
246
|
)
|
|
221
247
|
|
|
248
|
+
def upload_batch(
|
|
249
|
+
self,
|
|
250
|
+
files: list[tuple[str, Path, str]],
|
|
251
|
+
folder_ids: list[str],
|
|
252
|
+
*,
|
|
253
|
+
skip_existing: bool = False,
|
|
254
|
+
) -> dict:
|
|
255
|
+
"""POST /files/upload-batch (multipart). Each file is (filename, path, content_type)."""
|
|
256
|
+
if len(files) != len(folder_ids):
|
|
257
|
+
raise ApiError("folder_ids length must match files length", 400)
|
|
258
|
+
if len(files) > 1000:
|
|
259
|
+
raise ApiError("Maximum 1000 files per upload-batch request", 400)
|
|
260
|
+
query: dict[str, str] = {}
|
|
261
|
+
if self.organization_id:
|
|
262
|
+
query["organization_id"] = self.organization_id
|
|
263
|
+
url = self.base_url + "/api/v1/files/upload-batch"
|
|
264
|
+
if query:
|
|
265
|
+
url = url + "?" + urlencode(query)
|
|
266
|
+
data = {"folder_ids": json.dumps(list(folder_ids))}
|
|
267
|
+
if skip_existing:
|
|
268
|
+
data["skip_existing"] = "true"
|
|
269
|
+
delay_seconds = 1.0
|
|
270
|
+
last_error: Optional[ApiError] = None
|
|
271
|
+
for attempt in range(6):
|
|
272
|
+
handles = []
|
|
273
|
+
try:
|
|
274
|
+
multipart = []
|
|
275
|
+
for filename, path, content_type in files:
|
|
276
|
+
handle = Path(path).open("rb")
|
|
277
|
+
handles.append(handle)
|
|
278
|
+
multipart.append(
|
|
279
|
+
(
|
|
280
|
+
"files",
|
|
281
|
+
(
|
|
282
|
+
filename,
|
|
283
|
+
handle,
|
|
284
|
+
content_type or "application/octet-stream",
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
response = self._client.post(
|
|
289
|
+
url,
|
|
290
|
+
headers=self._headers(),
|
|
291
|
+
data=data,
|
|
292
|
+
files=multipart,
|
|
293
|
+
timeout=600.0,
|
|
294
|
+
)
|
|
295
|
+
if response.status_code == 429 and attempt < 5:
|
|
296
|
+
last_error = ApiError(public_detail(response), 429)
|
|
297
|
+
time.sleep(delay_seconds)
|
|
298
|
+
delay_seconds = min(delay_seconds * 2, 30.0)
|
|
299
|
+
continue
|
|
300
|
+
if response.status_code >= 400:
|
|
301
|
+
raise ApiError(public_detail(response), response.status_code)
|
|
302
|
+
if not response.content:
|
|
303
|
+
return {"successful": [], "failed": [], "skipped": []}
|
|
304
|
+
return response.json()
|
|
305
|
+
except ApiError as exc:
|
|
306
|
+
if exc.status_code != 429 or attempt == 5:
|
|
307
|
+
raise
|
|
308
|
+
last_error = exc
|
|
309
|
+
time.sleep(delay_seconds)
|
|
310
|
+
delay_seconds = min(delay_seconds * 2, 30.0)
|
|
311
|
+
finally:
|
|
312
|
+
for handle in handles:
|
|
313
|
+
handle.close()
|
|
314
|
+
raise last_error or ApiError("upload-batch failed", 500)
|
|
315
|
+
|
|
316
|
+
def exists_batch(self, items: list[dict[str, str]]) -> dict:
|
|
317
|
+
"""POST /files/exists-batch. Each item is {folder_id, name}. Chunks to 2000."""
|
|
318
|
+
merged: list[dict] = []
|
|
319
|
+
chunk_size = 2000
|
|
320
|
+
for start in range(0, len(items), chunk_size):
|
|
321
|
+
chunk = items[start : start + chunk_size]
|
|
322
|
+
payload = self._request_with_backoff(
|
|
323
|
+
"POST",
|
|
324
|
+
"/api/v1/files/exists-batch",
|
|
325
|
+
json_body={"items": chunk},
|
|
326
|
+
)
|
|
327
|
+
merged.extend(list((payload or {}).get("existing") or []))
|
|
328
|
+
return {"existing": merged, "existing_count": len(merged), "requested_count": len(items)}
|
|
329
|
+
|
|
330
|
+
def create_archive_extraction_job(
|
|
331
|
+
self,
|
|
332
|
+
zip_filename: str,
|
|
333
|
+
zip_size: int,
|
|
334
|
+
extract_to_folder_id: str,
|
|
335
|
+
) -> dict:
|
|
336
|
+
return self._request_with_backoff(
|
|
337
|
+
"POST",
|
|
338
|
+
"/api/v1/files/archive-extraction-jobs",
|
|
339
|
+
json_body={
|
|
340
|
+
"zip_filename": zip_filename,
|
|
341
|
+
"zip_size": zip_size,
|
|
342
|
+
"extract_to_folder_id": extract_to_folder_id,
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def start_archive_extraction(
|
|
347
|
+
self,
|
|
348
|
+
file_id: str,
|
|
349
|
+
extract_to_folder_id: str,
|
|
350
|
+
*,
|
|
351
|
+
job_id: Optional[str] = None,
|
|
352
|
+
) -> dict:
|
|
353
|
+
body: dict[str, Any] = {
|
|
354
|
+
"file_id": file_id,
|
|
355
|
+
"extract_to_folder_id": extract_to_folder_id,
|
|
356
|
+
}
|
|
357
|
+
if job_id:
|
|
358
|
+
body["job_id"] = job_id
|
|
359
|
+
return self._request_with_backoff(
|
|
360
|
+
"POST",
|
|
361
|
+
"/api/v1/files/archive-extraction",
|
|
362
|
+
json_body=body,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def get_zip_extraction_job(self, job_id: str) -> dict:
|
|
366
|
+
return self.request("GET", f"/api/v1/files/zip-extraction-jobs/{job_id}")
|
|
367
|
+
|
|
222
368
|
def download_to(self, url: str, path) -> None:
|
|
223
369
|
"""Stream bytes to a file. The URL is not logged."""
|
|
224
370
|
assert_allowed_storage_url(url)
|
|
@@ -236,6 +382,32 @@ class ApiClient:
|
|
|
236
382
|
if response.status_code >= 400:
|
|
237
383
|
raise ApiError(f"Upload to storage failed ({response.status_code})", response.status_code)
|
|
238
384
|
|
|
385
|
+
def upload_multipart(self, filename: str, folder_id: str, path, content_type: str) -> dict:
|
|
386
|
+
"""Upload via POST /api/v1/files/upload (API proxy; avoids direct GCS egress).
|
|
387
|
+
|
|
388
|
+
Used when the sandbox blocks path-style ``storage.googleapis.com`` (Claude Science
|
|
389
|
+
denylist) so bytes can still reach Studio through the allowlisted API host.
|
|
390
|
+
"""
|
|
391
|
+
query: dict[str, str] = {}
|
|
392
|
+
if self.organization_id:
|
|
393
|
+
query["organization_id"] = self.organization_id
|
|
394
|
+
url = self.base_url + "/api/v1/files/upload"
|
|
395
|
+
if query:
|
|
396
|
+
url = url + "?" + urlencode(query)
|
|
397
|
+
headers = self._headers()
|
|
398
|
+
with open(path, "rb") as handle:
|
|
399
|
+
response = self._client.post(
|
|
400
|
+
url,
|
|
401
|
+
headers=headers,
|
|
402
|
+
data={"folder_id": folder_id},
|
|
403
|
+
files={"file": (filename, handle, content_type or "application/octet-stream")},
|
|
404
|
+
)
|
|
405
|
+
if response.status_code >= 400:
|
|
406
|
+
raise ApiError(public_detail(response), response.status_code)
|
|
407
|
+
if not response.content:
|
|
408
|
+
return {}
|
|
409
|
+
return response.json()
|
|
410
|
+
|
|
239
411
|
def list_agent_schemas(self) -> dict:
|
|
240
412
|
return self.request("GET", "/api/v1/workflows/agent-schemas")
|
|
241
413
|
|
|
@@ -248,6 +420,36 @@ class ApiClient:
|
|
|
248
420
|
def get_execution(self, execution_id: str) -> dict:
|
|
249
421
|
return self.request("GET", f"/api/v1/workflows/executions/{execution_id}")
|
|
250
422
|
|
|
423
|
+
def get_execution_detailed_status(
|
|
424
|
+
self,
|
|
425
|
+
execution_id: str,
|
|
426
|
+
*,
|
|
427
|
+
steps_limit: Optional[int] = None,
|
|
428
|
+
) -> dict:
|
|
429
|
+
"""GET detailed Pod-step status (Argo) for an execution."""
|
|
430
|
+
params: dict[str, Any] = {}
|
|
431
|
+
if steps_limit is not None:
|
|
432
|
+
params["steps_limit"] = int(steps_limit)
|
|
433
|
+
return self.request(
|
|
434
|
+
"GET",
|
|
435
|
+
f"/api/v1/workflows/executions/{execution_id}/status/detailed",
|
|
436
|
+
params=params or None,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def get_execution_step_logs(
|
|
440
|
+
self,
|
|
441
|
+
execution_id: str,
|
|
442
|
+
step_name: str,
|
|
443
|
+
*,
|
|
444
|
+
tail_lines: int = 500,
|
|
445
|
+
) -> dict:
|
|
446
|
+
"""GET logs for one workflow step (Pod node)."""
|
|
447
|
+
return self.request(
|
|
448
|
+
"GET",
|
|
449
|
+
f"/api/v1/workflows/executions/{execution_id}/steps/{step_name}/logs",
|
|
450
|
+
params={"tail_lines": int(tail_lines)},
|
|
451
|
+
)
|
|
452
|
+
|
|
251
453
|
def list_execution_results(self, execution_id: str) -> list:
|
|
252
454
|
payload = self.request("GET", f"/api/v1/workflows/executions/{execution_id}/results")
|
|
253
455
|
return list(payload or [])
|
|
@@ -12,7 +12,13 @@ from urllib.parse import parse_qs, urlencode, urlparse
|
|
|
12
12
|
|
|
13
13
|
import httpx
|
|
14
14
|
|
|
15
|
-
from proteworks.config import
|
|
15
|
+
from proteworks.config import (
|
|
16
|
+
LOOPBACK_PORT,
|
|
17
|
+
LOOPBACK_REDIRECT_PATH,
|
|
18
|
+
ConfigStore,
|
|
19
|
+
Settings,
|
|
20
|
+
TokenStore,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
def pkce_pair() -> tuple[str, str]:
|
|
@@ -118,4 +124,7 @@ def login(settings: Settings, token_store: TokenStore, stderr) -> None:
|
|
|
118
124
|
if not access_token:
|
|
119
125
|
raise SystemExit("Login failed.")
|
|
120
126
|
token_store.set(access_token)
|
|
127
|
+
# Bind credentials to this API URL so a later PROTEWORKS_API_URL change cannot
|
|
128
|
+
# silently send the bearer token to a different host.
|
|
129
|
+
ConfigStore(settings.config_dir / "config.json").bind_api_url(settings.api_url)
|
|
121
130
|
print("Signed in.", file=stderr)
|
|
@@ -12,12 +12,13 @@ def list_remote(api: ApiClient, path: str, *, limit: int = 200) -> dict[str, Any
|
|
|
12
12
|
List files under a file-browser path.
|
|
13
13
|
|
|
14
14
|
Returns agent-safe fields only (includes file_id for future cloud submit).
|
|
15
|
-
|
|
15
|
+
Fetches a single manifest page of ``limit`` items (does not walk the whole
|
|
16
|
+
tree). Sets truncated=true when the API reports more remain.
|
|
16
17
|
"""
|
|
17
18
|
if limit < 1:
|
|
18
19
|
raise ValueError("limit must be at least 1")
|
|
19
20
|
try:
|
|
20
|
-
page = api.
|
|
21
|
+
page = api.manifest(path, 0, limit=limit)
|
|
21
22
|
except ApiError as exc:
|
|
22
23
|
return {
|
|
23
24
|
"ok": False,
|
|
@@ -29,8 +30,8 @@ def list_remote(api: ApiClient, path: str, *, limit: int = 200) -> dict[str, Any
|
|
|
29
30
|
}
|
|
30
31
|
raw_items = list(page.get("items") or [])
|
|
31
32
|
total = int(page.get("total") or len(raw_items))
|
|
32
|
-
truncated = len(raw_items)
|
|
33
|
-
items = [_normalize_item(item) for item in raw_items
|
|
33
|
+
truncated = total > len(raw_items)
|
|
34
|
+
items = [_normalize_item(item) for item in raw_items]
|
|
34
35
|
return {
|
|
35
36
|
"ok": True,
|
|
36
37
|
"items": items,
|