proteworks 0.1.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.
- proteworks-0.1.0/PKG-INFO +28 -0
- proteworks-0.1.0/README.md +17 -0
- proteworks-0.1.0/pyproject.toml +26 -0
- proteworks-0.1.0/setup.cfg +4 -0
- proteworks-0.1.0/src/proteworks/__init__.py +3 -0
- proteworks-0.1.0/src/proteworks/api.py +180 -0
- proteworks-0.1.0/src/proteworks/auth.py +109 -0
- proteworks-0.1.0/src/proteworks/cli.py +176 -0
- proteworks-0.1.0/src/proteworks/config.py +88 -0
- proteworks-0.1.0/src/proteworks/index.py +95 -0
- proteworks-0.1.0/src/proteworks/orgs.py +57 -0
- proteworks-0.1.0/src/proteworks/pull.py +145 -0
- proteworks-0.1.0/src/proteworks/push.py +118 -0
- proteworks-0.1.0/src/proteworks/redact.py +47 -0
- proteworks-0.1.0/src/proteworks.egg-info/PKG-INFO +28 -0
- proteworks-0.1.0/src/proteworks.egg-info/SOURCES.txt +19 -0
- proteworks-0.1.0/src/proteworks.egg-info/dependency_links.txt +1 -0
- proteworks-0.1.0/src/proteworks.egg-info/entry_points.txt +2 -0
- proteworks-0.1.0/src/proteworks.egg-info/requires.txt +5 -0
- proteworks-0.1.0/src/proteworks.egg-info/top_level.txt +1 -0
- proteworks-0.1.0/tests/test_cli.py +498 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: proteworks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync files with Proteworks using file-browser paths.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: keyring>=25
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
11
|
+
|
|
12
|
+
# proteworks
|
|
13
|
+
|
|
14
|
+
Command line client for pulling and pushing Proteworks files. The default API is production. Set `PROTEWORKS_API_URL` only when testing against staging or a local stack.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv tool install proteworks
|
|
18
|
+
proteworks login
|
|
19
|
+
proteworks organization list
|
|
20
|
+
proteworks organization set "My Org"
|
|
21
|
+
proteworks pull '~/My Files/campaigns/run/' ./run
|
|
22
|
+
proteworks push ./1UBQ.pdb '~/My Files/test/'
|
|
23
|
+
proteworks push ./run '~/My Files/campaigns/run/'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
|
|
27
|
+
|
|
28
|
+
Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs. The same URL is used in development and production.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# proteworks
|
|
2
|
+
|
|
3
|
+
Command line client for pulling and pushing Proteworks files. The default API is production. Set `PROTEWORKS_API_URL` only when testing against staging or a local stack.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv tool install proteworks
|
|
7
|
+
proteworks login
|
|
8
|
+
proteworks organization list
|
|
9
|
+
proteworks organization set "My Org"
|
|
10
|
+
proteworks pull '~/My Files/campaigns/run/' ./run
|
|
11
|
+
proteworks push ./1UBQ.pdb '~/My Files/test/'
|
|
12
|
+
proteworks push ./run '~/My Files/campaigns/run/'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Quote wildcards so the shell does not expand them. A trailing slash on a remote path means a folder. `proteworks --help` lists every command.
|
|
16
|
+
|
|
17
|
+
Login returns to `http://127.0.0.1:8765/callback`. Add that exact URL to the Auth0 application's Allowed Callback URLs. The same URL is used in development and production.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "proteworks"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Sync files with Proteworks using file-browser paths."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.27",
|
|
9
|
+
"keyring>=25",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = ["pytest>=8"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
proteworks = "proteworks.cli:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["setuptools>=68"]
|
|
20
|
+
build-backend = "setuptools.build_meta"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Protein Studio HTTP API. Signed URLs are returned to callers and never logged."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from proteworks.redact import redact
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiError(Exception):
|
|
15
|
+
"""Sanitized API failure. The message never includes a storage URL."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, status_code: int):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def public_detail(response: httpx.Response) -> str:
|
|
23
|
+
"""User-facing error from a JSON body, without echoing storage fields."""
|
|
24
|
+
try:
|
|
25
|
+
payload = response.json()
|
|
26
|
+
except Exception:
|
|
27
|
+
return f"Request failed ({response.status_code})"
|
|
28
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
29
|
+
if isinstance(detail, str) and detail.strip():
|
|
30
|
+
cleaned = redact(detail)
|
|
31
|
+
return cleaned if isinstance(cleaned, str) else "Request failed"
|
|
32
|
+
if response.status_code == 404:
|
|
33
|
+
return "File not found"
|
|
34
|
+
return f"Request failed ({response.status_code})"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApiClient:
|
|
38
|
+
"""Bearer-token client. Callers must not print values returned as URLs."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, base_url: str, token: str, organization_id: Optional[str] = None, client: Optional[httpx.Client] = None):
|
|
41
|
+
self.base_url = base_url.rstrip("/")
|
|
42
|
+
self.token = token
|
|
43
|
+
self.organization_id = organization_id
|
|
44
|
+
self._client = client or httpx.Client(timeout=120.0)
|
|
45
|
+
self._owns_client = client is None
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
if self._owns_client:
|
|
49
|
+
self._client.close()
|
|
50
|
+
|
|
51
|
+
def _headers(self) -> dict[str, str]:
|
|
52
|
+
return {"Authorization": f"Bearer {self.token}", "Accept": "application/json"}
|
|
53
|
+
|
|
54
|
+
def request(
|
|
55
|
+
self,
|
|
56
|
+
method: str,
|
|
57
|
+
path: str,
|
|
58
|
+
*,
|
|
59
|
+
params: Optional[dict] = None,
|
|
60
|
+
json_body: Any = None,
|
|
61
|
+
data: Any = None,
|
|
62
|
+
extra_headers: Optional[dict] = None,
|
|
63
|
+
) -> Any:
|
|
64
|
+
query = dict(params or {})
|
|
65
|
+
if self.organization_id and "organization_id" not in query and path != "/api/v1/organizations":
|
|
66
|
+
query["organization_id"] = self.organization_id
|
|
67
|
+
url = self.base_url + path
|
|
68
|
+
if query:
|
|
69
|
+
url = url + "?" + urlencode(query)
|
|
70
|
+
headers = self._headers()
|
|
71
|
+
if extra_headers:
|
|
72
|
+
headers.update(extra_headers)
|
|
73
|
+
response = self._client.request(method, url, headers=headers, json=json_body, data=data)
|
|
74
|
+
if response.status_code >= 400:
|
|
75
|
+
raise ApiError(public_detail(response), response.status_code)
|
|
76
|
+
if not response.content:
|
|
77
|
+
return None
|
|
78
|
+
return response.json()
|
|
79
|
+
|
|
80
|
+
def list_organizations(self) -> list[dict]:
|
|
81
|
+
payload = self.request("GET", "/api/v1/organizations", params={})
|
|
82
|
+
return list(payload or [])
|
|
83
|
+
|
|
84
|
+
def manifest(self, path: str, offset: int, limit: int = 500) -> dict:
|
|
85
|
+
return self.request(
|
|
86
|
+
"GET",
|
|
87
|
+
"/api/v1/sync/manifest",
|
|
88
|
+
params={"path": path, "offset": offset, "limit": limit},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def manifest_all(self, path: str) -> dict:
|
|
92
|
+
"""Follow pages. Returns items plus root_folder_id from the first page."""
|
|
93
|
+
offset = 0
|
|
94
|
+
items: list[dict] = []
|
|
95
|
+
root_folder_id = None
|
|
96
|
+
total = None
|
|
97
|
+
while True:
|
|
98
|
+
page = self.manifest(path, offset)
|
|
99
|
+
root_folder_id = root_folder_id or page.get("root_folder_id")
|
|
100
|
+
batch = list(page.get("items") or [])
|
|
101
|
+
items.extend(batch)
|
|
102
|
+
total = int(page.get("total") or 0)
|
|
103
|
+
offset += len(batch)
|
|
104
|
+
if not batch or offset >= total:
|
|
105
|
+
break
|
|
106
|
+
return {"items": items, "total": total or 0, "root_folder_id": root_folder_id}
|
|
107
|
+
|
|
108
|
+
def content_urls(self, file_ids: list[str]) -> dict[str, dict]:
|
|
109
|
+
"""Mint signed download URLs. Retries when the API asks the client to slow down."""
|
|
110
|
+
payload = self._request_with_backoff(
|
|
111
|
+
"POST",
|
|
112
|
+
"/api/v1/files/content-urls",
|
|
113
|
+
json_body={"file_ids": file_ids, "purpose": "download"},
|
|
114
|
+
extra_headers={"X-Proteworks-Client": "cli"},
|
|
115
|
+
)
|
|
116
|
+
return dict((payload or {}).get("urls") or {})
|
|
117
|
+
|
|
118
|
+
def _request_with_backoff(
|
|
119
|
+
self,
|
|
120
|
+
method: str,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
json_body: Any = None,
|
|
124
|
+
extra_headers: Optional[dict] = None,
|
|
125
|
+
) -> Any:
|
|
126
|
+
delay_seconds = 1.0
|
|
127
|
+
for attempt in range(6):
|
|
128
|
+
try:
|
|
129
|
+
return self.request(method, path, json_body=json_body, extra_headers=extra_headers)
|
|
130
|
+
except ApiError as exc:
|
|
131
|
+
if exc.status_code != 429 or attempt == 5:
|
|
132
|
+
raise
|
|
133
|
+
time.sleep(delay_seconds)
|
|
134
|
+
delay_seconds = min(delay_seconds * 2, 30.0)
|
|
135
|
+
|
|
136
|
+
def list_child_folders(self, parent_id: str) -> list[dict]:
|
|
137
|
+
payload = self.request("GET", "/api/v1/folders/", params={"parent_id": parent_id, "limit": 1000})
|
|
138
|
+
return list((payload or {}).get("folders") or [])
|
|
139
|
+
|
|
140
|
+
def create_folder(self, name: str, parent_id: str) -> dict:
|
|
141
|
+
return self.request(
|
|
142
|
+
"POST",
|
|
143
|
+
"/api/v1/folders/",
|
|
144
|
+
data={"name": name, "parent_id": parent_id},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def upload_init(self, filename: str, folder_id: str, size_bytes: int, content_type: str) -> dict:
|
|
148
|
+
return self.request(
|
|
149
|
+
"POST",
|
|
150
|
+
"/api/v1/files/upload-init",
|
|
151
|
+
json_body={
|
|
152
|
+
"filename": filename,
|
|
153
|
+
"folder_id": folder_id,
|
|
154
|
+
"content_type": content_type,
|
|
155
|
+
"size_bytes": size_bytes,
|
|
156
|
+
"skip_existing": False,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def upload_complete(self, session_id: str, size_bytes: int) -> dict:
|
|
161
|
+
return self.request(
|
|
162
|
+
"POST",
|
|
163
|
+
"/api/v1/files/upload-complete",
|
|
164
|
+
json_body={"session_id": session_id, "size_bytes": size_bytes},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def download_to(self, url: str, path) -> None:
|
|
168
|
+
"""Stream bytes to a file. The URL is not logged."""
|
|
169
|
+
with self._client.stream("GET", url) as response:
|
|
170
|
+
response.raise_for_status()
|
|
171
|
+
with open(path, "wb") as handle:
|
|
172
|
+
for chunk in response.iter_bytes():
|
|
173
|
+
handle.write(chunk)
|
|
174
|
+
|
|
175
|
+
def put_file(self, url: str, path, headers: Optional[dict] = None) -> None:
|
|
176
|
+
"""Upload a file. The URL is not logged."""
|
|
177
|
+
with open(path, "rb") as handle:
|
|
178
|
+
response = self._client.put(url, content=handle, headers=headers or {})
|
|
179
|
+
if response.status_code >= 400:
|
|
180
|
+
raise ApiError(f"Upload to storage failed ({response.status_code})", response.status_code)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Auth0 loopback PKCE login. The access token is stored in the OS keychain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import secrets
|
|
8
|
+
import webbrowser
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
10
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from proteworks.config import LOOPBACK_PORT, LOOPBACK_REDIRECT_PATH, Settings, TokenStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pkce_pair() -> tuple[str, str]:
|
|
18
|
+
verifier = secrets.token_urlsafe(64)
|
|
19
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
20
|
+
challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
21
|
+
return verifier, challenge
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def authorize_url(settings: Settings, redirect_uri: str, state: str, challenge: str) -> str:
|
|
25
|
+
query = urlencode(
|
|
26
|
+
{
|
|
27
|
+
"response_type": "code",
|
|
28
|
+
"client_id": settings.auth0_client_id,
|
|
29
|
+
"redirect_uri": redirect_uri,
|
|
30
|
+
"scope": "openid profile email",
|
|
31
|
+
"audience": settings.auth0_audience,
|
|
32
|
+
"state": state,
|
|
33
|
+
"code_challenge": challenge,
|
|
34
|
+
"code_challenge_method": "S256",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
return f"https://{settings.auth0_domain}/authorize?{query}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
41
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib handler name
|
|
42
|
+
parsed = urlparse(self.path)
|
|
43
|
+
if parsed.path != LOOPBACK_REDIRECT_PATH:
|
|
44
|
+
self.send_response(404)
|
|
45
|
+
self.end_headers()
|
|
46
|
+
return
|
|
47
|
+
params = parse_qs(parsed.query)
|
|
48
|
+
self.server.auth_code = params.get("code", [None])[0] # type: ignore[attr-defined]
|
|
49
|
+
self.server.auth_state = params.get("state", [None])[0] # type: ignore[attr-defined]
|
|
50
|
+
self.server.auth_error = params.get("error", [None])[0] # type: ignore[attr-defined]
|
|
51
|
+
body = b"Signed in. You can close this window."
|
|
52
|
+
self.send_response(200)
|
|
53
|
+
self.send_header("Content-Type", "text/plain")
|
|
54
|
+
self.send_header("Content-Length", str(len(body)))
|
|
55
|
+
self.end_headers()
|
|
56
|
+
self.wfile.write(body)
|
|
57
|
+
|
|
58
|
+
def log_message(self, fmt: str, *args) -> None:
|
|
59
|
+
"""Do not log the authorization code."""
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def loopback_redirect_uri(port: int = LOOPBACK_PORT) -> str:
|
|
64
|
+
"""Callback Auth0 must allow. The port is fixed so the URL does not change."""
|
|
65
|
+
return f"http://127.0.0.1:{port}{LOOPBACK_REDIRECT_PATH}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def login(settings: Settings, token_store: TokenStore, stderr) -> None:
|
|
69
|
+
"""Open a browser, receive the loopback redirect, and save the access token."""
|
|
70
|
+
verifier, challenge = pkce_pair()
|
|
71
|
+
state = secrets.token_urlsafe(24)
|
|
72
|
+
try:
|
|
73
|
+
server = HTTPServer(("127.0.0.1", LOOPBACK_PORT), _CallbackHandler)
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
raise SystemExit(
|
|
76
|
+
f"Port {LOOPBACK_PORT} is in use, so login cannot listen on {loopback_redirect_uri()}. ({exc})"
|
|
77
|
+
) from exc
|
|
78
|
+
redirect_uri = loopback_redirect_uri()
|
|
79
|
+
url = authorize_url(settings, redirect_uri, state, challenge)
|
|
80
|
+
print(f"Auth0 must allow this callback URL: {redirect_uri}", file=stderr)
|
|
81
|
+
print("Opening a browser to sign in.", file=stderr)
|
|
82
|
+
opened = webbrowser.open(url)
|
|
83
|
+
if not opened:
|
|
84
|
+
print("Open this URL to sign in:", file=stderr)
|
|
85
|
+
print(url, file=stderr)
|
|
86
|
+
server.handle_request()
|
|
87
|
+
code = getattr(server, "auth_code", None)
|
|
88
|
+
returned_state = getattr(server, "auth_state", None)
|
|
89
|
+
error = getattr(server, "auth_error", None)
|
|
90
|
+
if error or not code or returned_state != state:
|
|
91
|
+
raise SystemExit("Login failed.")
|
|
92
|
+
token_response = httpx.post(
|
|
93
|
+
f"https://{settings.auth0_domain}/oauth/token",
|
|
94
|
+
json={
|
|
95
|
+
"grant_type": "authorization_code",
|
|
96
|
+
"client_id": settings.auth0_client_id,
|
|
97
|
+
"code": code,
|
|
98
|
+
"redirect_uri": redirect_uri,
|
|
99
|
+
"code_verifier": verifier,
|
|
100
|
+
},
|
|
101
|
+
timeout=60.0,
|
|
102
|
+
)
|
|
103
|
+
if token_response.status_code >= 400:
|
|
104
|
+
raise SystemExit("Login failed.")
|
|
105
|
+
access_token = token_response.json().get("access_token")
|
|
106
|
+
if not access_token:
|
|
107
|
+
raise SystemExit("Login failed.")
|
|
108
|
+
token_store.set(access_token)
|
|
109
|
+
print("Signed in.", file=stderr)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""proteworks command line. Help text comes from the registered subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from proteworks.api import ApiClient
|
|
10
|
+
from proteworks.auth import login
|
|
11
|
+
from proteworks.config import ConfigStore, Settings, TokenStore, load_settings
|
|
12
|
+
from proteworks.orgs import (
|
|
13
|
+
OrgSelectionError,
|
|
14
|
+
find_organization,
|
|
15
|
+
format_organization_line,
|
|
16
|
+
regular_organizations,
|
|
17
|
+
select_organization,
|
|
18
|
+
)
|
|
19
|
+
from proteworks.pull import pull_path
|
|
20
|
+
from proteworks.push import push_directory
|
|
21
|
+
from proteworks.redact import install_redacting_logs
|
|
22
|
+
|
|
23
|
+
_PULL_EXAMPLES = """\
|
|
24
|
+
examples:
|
|
25
|
+
proteworks pull '~/My Files/BoltzGenBinder.cif' .
|
|
26
|
+
proteworks pull '~/My Files/campaigns/' ./campaigns
|
|
27
|
+
proteworks pull '~/My Files/msa/*.cif' ./msa
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_PUSH_EXAMPLES = """\
|
|
31
|
+
examples:
|
|
32
|
+
proteworks push ./1UBQ.pdb '~/My Files/test/'
|
|
33
|
+
proteworks push ./campaigns '~/My Files/campaigns/'
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
"""Build the parser, then describe commands by walking what was registered."""
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="proteworks",
|
|
41
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
42
|
+
)
|
|
43
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
|
|
45
|
+
sub.add_parser("login", help="Sign in and save an access token in the OS keychain")
|
|
46
|
+
|
|
47
|
+
org = sub.add_parser("organization", help="List organizations or choose the active one")
|
|
48
|
+
org_sub = org.add_subparsers(dest="org_command", required=True)
|
|
49
|
+
org_sub.add_parser("list", help="Print each organization name")
|
|
50
|
+
set_parser = org_sub.add_parser("set", help="Save the active organization locally")
|
|
51
|
+
set_parser.add_argument("name", help="Organization name or id from organization list")
|
|
52
|
+
|
|
53
|
+
pull = sub.add_parser(
|
|
54
|
+
"pull",
|
|
55
|
+
help="Download a file, a folder/, or a quoted wildcard",
|
|
56
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
57
|
+
epilog=_PULL_EXAMPLES,
|
|
58
|
+
)
|
|
59
|
+
pull.add_argument("remote", help="File-browser path such as '~/My Files/campaigns/run/'. Quote wildcards.")
|
|
60
|
+
pull.add_argument("dest", help="Local file or directory")
|
|
61
|
+
pull.add_argument("--dry-run", action="store_true", help="Print the files that would be downloaded")
|
|
62
|
+
|
|
63
|
+
push = sub.add_parser(
|
|
64
|
+
"push",
|
|
65
|
+
help="Upload a local file or directory into a remote folder",
|
|
66
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
67
|
+
epilog=_PUSH_EXAMPLES,
|
|
68
|
+
)
|
|
69
|
+
push.add_argument("local_dir", help="Local file or directory to upload")
|
|
70
|
+
push.add_argument("remote", help="Remote folder path ending with /")
|
|
71
|
+
push.add_argument("--dry-run", action="store_true", help="Print the files that would be uploaded")
|
|
72
|
+
|
|
73
|
+
commands = "\n".join(f" {line}" for line in _registered_commands(parser))
|
|
74
|
+
parser.description = (
|
|
75
|
+
f"Commands:\n{commands}\n\n"
|
|
76
|
+
"examples:\n"
|
|
77
|
+
" proteworks pull '~/My Files/BoltzGenBinder.cif' .\n"
|
|
78
|
+
" proteworks pull '~/My Files/campaigns/' ./campaigns\n"
|
|
79
|
+
" proteworks pull '~/My Files/msa/*.cif' ./msa\n"
|
|
80
|
+
" proteworks push ./1UBQ.pdb '~/My Files/test/'\n"
|
|
81
|
+
" proteworks push ./campaigns '~/My Files/campaigns/'\n"
|
|
82
|
+
)
|
|
83
|
+
return parser
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _registered_commands(parser: argparse.ArgumentParser) -> list[str]:
|
|
87
|
+
lines: list[str] = []
|
|
88
|
+
for action in parser._actions:
|
|
89
|
+
if not isinstance(action, argparse._SubParsersAction):
|
|
90
|
+
continue
|
|
91
|
+
for name, subparser in action.choices.items():
|
|
92
|
+
lines.append(name)
|
|
93
|
+
for subaction in subparser._actions:
|
|
94
|
+
if isinstance(subaction, argparse._SubParsersAction):
|
|
95
|
+
for subname in subaction.choices:
|
|
96
|
+
lines.append(f"{name} {subname}")
|
|
97
|
+
return lines
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: list[str] | None = None, *, settings: Settings | None = None, token_store: TokenStore | None = None) -> int:
|
|
101
|
+
install_redacting_logs()
|
|
102
|
+
settings = settings or load_settings()
|
|
103
|
+
token_store = token_store or TokenStore()
|
|
104
|
+
parser = build_parser()
|
|
105
|
+
args = parser.parse_args(argv)
|
|
106
|
+
if settings.api_url_overridden:
|
|
107
|
+
print(f"using {settings.api_url}", file=sys.stderr)
|
|
108
|
+
if args.command == "login":
|
|
109
|
+
login(settings, token_store, sys.stderr)
|
|
110
|
+
config = ConfigStore(settings.config_dir / "config.json")
|
|
111
|
+
data = config.read()
|
|
112
|
+
data["api_url"] = settings.api_url
|
|
113
|
+
config.write(data)
|
|
114
|
+
return 0
|
|
115
|
+
token = token_store.get()
|
|
116
|
+
if not token:
|
|
117
|
+
print("Run proteworks login.", file=sys.stderr)
|
|
118
|
+
return 2
|
|
119
|
+
config = ConfigStore(settings.config_dir / "config.json")
|
|
120
|
+
bound = config.bound_api_url()
|
|
121
|
+
if bound and bound.rstrip("/") != settings.api_url.rstrip("/"):
|
|
122
|
+
print("API URL changed. Run proteworks login again.", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
api = ApiClient(settings.api_url, token)
|
|
125
|
+
try:
|
|
126
|
+
if args.command == "organization" and args.org_command == "list":
|
|
127
|
+
return _organization_list(api, config, sys.stdout)
|
|
128
|
+
if args.command == "organization" and args.org_command == "set":
|
|
129
|
+
return _organization_set(api, config, settings, args.name, sys.stderr)
|
|
130
|
+
org = _require_organization(api, config, sys.stderr)
|
|
131
|
+
if org is None:
|
|
132
|
+
return 2
|
|
133
|
+
api.organization_id = org["id"]
|
|
134
|
+
if args.command == "pull":
|
|
135
|
+
return pull_path(api, args.remote, Path(args.dest), dry_run=args.dry_run, stderr=sys.stderr)
|
|
136
|
+
if args.command == "push":
|
|
137
|
+
return push_directory(api, Path(args.local_dir), args.remote, dry_run=args.dry_run, stderr=sys.stderr)
|
|
138
|
+
parser.print_help(sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
finally:
|
|
141
|
+
api.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _organization_list(api: ApiClient, config: ConfigStore, stdout) -> int:
|
|
145
|
+
orgs = regular_organizations(api.list_organizations())
|
|
146
|
+
active = None
|
|
147
|
+
try:
|
|
148
|
+
active = select_organization(orgs, config.organization_id()).get("id")
|
|
149
|
+
except OrgSelectionError:
|
|
150
|
+
active = config.organization_id()
|
|
151
|
+
for org in orgs:
|
|
152
|
+
print(format_organization_line(org, active), file=stdout)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _organization_set(api: ApiClient, config: ConfigStore, settings: Settings, name: str, stderr) -> int:
|
|
157
|
+
try:
|
|
158
|
+
org = find_organization(api.list_organizations(), name)
|
|
159
|
+
except OrgSelectionError as exc:
|
|
160
|
+
print(str(exc), file=stderr)
|
|
161
|
+
return 2
|
|
162
|
+
config.set_organization(org["id"], settings.api_url)
|
|
163
|
+
print(f"Active organization: {org['name']}", file=stderr)
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _require_organization(api: ApiClient, config: ConfigStore, stderr):
|
|
168
|
+
try:
|
|
169
|
+
return select_organization(api.list_organizations(), config.organization_id())
|
|
170
|
+
except OrgSelectionError as exc:
|
|
171
|
+
print(str(exc), file=stderr)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Local CLI config and production API defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
PRODUCTION_API_URL = "https://api.studio.proteworks.com"
|
|
12
|
+
PRODUCTION_AUTH0_DOMAIN = "proteworks-prod.us.auth0.com"
|
|
13
|
+
PRODUCTION_AUTH0_CLIENT_ID = "wxtMn1fQ6hvAJopeNamTqugyLNbHUP2x"
|
|
14
|
+
PRODUCTION_AUTH0_AUDIENCE = "https://proteworks-prod.us.auth0.com/api/v2/"
|
|
15
|
+
LOOPBACK_REDIRECT_PATH = "/callback"
|
|
16
|
+
LOOPBACK_PORT = 8765
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Settings:
|
|
21
|
+
api_url: str
|
|
22
|
+
api_url_overridden: bool
|
|
23
|
+
auth0_domain: str
|
|
24
|
+
auth0_client_id: str
|
|
25
|
+
auth0_audience: str
|
|
26
|
+
config_dir: Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_settings() -> Settings:
|
|
30
|
+
"""Production is the default. PROTEWORKS_API_URL is only for staging or local tests."""
|
|
31
|
+
override = os.environ.get("PROTEWORKS_API_URL", "").strip()
|
|
32
|
+
api_url = override.rstrip("/") if override else PRODUCTION_API_URL
|
|
33
|
+
config_root = os.environ.get("PROTEWORKS_CONFIG_DIR", "").strip()
|
|
34
|
+
config_dir = Path(config_root) if config_root else Path.home() / ".config" / "proteworks"
|
|
35
|
+
return Settings(
|
|
36
|
+
api_url=api_url,
|
|
37
|
+
api_url_overridden=bool(override),
|
|
38
|
+
auth0_domain=os.environ.get("PROTEWORKS_AUTH0_DOMAIN", PRODUCTION_AUTH0_DOMAIN).strip(),
|
|
39
|
+
auth0_client_id=os.environ.get("PROTEWORKS_AUTH0_CLIENT_ID", PRODUCTION_AUTH0_CLIENT_ID).strip(),
|
|
40
|
+
auth0_audience=os.environ.get("PROTEWORKS_AUTH0_AUDIENCE", PRODUCTION_AUTH0_AUDIENCE).strip(),
|
|
41
|
+
config_dir=config_dir,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConfigStore:
|
|
46
|
+
"""Active organization for this API. The token itself stays in the keychain."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, path: Path):
|
|
49
|
+
self.path = path
|
|
50
|
+
|
|
51
|
+
def read(self) -> dict:
|
|
52
|
+
if not self.path.exists():
|
|
53
|
+
return {}
|
|
54
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
55
|
+
|
|
56
|
+
def write(self, data: dict) -> None:
|
|
57
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self.path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
def organization_id(self) -> Optional[str]:
|
|
61
|
+
value = self.read().get("organization_id")
|
|
62
|
+
return str(value) if value else None
|
|
63
|
+
|
|
64
|
+
def set_organization(self, organization_id: str, api_url: str) -> None:
|
|
65
|
+
data = self.read()
|
|
66
|
+
data["organization_id"] = organization_id
|
|
67
|
+
data["api_url"] = api_url
|
|
68
|
+
self.write(data)
|
|
69
|
+
|
|
70
|
+
def bound_api_url(self) -> Optional[str]:
|
|
71
|
+
value = self.read().get("api_url")
|
|
72
|
+
return str(value) if value else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TokenStore:
|
|
76
|
+
"""Access token in the OS keychain, never in the campaign folder."""
|
|
77
|
+
|
|
78
|
+
SERVICE = "proteworks"
|
|
79
|
+
|
|
80
|
+
def get(self) -> Optional[str]:
|
|
81
|
+
import keyring
|
|
82
|
+
|
|
83
|
+
return keyring.get_password(self.SERVICE, "access_token")
|
|
84
|
+
|
|
85
|
+
def set(self, token: str) -> None:
|
|
86
|
+
import keyring
|
|
87
|
+
|
|
88
|
+
keyring.set_password(self.SERVICE, "access_token", token)
|