japanfold 1.0.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.
- japanfold-1.0.0/PKG-INFO +73 -0
- japanfold-1.0.0/README.md +62 -0
- japanfold-1.0.0/japanfold.egg-info/PKG-INFO +73 -0
- japanfold-1.0.0/japanfold.egg-info/SOURCES.txt +8 -0
- japanfold-1.0.0/japanfold.egg-info/dependency_links.txt +1 -0
- japanfold-1.0.0/japanfold.egg-info/entry_points.txt +2 -0
- japanfold-1.0.0/japanfold.egg-info/top_level.txt +1 -0
- japanfold-1.0.0/japanfold_cli.py +752 -0
- japanfold-1.0.0/pyproject.toml +24 -0
- japanfold-1.0.0/setup.cfg +4 -0
japanfold-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: japanfold
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Command-line client for JapanFold: protein structure prediction, binder design and embeddings over an HTTP API.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://japanfold.aiand.com
|
|
7
|
+
Project-URL: Documentation, https://japanfold.aiand.com/docs
|
|
8
|
+
Keywords: protein,structure-prediction,boltz,esmfold,binder-design,bioinformatics
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# japanfold
|
|
13
|
+
|
|
14
|
+
Command-line client for [JapanFold](https://japanfold.aiand.com), a hosted API for protein
|
|
15
|
+
structure prediction, binder design and protein embeddings. No GPU and no model weights on your
|
|
16
|
+
side; the models run on JapanFold's servers in Japan.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install japanfold
|
|
20
|
+
export JAPANFOLD_API_KEY=jf_live_... # from https://japanfold.aiand.com/account/token
|
|
21
|
+
japanfold fold --sequence MKTAYIAKQRQISFVKSHFSRQ --wait --out ./out
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It has no dependencies beyond the Python standard library (3.9+).
|
|
25
|
+
|
|
26
|
+
## Key and endpoint
|
|
27
|
+
|
|
28
|
+
Every call needs a key. Sign in at [japanfold.aiand.com/account/token](https://japanfold.aiand.com/account/token)
|
|
29
|
+
to get one; a new account comes with $100 of credit. The CLI reads it from `--api-key`, then
|
|
30
|
+
`$JAPANFOLD_API_KEY`, then `~/.config/japanfold/config.json` (written by `japanfold auth login`).
|
|
31
|
+
|
|
32
|
+
The API lives at `https://api.japanfold.aiand.com`. Set `JAPANFOLD_BASE_URL` or `--base-url` to
|
|
33
|
+
point somewhere else.
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
japanfold models # every model and design protocol
|
|
39
|
+
japanfold fold --sequence MKTAYIAK... --model boltz2 --wait --out ./out
|
|
40
|
+
japanfold design spec.yaml --model boltzgen --protocol nanobody-anything --wait --out ./out
|
|
41
|
+
japanfold design --model rfd3 --protocol rfd3-binder --structure target.pdb \
|
|
42
|
+
--contig 'A1-150,60-80' --wait --out ./out
|
|
43
|
+
japanfold design --model pxdesign --protocol pxdesign-binder --structure target.pdb \
|
|
44
|
+
--chains A --binder-length 80 --hotspots A64,A65,A66 --wait --out ./out
|
|
45
|
+
japanfold embed --sequence MKTAYIAK... --model esmc-600m --wait --out ./out
|
|
46
|
+
japanfold wait JOB_ID --out ./out # wait for a job and download it
|
|
47
|
+
japanfold credits # balance left on the key
|
|
48
|
+
japanfold jobs list
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`--wait` submits, waits for the job and downloads the results in one foreground command.
|
|
52
|
+
Without it a command prints the job id and returns; `japanfold wait JOB_ID` picks it up later
|
|
53
|
+
and is safe to re-run. `fold` is the same command as `predict`, and `wait` the same as `download`.
|
|
54
|
+
|
|
55
|
+
## For agents
|
|
56
|
+
|
|
57
|
+
`--json` on any command prints machine-readable JSON on stdout and keeps human messages on
|
|
58
|
+
stderr. `japanfold schema` prints the OpenAPI 3.1 contract. Exit codes say what went wrong
|
|
59
|
+
without parsing text:
|
|
60
|
+
|
|
61
|
+
| Code | Meaning |
|
|
62
|
+
|---|---|
|
|
63
|
+
| 0 | success |
|
|
64
|
+
| 1 | job failed or generic error |
|
|
65
|
+
| 2 | missing or invalid key |
|
|
66
|
+
| 3 | invalid input |
|
|
67
|
+
| 4 | not found |
|
|
68
|
+
| 5 | rate-limited or at capacity |
|
|
69
|
+
| 6 | server unreachable |
|
|
70
|
+
|
|
71
|
+
Agents that prefer plain HTTP can skip the CLI: the [docs](https://japanfold.aiand.com/docs/)
|
|
72
|
+
cover the REST API, and the agent skill lives at
|
|
73
|
+
[github.com/japanfold/japanfold](https://github.com/japanfold/japanfold).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# japanfold
|
|
2
|
+
|
|
3
|
+
Command-line client for [JapanFold](https://japanfold.aiand.com), a hosted API for protein
|
|
4
|
+
structure prediction, binder design and protein embeddings. No GPU and no model weights on your
|
|
5
|
+
side; the models run on JapanFold's servers in Japan.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install japanfold
|
|
9
|
+
export JAPANFOLD_API_KEY=jf_live_... # from https://japanfold.aiand.com/account/token
|
|
10
|
+
japanfold fold --sequence MKTAYIAKQRQISFVKSHFSRQ --wait --out ./out
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It has no dependencies beyond the Python standard library (3.9+).
|
|
14
|
+
|
|
15
|
+
## Key and endpoint
|
|
16
|
+
|
|
17
|
+
Every call needs a key. Sign in at [japanfold.aiand.com/account/token](https://japanfold.aiand.com/account/token)
|
|
18
|
+
to get one; a new account comes with $100 of credit. The CLI reads it from `--api-key`, then
|
|
19
|
+
`$JAPANFOLD_API_KEY`, then `~/.config/japanfold/config.json` (written by `japanfold auth login`).
|
|
20
|
+
|
|
21
|
+
The API lives at `https://api.japanfold.aiand.com`. Set `JAPANFOLD_BASE_URL` or `--base-url` to
|
|
22
|
+
point somewhere else.
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
japanfold models # every model and design protocol
|
|
28
|
+
japanfold fold --sequence MKTAYIAK... --model boltz2 --wait --out ./out
|
|
29
|
+
japanfold design spec.yaml --model boltzgen --protocol nanobody-anything --wait --out ./out
|
|
30
|
+
japanfold design --model rfd3 --protocol rfd3-binder --structure target.pdb \
|
|
31
|
+
--contig 'A1-150,60-80' --wait --out ./out
|
|
32
|
+
japanfold design --model pxdesign --protocol pxdesign-binder --structure target.pdb \
|
|
33
|
+
--chains A --binder-length 80 --hotspots A64,A65,A66 --wait --out ./out
|
|
34
|
+
japanfold embed --sequence MKTAYIAK... --model esmc-600m --wait --out ./out
|
|
35
|
+
japanfold wait JOB_ID --out ./out # wait for a job and download it
|
|
36
|
+
japanfold credits # balance left on the key
|
|
37
|
+
japanfold jobs list
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`--wait` submits, waits for the job and downloads the results in one foreground command.
|
|
41
|
+
Without it a command prints the job id and returns; `japanfold wait JOB_ID` picks it up later
|
|
42
|
+
and is safe to re-run. `fold` is the same command as `predict`, and `wait` the same as `download`.
|
|
43
|
+
|
|
44
|
+
## For agents
|
|
45
|
+
|
|
46
|
+
`--json` on any command prints machine-readable JSON on stdout and keeps human messages on
|
|
47
|
+
stderr. `japanfold schema` prints the OpenAPI 3.1 contract. Exit codes say what went wrong
|
|
48
|
+
without parsing text:
|
|
49
|
+
|
|
50
|
+
| Code | Meaning |
|
|
51
|
+
|---|---|
|
|
52
|
+
| 0 | success |
|
|
53
|
+
| 1 | job failed or generic error |
|
|
54
|
+
| 2 | missing or invalid key |
|
|
55
|
+
| 3 | invalid input |
|
|
56
|
+
| 4 | not found |
|
|
57
|
+
| 5 | rate-limited or at capacity |
|
|
58
|
+
| 6 | server unreachable |
|
|
59
|
+
|
|
60
|
+
Agents that prefer plain HTTP can skip the CLI: the [docs](https://japanfold.aiand.com/docs/)
|
|
61
|
+
cover the REST API, and the agent skill lives at
|
|
62
|
+
[github.com/japanfold/japanfold](https://github.com/japanfold/japanfold).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: japanfold
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Command-line client for JapanFold: protein structure prediction, binder design and embeddings over an HTTP API.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://japanfold.aiand.com
|
|
7
|
+
Project-URL: Documentation, https://japanfold.aiand.com/docs
|
|
8
|
+
Keywords: protein,structure-prediction,boltz,esmfold,binder-design,bioinformatics
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# japanfold
|
|
13
|
+
|
|
14
|
+
Command-line client for [JapanFold](https://japanfold.aiand.com), a hosted API for protein
|
|
15
|
+
structure prediction, binder design and protein embeddings. No GPU and no model weights on your
|
|
16
|
+
side; the models run on JapanFold's servers in Japan.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install japanfold
|
|
20
|
+
export JAPANFOLD_API_KEY=jf_live_... # from https://japanfold.aiand.com/account/token
|
|
21
|
+
japanfold fold --sequence MKTAYIAKQRQISFVKSHFSRQ --wait --out ./out
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It has no dependencies beyond the Python standard library (3.9+).
|
|
25
|
+
|
|
26
|
+
## Key and endpoint
|
|
27
|
+
|
|
28
|
+
Every call needs a key. Sign in at [japanfold.aiand.com/account/token](https://japanfold.aiand.com/account/token)
|
|
29
|
+
to get one; a new account comes with $100 of credit. The CLI reads it from `--api-key`, then
|
|
30
|
+
`$JAPANFOLD_API_KEY`, then `~/.config/japanfold/config.json` (written by `japanfold auth login`).
|
|
31
|
+
|
|
32
|
+
The API lives at `https://api.japanfold.aiand.com`. Set `JAPANFOLD_BASE_URL` or `--base-url` to
|
|
33
|
+
point somewhere else.
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
japanfold models # every model and design protocol
|
|
39
|
+
japanfold fold --sequence MKTAYIAK... --model boltz2 --wait --out ./out
|
|
40
|
+
japanfold design spec.yaml --model boltzgen --protocol nanobody-anything --wait --out ./out
|
|
41
|
+
japanfold design --model rfd3 --protocol rfd3-binder --structure target.pdb \
|
|
42
|
+
--contig 'A1-150,60-80' --wait --out ./out
|
|
43
|
+
japanfold design --model pxdesign --protocol pxdesign-binder --structure target.pdb \
|
|
44
|
+
--chains A --binder-length 80 --hotspots A64,A65,A66 --wait --out ./out
|
|
45
|
+
japanfold embed --sequence MKTAYIAK... --model esmc-600m --wait --out ./out
|
|
46
|
+
japanfold wait JOB_ID --out ./out # wait for a job and download it
|
|
47
|
+
japanfold credits # balance left on the key
|
|
48
|
+
japanfold jobs list
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`--wait` submits, waits for the job and downloads the results in one foreground command.
|
|
52
|
+
Without it a command prints the job id and returns; `japanfold wait JOB_ID` picks it up later
|
|
53
|
+
and is safe to re-run. `fold` is the same command as `predict`, and `wait` the same as `download`.
|
|
54
|
+
|
|
55
|
+
## For agents
|
|
56
|
+
|
|
57
|
+
`--json` on any command prints machine-readable JSON on stdout and keeps human messages on
|
|
58
|
+
stderr. `japanfold schema` prints the OpenAPI 3.1 contract. Exit codes say what went wrong
|
|
59
|
+
without parsing text:
|
|
60
|
+
|
|
61
|
+
| Code | Meaning |
|
|
62
|
+
|---|---|
|
|
63
|
+
| 0 | success |
|
|
64
|
+
| 1 | job failed or generic error |
|
|
65
|
+
| 2 | missing or invalid key |
|
|
66
|
+
| 3 | invalid input |
|
|
67
|
+
| 4 | not found |
|
|
68
|
+
| 5 | rate-limited or at capacity |
|
|
69
|
+
| 6 | server unreachable |
|
|
70
|
+
|
|
71
|
+
Agents that prefer plain HTTP can skip the CLI: the [docs](https://japanfold.aiand.com/docs/)
|
|
72
|
+
cover the REST API, and the agent skill lives at
|
|
73
|
+
[github.com/japanfold/japanfold](https://github.com/japanfold/japanfold).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
japanfold_cli
|
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""JapanFold CLI: biomolecular structure prediction and binder design.
|
|
3
|
+
|
|
4
|
+
A dependency-free (stdlib-only) client for the JapanFold ``/v1`` API. Designed
|
|
5
|
+
to be driven both by humans and by coding agents (Claude Code, Codex, Cursor,
|
|
6
|
+
Gemini CLI): every command takes ``--json`` for machine output, auth comes from
|
|
7
|
+
an env var for sandboxed agents, and ``--wait`` / ``wait`` run as a single
|
|
8
|
+
foreground command (submit, poll, download), so an agent never has to
|
|
9
|
+
background a process.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
import zipfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
__version__ = "1.0.0"
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.japanfold.aiand.com"
|
|
30
|
+
CONFIG_PATH = Path(os.environ.get("JAPANFOLD_CONFIG",
|
|
31
|
+
Path.home() / ".config" / "japanfold" / "config.json"))
|
|
32
|
+
TERMINAL = {"succeeded", "failed", "canceled"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --------------------------------------------------------------------------- #
|
|
36
|
+
# Config / auth
|
|
37
|
+
# --------------------------------------------------------------------------- #
|
|
38
|
+
_config_cache: dict | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_config() -> dict:
|
|
42
|
+
# Read once per process: resolve_api_key + resolve_base_url both consult it
|
|
43
|
+
# for a single command. _save_config invalidates it.
|
|
44
|
+
global _config_cache
|
|
45
|
+
if _config_cache is None:
|
|
46
|
+
try:
|
|
47
|
+
_config_cache = json.loads(CONFIG_PATH.read_text())
|
|
48
|
+
except (OSError, ValueError):
|
|
49
|
+
_config_cache = {}
|
|
50
|
+
return _config_cache
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _save_config(cfg: dict) -> None:
|
|
54
|
+
global _config_cache
|
|
55
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
tmp = CONFIG_PATH.with_suffix(".tmp")
|
|
57
|
+
tmp.write_text(json.dumps(cfg, indent=2))
|
|
58
|
+
tmp.chmod(0o600)
|
|
59
|
+
os.replace(tmp, CONFIG_PATH)
|
|
60
|
+
_config_cache = None # next read reflects the write
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_api_key(args) -> str | None:
|
|
64
|
+
return getattr(args, "api_key", None) or os.environ.get("JAPANFOLD_API_KEY") \
|
|
65
|
+
or _load_config().get("api_key")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_base_url(args) -> str:
|
|
69
|
+
return (getattr(args, "base_url", None) or os.environ.get("JAPANFOLD_BASE_URL")
|
|
70
|
+
or _load_config().get("base_url") or DEFAULT_BASE_URL).rstrip("/")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --------------------------------------------------------------------------- #
|
|
74
|
+
# HTTP
|
|
75
|
+
# --------------------------------------------------------------------------- #
|
|
76
|
+
# Meaningful exit codes so an agent can branch on the failure mode without
|
|
77
|
+
# parsing stderr. 0=ok; 1=job failed / generic; 2=auth; 3=invalid input/usage;
|
|
78
|
+
# 4=not found; 5=rate-limited/capacity; 6=unreachable; 130=interrupted.
|
|
79
|
+
EXIT_OK, EXIT_ERROR, EXIT_AUTH, EXIT_INPUT, EXIT_NOTFOUND, EXIT_RATE, EXIT_UNREACHABLE = 0, 1, 2, 3, 4, 5, 6
|
|
80
|
+
_STATUS_EXIT = {0: EXIT_UNREACHABLE, 400: EXIT_INPUT, 401: EXIT_AUTH, 403: EXIT_AUTH,
|
|
81
|
+
404: EXIT_NOTFOUND, 422: EXIT_INPUT, 429: EXIT_RATE}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Done(Exception):
|
|
85
|
+
"""Raised by a handler to exit with a specific code (e.g. a failed job)."""
|
|
86
|
+
def __init__(self, code):
|
|
87
|
+
self.code = code
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ApiError(Exception):
|
|
91
|
+
def __init__(self, status, body):
|
|
92
|
+
self.status = status
|
|
93
|
+
self.body = body
|
|
94
|
+
if isinstance(body, dict):
|
|
95
|
+
msg = body.get("title") or body.get("error") or str(body)
|
|
96
|
+
else:
|
|
97
|
+
msg = str(body)
|
|
98
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
99
|
+
super().__init__(f"{status}: {msg}" + (f": {detail}" if detail else ""))
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def exit_code(self):
|
|
103
|
+
return _STATUS_EXIT.get(self.status, EXIT_ERROR)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# A results bundle is the one response whose size is unbounded by the API, so it
|
|
107
|
+
# does not share the 120 s default. 900 s covers the 475 MB bundle measured on the
|
|
108
|
+
# service over a link as slow as 0.5 MB/s.
|
|
109
|
+
_ARCHIVE_TIMEOUT = 900.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _KeyStaysOnItsHost(urllib.request.HTTPRedirectHandler):
|
|
113
|
+
"""Drop the key when a redirect leaves the API's host.
|
|
114
|
+
|
|
115
|
+
urllib copies every header onto the next hop, so without this a redirect to
|
|
116
|
+
storage (an artifact's short-lived link) would carry the customer's key to
|
|
117
|
+
another host, which then refuses a request with two credentials. curl,
|
|
118
|
+
requests and httpx already behave this way.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
122
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
123
|
+
if new is not None and new.host != req.host:
|
|
124
|
+
new.remove_header("Authorization")
|
|
125
|
+
return new
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_open = urllib.request.build_opener(_KeyStaysOnItsHost).open
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _request(method, base, path, key, *, body=None, raw=False, timeout=120,
|
|
132
|
+
idempotency=None, sink=None):
|
|
133
|
+
url = base + path
|
|
134
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
135
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
136
|
+
# A real User-Agent: urllib's default ("Python-urllib/x.y") trips edge bot
|
|
137
|
+
# filters (e.g. Cloudflare 1010) that a named client clears.
|
|
138
|
+
req.add_header("User-Agent", f"japanfold-cli/{__version__}")
|
|
139
|
+
if key:
|
|
140
|
+
req.add_header("Authorization", f"Bearer {key}")
|
|
141
|
+
if data is not None:
|
|
142
|
+
req.add_header("Content-Type", "application/json")
|
|
143
|
+
if idempotency:
|
|
144
|
+
req.add_header("Idempotency-Key", idempotency)
|
|
145
|
+
try:
|
|
146
|
+
with _open(req, timeout=timeout) as resp:
|
|
147
|
+
if sink is not None:
|
|
148
|
+
# Stream to disk: a large bundle must not have to fit in memory.
|
|
149
|
+
sink.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
with open(sink, "wb") as fh:
|
|
151
|
+
shutil.copyfileobj(resp, fh, 1 << 20)
|
|
152
|
+
return resp.status, sink, dict(resp.headers)
|
|
153
|
+
payload = resp.read()
|
|
154
|
+
if raw:
|
|
155
|
+
return resp.status, payload, dict(resp.headers)
|
|
156
|
+
return resp.status, (json.loads(payload) if payload else {}), dict(resp.headers)
|
|
157
|
+
except urllib.error.HTTPError as e:
|
|
158
|
+
payload = e.read()
|
|
159
|
+
try:
|
|
160
|
+
parsed = json.loads(payload)
|
|
161
|
+
except ValueError:
|
|
162
|
+
parsed = {"title": payload.decode(errors="replace")[:200] or e.reason, "status": e.code}
|
|
163
|
+
raise ApiError(e.code, parsed) from None
|
|
164
|
+
except urllib.error.URLError as e:
|
|
165
|
+
raise ApiError(0, {"title": f"cannot reach {base}", "detail": str(e.reason)}) from None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --------------------------------------------------------------------------- #
|
|
169
|
+
# Output helpers
|
|
170
|
+
# --------------------------------------------------------------------------- #
|
|
171
|
+
def _emit(obj, as_json):
|
|
172
|
+
if as_json:
|
|
173
|
+
print(json.dumps(obj, indent=2))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _err(msg):
|
|
177
|
+
print(f"japanfold: {msg}", file=sys.stderr)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _progress(job):
|
|
181
|
+
p = job.get("progress")
|
|
182
|
+
bar = f"{int(p * 100):3d}%" if isinstance(p, (int, float)) else " · "
|
|
183
|
+
stage = job.get("stage") or ""
|
|
184
|
+
return f"[{bar}] {job.get('status'):9} {stage}".rstrip()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------------------- #
|
|
188
|
+
# Input normalization
|
|
189
|
+
# --------------------------------------------------------------------------- #
|
|
190
|
+
def _read_input(positional, sequence, inp):
|
|
191
|
+
"""Return a request field dict: {sequence|input|...}. Precedence: --sequence,
|
|
192
|
+
--input, then the positional (a file path or '-' for stdin)."""
|
|
193
|
+
if sequence:
|
|
194
|
+
return {"sequence": sequence}
|
|
195
|
+
if inp:
|
|
196
|
+
return {"input": inp}
|
|
197
|
+
if positional:
|
|
198
|
+
if positional == "-" or Path(positional).is_file():
|
|
199
|
+
text = sys.stdin.read() if positional == "-" else Path(positional).read_text()
|
|
200
|
+
# A file or pipe holding residues alone is a sequence, not a FASTA/YAML
|
|
201
|
+
# document: the API refuses bare residues sent as 'input'.
|
|
202
|
+
if text.strip() and "".join(text.split()).isalpha():
|
|
203
|
+
return {"sequence": "".join(text.split())}
|
|
204
|
+
return {"input": text}
|
|
205
|
+
# Not a file: treat a bare alphabetic token as a sequence, else raw input.
|
|
206
|
+
if positional.replace("\n", "").isalpha():
|
|
207
|
+
return {"sequence": positional}
|
|
208
|
+
return {"input": positional}
|
|
209
|
+
return {}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _read_text_arg(value):
|
|
213
|
+
"""A flag that accepts a file path, '-' for stdin, or the text itself."""
|
|
214
|
+
if not value:
|
|
215
|
+
return value
|
|
216
|
+
if value == "-":
|
|
217
|
+
return sys.stdin.read()
|
|
218
|
+
p = Path(value)
|
|
219
|
+
if p.is_file():
|
|
220
|
+
return p.read_text()
|
|
221
|
+
return value
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _collect_params(pairs, **explicit):
|
|
225
|
+
params = {}
|
|
226
|
+
for k, v in explicit.items():
|
|
227
|
+
if v is not None:
|
|
228
|
+
params[k] = v
|
|
229
|
+
for pair in pairs or []:
|
|
230
|
+
if "=" not in pair:
|
|
231
|
+
raise SystemExit(f"--param must be key=value, got: {pair}")
|
|
232
|
+
k, v = pair.split("=", 1)
|
|
233
|
+
# coerce simple scalars
|
|
234
|
+
if v.lower() in ("true", "false"):
|
|
235
|
+
v = v.lower() == "true"
|
|
236
|
+
else:
|
|
237
|
+
try:
|
|
238
|
+
v = int(v)
|
|
239
|
+
except ValueError:
|
|
240
|
+
try:
|
|
241
|
+
v = float(v)
|
|
242
|
+
except ValueError:
|
|
243
|
+
pass
|
|
244
|
+
params[k] = v
|
|
245
|
+
return params
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --------------------------------------------------------------------------- #
|
|
249
|
+
# Poll + download
|
|
250
|
+
# --------------------------------------------------------------------------- #
|
|
251
|
+
def _poll_until_done(base, key, job_id, *, quiet=False, interval=5.0, max_interval=15.0):
|
|
252
|
+
last = None
|
|
253
|
+
while True:
|
|
254
|
+
_, job, _ = _request("GET", base, f"/v1/jobs/{job_id}", key)
|
|
255
|
+
line = _progress(job)
|
|
256
|
+
if not quiet and line != last:
|
|
257
|
+
print(f" {job_id[:8]} {line}", file=sys.stderr)
|
|
258
|
+
last = line
|
|
259
|
+
if job.get("status") in TERMINAL:
|
|
260
|
+
return job
|
|
261
|
+
time.sleep(interval)
|
|
262
|
+
interval = min(max_interval, interval * 1.3)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _download(base, key, job_id, out_dir, *, name=None):
|
|
266
|
+
"""Fetch the results bundle and extract it. Resume-friendly: skips if already
|
|
267
|
+
extracted. Returns the output directory path."""
|
|
268
|
+
dest = Path(out_dir) / (name or job_id)
|
|
269
|
+
marker = dest / ".japanfold-complete"
|
|
270
|
+
if marker.exists():
|
|
271
|
+
return dest
|
|
272
|
+
# A results bundle can be hundreds of MB (475 MB measured on the service), so
|
|
273
|
+
# the archive gets its own long timeout and lands on disk rather than in
|
|
274
|
+
# memory. The shared 120 s default turned a slow link into the misleading
|
|
275
|
+
# "no results to download".
|
|
276
|
+
tmp = dest.with_suffix(".zip.part")
|
|
277
|
+
try:
|
|
278
|
+
_, blob, _ = _request("GET", base, f"/v1/jobs/{job_id}/archive", key, raw=True,
|
|
279
|
+
timeout=_ARCHIVE_TIMEOUT, sink=tmp)
|
|
280
|
+
except ApiError as e:
|
|
281
|
+
tmp.unlink(missing_ok=True)
|
|
282
|
+
raise SystemExit(f"no results to download: {e}")
|
|
283
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
284
|
+
tmp.unlink(missing_ok=True)
|
|
285
|
+
raise SystemExit(f"results download failed after {_ARCHIVE_TIMEOUT:.0f}s: {e}")
|
|
286
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
with zipfile.ZipFile(blob) as z:
|
|
288
|
+
for m in z.namelist():
|
|
289
|
+
# Skip engine bookkeeping files; keep structures + results.json only.
|
|
290
|
+
if m.endswith((".lock", ".tmp", ".bak")):
|
|
291
|
+
continue
|
|
292
|
+
z.extract(m, dest)
|
|
293
|
+
tmp.unlink(missing_ok=True)
|
|
294
|
+
# Persist the final job record + the /v1 results manifest under distinct names
|
|
295
|
+
# so they never clobber the engine's own results.json inside the bundle.
|
|
296
|
+
_, job, _ = _request("GET", base, f"/v1/jobs/{job_id}", key)
|
|
297
|
+
(dest / "job.json").write_text(json.dumps(job, indent=2))
|
|
298
|
+
try:
|
|
299
|
+
_, res, _ = _request("GET", base, f"/v1/jobs/{job_id}/results", key)
|
|
300
|
+
(dest / "japanfold_manifest.json").write_text(json.dumps(res, indent=2))
|
|
301
|
+
except ApiError:
|
|
302
|
+
pass
|
|
303
|
+
marker.write_text("ok")
|
|
304
|
+
return dest
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --------------------------------------------------------------------------- #
|
|
308
|
+
# Commands
|
|
309
|
+
# --------------------------------------------------------------------------- #
|
|
310
|
+
def _money(amount: float, currency: str = "usd") -> str:
|
|
311
|
+
"""An amount the way the service renders it: two significant digits, never
|
|
312
|
+
fewer than two decimals and never more than six.
|
|
313
|
+
|
|
314
|
+
Six is one micro, where the ledger itself stops, so nothing non-zero reads
|
|
315
|
+
as $0.00 -- and sub-cent is ordinary here, because a twenty-second fold
|
|
316
|
+
costs a fraction of a cent. The rule lives in ``japanfold/pricing.py``; this
|
|
317
|
+
file ships standalone with no dependencies, so it carries the five lines instead.
|
|
318
|
+
"""
|
|
319
|
+
a = abs(amount)
|
|
320
|
+
digits = 2 if a == 0 else max(2, min(6, 1 - math.floor(math.log10(a))))
|
|
321
|
+
body, sign = f"{a:,.{digits}f}", "-" if amount < 0 else ""
|
|
322
|
+
return f"{sign}${body}" if currency.lower() == "usd" else f"{sign}{body} {currency.upper()}"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
TOKEN_PAGE = "https://japanfold.aiand.com/account/token"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _account_line(acct: dict) -> str:
|
|
329
|
+
"""The balance, in the account's own currency, and what an hour costs."""
|
|
330
|
+
cur = acct.get("currency") or "usd"
|
|
331
|
+
per_hour = (acct.get("rate") or {}).get("per_hour")
|
|
332
|
+
rate = f" at {_money(per_hour, cur)} per processor hour" if per_hour else ""
|
|
333
|
+
return f"{_money(acct.get('balance') or 0, cur)} left{rate}."
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def cmd_auth(args):
|
|
337
|
+
if args.auth_cmd == "login":
|
|
338
|
+
key = args.api_key or os.environ.get("JAPANFOLD_API_KEY")
|
|
339
|
+
if not key and sys.stdin.isatty():
|
|
340
|
+
key = input(f"API key from {TOKEN_PAGE}: ").strip()
|
|
341
|
+
if not key:
|
|
342
|
+
_err(f"no key provided (pass --api-key, set JAPANFOLD_API_KEY, or run interactively). "
|
|
343
|
+
f"Get one at {TOKEN_PAGE}")
|
|
344
|
+
return EXIT_AUTH
|
|
345
|
+
base = resolve_base_url(args)
|
|
346
|
+
try:
|
|
347
|
+
_request("GET", base, "/v1/jobs?limit=1", key)
|
|
348
|
+
except ApiError as e:
|
|
349
|
+
_err(f"key rejected by {base}: {e}")
|
|
350
|
+
return EXIT_AUTH
|
|
351
|
+
cfg = _load_config()
|
|
352
|
+
cfg["api_key"] = key
|
|
353
|
+
cfg["base_url"] = base
|
|
354
|
+
_save_config(cfg)
|
|
355
|
+
print(f"Logged in to {base}. Credentials saved to {CONFIG_PATH}.")
|
|
356
|
+
return 0
|
|
357
|
+
if args.auth_cmd == "status":
|
|
358
|
+
key = resolve_api_key(args)
|
|
359
|
+
base = resolve_base_url(args)
|
|
360
|
+
if not key:
|
|
361
|
+
_err(f"no key. Get one at {TOKEN_PAGE}, then `japanfold auth login`.")
|
|
362
|
+
return EXIT_AUTH
|
|
363
|
+
try:
|
|
364
|
+
_, acct, _ = _request("GET", base, "/v1/account", key)
|
|
365
|
+
except ApiError as e:
|
|
366
|
+
print(f"Key present but not valid at {base}: {e}")
|
|
367
|
+
return EXIT_AUTH
|
|
368
|
+
if args.json:
|
|
369
|
+
_emit(acct, True)
|
|
370
|
+
return EXIT_OK
|
|
371
|
+
print(f"{base} (key {key[:12]}…)")
|
|
372
|
+
print(_account_line(acct))
|
|
373
|
+
return EXIT_OK
|
|
374
|
+
if args.auth_cmd == "logout":
|
|
375
|
+
cfg = _load_config()
|
|
376
|
+
cfg.pop("api_key", None)
|
|
377
|
+
_save_config(cfg)
|
|
378
|
+
print("Logged out.")
|
|
379
|
+
return EXIT_OK
|
|
380
|
+
return EXIT_INPUT
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def cmd_models(args):
|
|
384
|
+
base = resolve_base_url(args)
|
|
385
|
+
_, data, _ = _request("GET", base, "/v1/models", resolve_api_key(args))
|
|
386
|
+
if args.json:
|
|
387
|
+
_emit(data, True)
|
|
388
|
+
return 0
|
|
389
|
+
# A family card carries its variants in `checkpoints`, and those are the ids
|
|
390
|
+
# you actually pass to --model. Printing cards alone hid esmfold2-fast,
|
|
391
|
+
# opendde-abag and every ESMC/SaProt size except the first, so `japanfold
|
|
392
|
+
# models` did not list esmc-600m -- the default `japanfold embed --help`
|
|
393
|
+
# tells you to look here for. Nest them, the way design protocols already are.
|
|
394
|
+
def print_family(m):
|
|
395
|
+
cps = m.get("checkpoints") or []
|
|
396
|
+
if not cps:
|
|
397
|
+
print(f" {m['id']:16} {m.get('tagline', '')}")
|
|
398
|
+
return
|
|
399
|
+
# The card is named after its first checkpoint, so printing the card id
|
|
400
|
+
# and then the checkpoints would print one id twice. Head the group with
|
|
401
|
+
# the family name and indent every id under it -- what you pass --model
|
|
402
|
+
# is always the indented thing.
|
|
403
|
+
print(f" {m.get('name', m['id']):16} {m.get('tagline', '')}")
|
|
404
|
+
for cp in cps:
|
|
405
|
+
print(f" {cp['id']:24} {cp.get('name', cp.get('label', ''))}")
|
|
406
|
+
|
|
407
|
+
print("Models:")
|
|
408
|
+
for m in data["models"]:
|
|
409
|
+
print_family(m)
|
|
410
|
+
# Grouped by design model, because that is the choice a user makes first:
|
|
411
|
+
# the protocol only exists inside one of the three models.
|
|
412
|
+
print("Design models and their protocols:")
|
|
413
|
+
dms = data.get("design_models") or [{"id": e, "name": e} for e in
|
|
414
|
+
dict.fromkeys(p.get("engine") for p in data["protocols"])]
|
|
415
|
+
for dm in dms:
|
|
416
|
+
tag = dm.get("tagline", "")
|
|
417
|
+
print(f" {dm['id']:12} {dm.get('name', dm['id'])}{' ' + tag if tag else ''}")
|
|
418
|
+
for p in data["protocols"]:
|
|
419
|
+
if p.get("engine") == dm["id"]:
|
|
420
|
+
print(f" {p['id']:24} {p.get('name', '')}")
|
|
421
|
+
print("Embedding models:")
|
|
422
|
+
for m in data.get("embed_models", []):
|
|
423
|
+
print_family(m)
|
|
424
|
+
return 0
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def cmd_schema(args):
|
|
428
|
+
"""Print the OpenAPI 3.1 contract (agent/tool introspection)."""
|
|
429
|
+
base = resolve_base_url(args)
|
|
430
|
+
_, spec, _ = _request("GET", base, "/v1/openapi.json", None)
|
|
431
|
+
print(json.dumps(spec, indent=2))
|
|
432
|
+
return 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def cmd_predict(args):
|
|
436
|
+
base, key = resolve_base_url(args), resolve_api_key(args)
|
|
437
|
+
fields = _read_input(args.input_pos, args.sequence, args.input)
|
|
438
|
+
if not fields:
|
|
439
|
+
_err("provide an input: a FASTA/YAML file, '-', --sequence, or --input")
|
|
440
|
+
return EXIT_INPUT
|
|
441
|
+
params = _collect_params(args.param, use_msa_server=args.use_msa_server,
|
|
442
|
+
fast=args.fast,
|
|
443
|
+
recycling_steps=args.recycling_steps,
|
|
444
|
+
sampling_steps=args.sampling_steps,
|
|
445
|
+
diffusion_samples=args.diffusion_samples,
|
|
446
|
+
output_format=args.output_format,
|
|
447
|
+
seed=args.seed, write_pae=args.write_pae,
|
|
448
|
+
use_tfg_guidance=args.use_tfg_guidance)
|
|
449
|
+
body = {"model": args.model, "name": args.name, "params": params, **fields}
|
|
450
|
+
_, job, _ = _request("POST", base, "/v1/predictions", key, body=body,
|
|
451
|
+
idempotency=args.idempotency_key)
|
|
452
|
+
return _after_submit(base, key, job, args)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def cmd_design(args):
|
|
456
|
+
base, key = resolve_base_url(args), resolve_api_key(args)
|
|
457
|
+
params = _collect_params(args.param, num_designs=args.num_designs,
|
|
458
|
+
budget=args.budget, num_timesteps=args.num_timesteps,
|
|
459
|
+
n_step=args.n_step, fast=args.fast, seed=args.seed)
|
|
460
|
+
body = {"model": args.model, "protocol": args.protocol, "name": args.name, "params": params}
|
|
461
|
+
# Three input shapes, one per design model: BoltzGen takes a YAML spec,
|
|
462
|
+
# RFdiffusion3 a target structure plus a contig, PXDesign a target structure plus
|
|
463
|
+
# the chains to condition on. Route on what the caller supplied; the server is the
|
|
464
|
+
# authority on which the protocol needs.
|
|
465
|
+
if args.structure or args.contig or args.chains or args.binder_length:
|
|
466
|
+
body["structure"] = _read_text_arg(args.structure)
|
|
467
|
+
for key, val in (("contig", args.contig), ("chains", args.chains),
|
|
468
|
+
("binder_length", args.binder_length), ("hotspots", args.hotspots)):
|
|
469
|
+
if val is not None:
|
|
470
|
+
body[key] = val
|
|
471
|
+
else:
|
|
472
|
+
spec = _read_input(args.input_pos, None, args.spec).get("input")
|
|
473
|
+
if not spec:
|
|
474
|
+
_err("provide a design spec (a YAML file, '-', or --spec), "
|
|
475
|
+
"--structure and --contig for an RFdiffusion3 protocol, or "
|
|
476
|
+
"--structure, --chains and --binder-length for a PXDesign one")
|
|
477
|
+
return EXIT_INPUT
|
|
478
|
+
body["spec"] = spec
|
|
479
|
+
_, job, _ = _request("POST", base, "/v1/designs", key, body=body)
|
|
480
|
+
return _after_submit(base, key, job, args)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def cmd_embed(args):
|
|
484
|
+
base, key = resolve_base_url(args), resolve_api_key(args)
|
|
485
|
+
fields = _read_input(args.input_pos, args.sequence, args.input)
|
|
486
|
+
if not fields:
|
|
487
|
+
_err("provide an input: a FASTA/YAML file, '-', --sequence, or --input")
|
|
488
|
+
return EXIT_INPUT
|
|
489
|
+
params = _collect_params(args.param, pool=args.pool, format=args.format,
|
|
490
|
+
fast=args.fast)
|
|
491
|
+
body = {"model": args.model, "name": args.name, "params": params, **fields}
|
|
492
|
+
_, job, _ = _request("POST", base, "/v1/embeddings", key, body=body,
|
|
493
|
+
idempotency=args.idempotency_key)
|
|
494
|
+
return _after_submit(base, key, job, args)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _after_submit(base, key, job, args):
|
|
498
|
+
job_id = job["id"]
|
|
499
|
+
_err(f"Submitted job {job_id} ({job.get('kind')}, {job.get('model') or job.get('protocol')})")
|
|
500
|
+
if not args.wait:
|
|
501
|
+
_emit(job, args.json)
|
|
502
|
+
if not args.json:
|
|
503
|
+
print(job_id)
|
|
504
|
+
return 0
|
|
505
|
+
return _finish(base, key, job_id, args)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _finish(base, key, job_id, args):
|
|
509
|
+
"""Poll a job to completion, download its results, and emit. Shared by
|
|
510
|
+
``predict/design --wait`` and the standalone ``download`` command."""
|
|
511
|
+
final = _poll_until_done(base, key, job_id, quiet=args.json)
|
|
512
|
+
if final.get("status") != "succeeded":
|
|
513
|
+
_err(f"job {final.get('status')}: {final.get('error') or 'see logs'}")
|
|
514
|
+
_emit(final, args.json)
|
|
515
|
+
raise Done(1)
|
|
516
|
+
dest = _download(base, key, job_id, args.out or ".", name=args.name)
|
|
517
|
+
_err(f"Done. Results in {dest}")
|
|
518
|
+
_emit({**final, "output_dir": str(dest)}, args.json)
|
|
519
|
+
if not args.json:
|
|
520
|
+
print(str(dest))
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def cmd_jobs(args):
|
|
525
|
+
base, key = resolve_base_url(args), resolve_api_key(args)
|
|
526
|
+
if args.jobs_cmd == "list":
|
|
527
|
+
path = f"/v1/jobs?limit={args.limit}" + (f"&cursor={args.cursor}" if args.cursor else "")
|
|
528
|
+
_, data, _ = _request("GET", base, path, key)
|
|
529
|
+
if args.json:
|
|
530
|
+
_emit(data, True)
|
|
531
|
+
return 0
|
|
532
|
+
for j in data["data"]:
|
|
533
|
+
print(f" {j['id']} {j['status']:9} {j.get('kind'):7} {j.get('name', '')}")
|
|
534
|
+
if data.get("has_more"):
|
|
535
|
+
print(f" … more (next cursor: {data['next_cursor']})", file=sys.stderr)
|
|
536
|
+
return 0
|
|
537
|
+
if args.jobs_cmd == "get":
|
|
538
|
+
_, job, _ = _request("GET", base, f"/v1/jobs/{args.job_id}", key)
|
|
539
|
+
if args.json:
|
|
540
|
+
_emit(job, True)
|
|
541
|
+
else:
|
|
542
|
+
print(f"{job['id']} {_progress(job)}")
|
|
543
|
+
return 0 if job.get("status") != "failed" else 1
|
|
544
|
+
if args.jobs_cmd == "cancel":
|
|
545
|
+
_, res, _ = _request("POST", base, f"/v1/jobs/{args.job_id}/cancel", key)
|
|
546
|
+
_emit(res, args.json)
|
|
547
|
+
if not args.json:
|
|
548
|
+
print("canceled" if res.get("canceled") else "not canceled")
|
|
549
|
+
return 0
|
|
550
|
+
return EXIT_INPUT
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def cmd_credits(args):
|
|
554
|
+
args.auth_cmd = "status"
|
|
555
|
+
return cmd_auth(args)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def cmd_download(args):
|
|
559
|
+
base, key = resolve_base_url(args), resolve_api_key(args)
|
|
560
|
+
return _finish(base, key, args.job_id, args)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def cmd_logs(args):
|
|
564
|
+
base, key = resolve_base_url(args), resolve_api_key(args)
|
|
565
|
+
_, blob, _ = _request("GET", base, f"/v1/jobs/{args.job_id}/logs", key, raw=True)
|
|
566
|
+
sys.stdout.write(blob.decode(errors="replace"))
|
|
567
|
+
return 0
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# --------------------------------------------------------------------------- #
|
|
571
|
+
# Argument parsing
|
|
572
|
+
# --------------------------------------------------------------------------- #
|
|
573
|
+
EPILOG = f"""\
|
|
574
|
+
key: JAPANFOLD_API_KEY (or --api-key, or `japanfold auth login`).
|
|
575
|
+
Get one at {TOKEN_PAGE}
|
|
576
|
+
base URL: {DEFAULT_BASE_URL} (override with JAPANFOLD_BASE_URL or --base-url)
|
|
577
|
+
|
|
578
|
+
examples:
|
|
579
|
+
japanfold fold --sequence MKTAYIAKQR... --wait --out ./out
|
|
580
|
+
japanfold design spec.yaml --model boltzgen --protocol nanobody-anything --wait --out ./out
|
|
581
|
+
japanfold embed --sequence MKTAYIAKQR... --wait --out ./out
|
|
582
|
+
japanfold wait JOB_ID --out ./out
|
|
583
|
+
japanfold credits
|
|
584
|
+
|
|
585
|
+
`japanfold models` lists every model and design protocol; `japanfold <command> -h`
|
|
586
|
+
shows its flags. Exit codes: 0 ok, 1 job failed, 2 auth, 3 bad input, 4 not found,
|
|
587
|
+
5 rate-limited, 6 unreachable.
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
def build_parser():
|
|
591
|
+
# Global flags live on a parent parser (with SUPPRESS defaults) added to the
|
|
592
|
+
# top parser AND every subparser, so `--json`/`--base-url`/`--api-key` work in
|
|
593
|
+
# any position, before OR after the subcommand, without clobbering.
|
|
594
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
595
|
+
common.add_argument("--base-url", default=argparse.SUPPRESS,
|
|
596
|
+
help=f"API base URL (default {DEFAULT_BASE_URL}).")
|
|
597
|
+
common.add_argument("--api-key", default=argparse.SUPPRESS, help="Override the API key.")
|
|
598
|
+
common.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
|
|
599
|
+
help="Machine-readable JSON on stdout.")
|
|
600
|
+
|
|
601
|
+
def add(subparsers, name, **kw):
|
|
602
|
+
return subparsers.add_parser(name, parents=[common], **kw)
|
|
603
|
+
|
|
604
|
+
p = argparse.ArgumentParser(prog="japanfold", parents=[common],
|
|
605
|
+
description=__doc__.splitlines()[0], epilog=EPILOG,
|
|
606
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
607
|
+
p.add_argument("--version", action="version", version=f"japanfold {__version__}")
|
|
608
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
609
|
+
|
|
610
|
+
a = add(sub, "auth", help="Save a key / check the balance / log out.")
|
|
611
|
+
asub = a.add_subparsers(dest="auth_cmd", required=True)
|
|
612
|
+
add(asub, "login")
|
|
613
|
+
add(asub, "status")
|
|
614
|
+
add(asub, "logout")
|
|
615
|
+
|
|
616
|
+
add(sub, "models", help="List available models, protocols and limits.")
|
|
617
|
+
add(sub, "schema", help="Print the OpenAPI 3.1 contract (for tooling/agents).")
|
|
618
|
+
|
|
619
|
+
add(sub, "credits", help="Show the balance left on your key (same as `auth status`).")
|
|
620
|
+
|
|
621
|
+
pr = add(sub, "predict", aliases=["fold"], help="Predict a structure.")
|
|
622
|
+
pr.add_argument("input_pos", nargs="?", metavar="INPUT", help="FASTA/YAML file, or '-' for stdin.")
|
|
623
|
+
pr.add_argument("--sequence", help="A single protein sequence (convenience).")
|
|
624
|
+
pr.add_argument("--input", help="Inline FASTA/YAML string.")
|
|
625
|
+
pr.add_argument("--model", default="boltz2",
|
|
626
|
+
help="Prediction model id (default boltz2). `japanfold models` lists them.")
|
|
627
|
+
pr.add_argument("--name")
|
|
628
|
+
# Both directions, or the CLI can only ever agree with the server default:
|
|
629
|
+
# --use-msa-server had no opposite, so a CLI user could not fold Protenix-v2 /
|
|
630
|
+
# OpenFold3 / OpenBind-0 / RoseTTAFold3 / OpenDDE single-sequence at all,
|
|
631
|
+
# though the web form and the raw API both offer it. Same for --fast on all
|
|
632
|
+
# three verbs. Unset stays absent from the body, which is what makes an
|
|
633
|
+
# omitted param take the catalog default.
|
|
634
|
+
pr.add_argument("--fast", action=argparse.BooleanOptionalAction, default=None,
|
|
635
|
+
help="Higher throughput, may be slightly less accurate. Off unless given.")
|
|
636
|
+
pr.add_argument("--use-msa-server", action=argparse.BooleanOptionalAction,
|
|
637
|
+
default=None, dest="use_msa_server",
|
|
638
|
+
help="Build an MSA for the input. --no-use-msa-server folds single-sequence.")
|
|
639
|
+
pr.add_argument("--recycling-steps", type=int, dest="recycling_steps",
|
|
640
|
+
help="Omit it and the model uses its own recommended value.")
|
|
641
|
+
pr.add_argument("--sampling-steps", type=int, dest="sampling_steps",
|
|
642
|
+
help="Omit it and the model uses its own recommended value.")
|
|
643
|
+
pr.add_argument("--diffusion-samples", type=int, dest="diffusion_samples",
|
|
644
|
+
help="Structures to sample per target.")
|
|
645
|
+
pr.add_argument("--output-format", choices=["cif", "pdb"], dest="output_format")
|
|
646
|
+
pr.add_argument("--seed", type=int, help="Diffusion noise seed. Default 0.")
|
|
647
|
+
pr.add_argument("--write-pae", action=argparse.BooleanOptionalAction, default=None,
|
|
648
|
+
dest="write_pae",
|
|
649
|
+
help="Also return the PAE matrix (models with the pae cap).")
|
|
650
|
+
pr.add_argument("--use-tfg-guidance", action=argparse.BooleanOptionalAction, default=None,
|
|
651
|
+
dest="use_tfg_guidance",
|
|
652
|
+
help="OpenDDE: steer sampling with the input's constraint: block "
|
|
653
|
+
"(contact pairs or epitope residues).")
|
|
654
|
+
pr.add_argument("--param", action="append", help="Extra param key=value (repeatable).")
|
|
655
|
+
pr.add_argument("--idempotency-key", dest="idempotency_key")
|
|
656
|
+
pr.add_argument("--wait", action="store_true", help="Poll to completion and download results.")
|
|
657
|
+
pr.add_argument("--out", help="Output directory for --wait (default: cwd).")
|
|
658
|
+
|
|
659
|
+
de = add(sub, "design",
|
|
660
|
+
help="Design binders/proteins (BoltzGen, RFdiffusion3 or PXDesign).")
|
|
661
|
+
de.add_argument("input_pos", nargs="?", metavar="SPEC", help="YAML design spec file, or '-'.")
|
|
662
|
+
de.add_argument("--spec", help="Inline YAML design spec (BoltzGen protocols).")
|
|
663
|
+
de.add_argument("--model", dest="model",
|
|
664
|
+
help="Design model: boltzgen, rfd3 or pxdesign. Must match the protocol; "
|
|
665
|
+
"`japanfold models` groups the protocols by model.")
|
|
666
|
+
de.add_argument("--protocol", help="Design protocol id. `japanfold models` lists them.")
|
|
667
|
+
de.add_argument("--structure", help="Target structure (PDB/mmCIF file, '-' for stdin, or the "
|
|
668
|
+
"text itself). RFdiffusion3 and PXDesign protocols.")
|
|
669
|
+
de.add_argument("--contig", help="RFdiffusion3 contig, e.g. 'A1-150,60-80': what stays fixed "
|
|
670
|
+
"and what gets designed.")
|
|
671
|
+
de.add_argument("--chains", help="Target chain ids to design against, e.g. 'A' or 'A,B'. "
|
|
672
|
+
"PXDesign protocols only.")
|
|
673
|
+
de.add_argument("--binder-length", type=int, dest="binder_length",
|
|
674
|
+
help="Residues in the designed binder, 8-200 (PXDesign).")
|
|
675
|
+
de.add_argument("--hotspots", help="Target residues to aim the binder at, e.g. 'A64,A65,A66' "
|
|
676
|
+
"(bare numbers when one chain is named). PXDesign, optional.")
|
|
677
|
+
de.add_argument("--name")
|
|
678
|
+
de.add_argument("--num-designs", type=int, dest="num_designs")
|
|
679
|
+
de.add_argument("--budget", type=int, help="Ranked designs to keep (BoltzGen).")
|
|
680
|
+
de.add_argument("--num-timesteps", type=int, dest="num_timesteps",
|
|
681
|
+
help="Diffusion steps per design (RFdiffusion3).")
|
|
682
|
+
de.add_argument("--n-step", type=int, dest="n_step",
|
|
683
|
+
help="Diffusion steps per design (PXDesign).")
|
|
684
|
+
de.add_argument("--fast", action=argparse.BooleanOptionalAction, default=None,
|
|
685
|
+
help="Higher throughput, may be slightly less accurate. Off unless given.")
|
|
686
|
+
de.add_argument("--seed", type=int,
|
|
687
|
+
help="Noise seed: the same seed and input give the same designs. "
|
|
688
|
+
"BoltzGen draws fresh when omitted; RFdiffusion3 and PXDesign use 42.")
|
|
689
|
+
de.add_argument("--param", action="append")
|
|
690
|
+
de.add_argument("--wait", action="store_true")
|
|
691
|
+
de.add_argument("--out")
|
|
692
|
+
|
|
693
|
+
em = add(sub, "embed", help="Compute protein-language-model embeddings.")
|
|
694
|
+
em.add_argument("input_pos", nargs="?", metavar="INPUT", help="FASTA/YAML file, or '-' for stdin.")
|
|
695
|
+
em.add_argument("--sequence", help="A single protein sequence (convenience).")
|
|
696
|
+
em.add_argument("--input", help="Inline FASTA/YAML string.")
|
|
697
|
+
em.add_argument("--model", default="esmc-600m",
|
|
698
|
+
help="Embedding model id (default esmc-600m). `japanfold models` lists them.")
|
|
699
|
+
em.add_argument("--pool", default="mean", choices=["mean", "max", "cls"])
|
|
700
|
+
em.add_argument("--format", default="npz", choices=["npz", "parquet"], dest="format")
|
|
701
|
+
em.add_argument("--name")
|
|
702
|
+
em.add_argument("--fast", action=argparse.BooleanOptionalAction, default=None)
|
|
703
|
+
em.add_argument("--param", action="append", help="Extra param key=value (repeatable).")
|
|
704
|
+
em.add_argument("--idempotency-key", dest="idempotency_key")
|
|
705
|
+
em.add_argument("--wait", action="store_true", help="Poll to completion and download results.")
|
|
706
|
+
em.add_argument("--out", help="Output directory for --wait (default: cwd).")
|
|
707
|
+
|
|
708
|
+
j = add(sub, "jobs", help="List / inspect / cancel jobs.")
|
|
709
|
+
jsub = j.add_subparsers(dest="jobs_cmd", required=True)
|
|
710
|
+
jl = add(jsub, "list")
|
|
711
|
+
jl.add_argument("--limit", type=int, default=20)
|
|
712
|
+
jl.add_argument("--cursor")
|
|
713
|
+
add(jsub, "get").add_argument("job_id")
|
|
714
|
+
add(jsub, "cancel").add_argument("job_id")
|
|
715
|
+
|
|
716
|
+
dl = add(sub, "download", aliases=["wait"],
|
|
717
|
+
help="Wait for a job to finish and download its results.")
|
|
718
|
+
dl.add_argument("job_id")
|
|
719
|
+
dl.add_argument("--out")
|
|
720
|
+
dl.add_argument("--name", help="Subdirectory name (default: job id).")
|
|
721
|
+
|
|
722
|
+
lg = add(sub, "logs", help="Print a job's run log.")
|
|
723
|
+
lg.add_argument("job_id")
|
|
724
|
+
return p
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def main(argv=None):
|
|
728
|
+
args = build_parser().parse_args(argv)
|
|
729
|
+
# Materialize SUPPRESS-defaulted globals so handlers can read them freely.
|
|
730
|
+
args.json = getattr(args, "json", False)
|
|
731
|
+
args.base_url = getattr(args, "base_url", None)
|
|
732
|
+
args.api_key = getattr(args, "api_key", None)
|
|
733
|
+
if not hasattr(args, "name"):
|
|
734
|
+
args.name = None
|
|
735
|
+
handlers = {"auth": cmd_auth, "credits": cmd_credits, "models": cmd_models,
|
|
736
|
+
"predict": cmd_predict, "fold": cmd_predict, "design": cmd_design,
|
|
737
|
+
"embed": cmd_embed, "jobs": cmd_jobs, "download": cmd_download,
|
|
738
|
+
"wait": cmd_download, "logs": cmd_logs, "schema": cmd_schema}
|
|
739
|
+
try:
|
|
740
|
+
return handlers[args.cmd](args) or EXIT_OK
|
|
741
|
+
except Done as e:
|
|
742
|
+
return e.code
|
|
743
|
+
except ApiError as e:
|
|
744
|
+
_err(str(e))
|
|
745
|
+
return e.exit_code
|
|
746
|
+
except KeyboardInterrupt:
|
|
747
|
+
_err("interrupted")
|
|
748
|
+
return 130
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
if __name__ == "__main__":
|
|
752
|
+
sys.exit(main())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "japanfold"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Command-line client for JapanFold: protein structure prediction, binder design and embeddings over an HTTP API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
# Intentionally dependency-free (stdlib only) so it installs anywhere an agent runs.
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["protein", "structure-prediction", "boltz", "esmfold", "binder-design", "bioinformatics"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://japanfold.aiand.com"
|
|
18
|
+
Documentation = "https://japanfold.aiand.com/docs"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
japanfold = "japanfold_cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
py-modules = ["japanfold_cli"]
|