proteworks 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- proteworks/api.py +180 -0
- proteworks/auth.py +109 -0
- proteworks/cli.py +176 -0
- proteworks/config.py +88 -0
- proteworks/index.py +95 -0
- proteworks/orgs.py +57 -0
- proteworks/pull.py +145 -0
- proteworks/push.py +118 -0
- proteworks/redact.py +47 -0
- proteworks-0.1.0.dist-info/METADATA +28 -0
- proteworks-0.1.0.dist-info/RECORD +15 -0
- proteworks-0.1.0.dist-info/WHEEL +5 -0
- proteworks-0.1.0.dist-info/entry_points.txt +2 -0
- proteworks-0.1.0.dist-info/top_level.txt +1 -0
proteworks/__init__.py
ADDED
proteworks/api.py
ADDED
|
@@ -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)
|
proteworks/auth.py
ADDED
|
@@ -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)
|
proteworks/cli.py
ADDED
|
@@ -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())
|
proteworks/config.py
ADDED
|
@@ -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)
|
proteworks/index.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Local sync index. Stores ids, logical paths, sizes, and hashes only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def index_path(dest_dir: Path) -> Path:
|
|
11
|
+
return dest_dir / ".proteworks" / "files.jsonl"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_index(dest_dir: Path) -> dict[str, dict]:
|
|
15
|
+
path = index_path(dest_dir)
|
|
16
|
+
if not path.exists():
|
|
17
|
+
return {}
|
|
18
|
+
rows: dict[str, dict] = {}
|
|
19
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
20
|
+
if not line.strip():
|
|
21
|
+
continue
|
|
22
|
+
row = json.loads(line)
|
|
23
|
+
rows[row["relative_path"]] = {
|
|
24
|
+
"file_id": row.get("file_id"),
|
|
25
|
+
"relative_path": row["relative_path"],
|
|
26
|
+
"size": row.get("size"),
|
|
27
|
+
"sha256": row.get("sha256"),
|
|
28
|
+
}
|
|
29
|
+
return rows
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_index(dest_dir: Path, rows: dict[str, dict]) -> None:
|
|
33
|
+
path = index_path(dest_dir)
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
lines = []
|
|
36
|
+
for key in sorted(rows):
|
|
37
|
+
row = rows[key]
|
|
38
|
+
lines.append(
|
|
39
|
+
json.dumps(
|
|
40
|
+
{
|
|
41
|
+
"file_id": row.get("file_id"),
|
|
42
|
+
"relative_path": row["relative_path"],
|
|
43
|
+
"size": row.get("size"),
|
|
44
|
+
"sha256": row.get("sha256"),
|
|
45
|
+
},
|
|
46
|
+
separators=(",", ":"),
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def sha256_file(path: Path) -> str:
|
|
53
|
+
digest = hashlib.sha256()
|
|
54
|
+
with path.open("rb") as handle:
|
|
55
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
56
|
+
digest.update(chunk)
|
|
57
|
+
return digest.hexdigest()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sha256_bytes(payload: bytes) -> str:
|
|
61
|
+
return hashlib.sha256(payload).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def local_relative(remote: str, relative_path: str) -> str:
|
|
65
|
+
"""Path under the destination, relative to the requested remote prefix."""
|
|
66
|
+
prefix = remote.rstrip("/")
|
|
67
|
+
if relative_path == prefix:
|
|
68
|
+
return Path(relative_path).name
|
|
69
|
+
marker = prefix + "/"
|
|
70
|
+
if relative_path.startswith(marker):
|
|
71
|
+
return relative_path[len(marker) :]
|
|
72
|
+
stripped = relative_path[2:] if relative_path.startswith("~/") else relative_path
|
|
73
|
+
return stripped
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def destination_for(dest: Path, remote: str, relative_path: str, *, single_file: bool) -> Path:
|
|
77
|
+
"""Place a downloaded file under dest without keeping the ~/ prefix."""
|
|
78
|
+
name = local_relative(remote, relative_path)
|
|
79
|
+
if single_file and not _dest_is_directory(dest):
|
|
80
|
+
return dest
|
|
81
|
+
return dest / name
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _dest_is_directory(dest: Path) -> bool:
|
|
85
|
+
if str(dest) in {".", ""}:
|
|
86
|
+
return True
|
|
87
|
+
if dest.exists():
|
|
88
|
+
return dest.is_dir()
|
|
89
|
+
return str(dest).endswith("/")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def index_dir_for(dest: Path, *, single_file: bool) -> Path:
|
|
93
|
+
if single_file and not _dest_is_directory(dest):
|
|
94
|
+
return dest.parent if dest.parent != Path("") else Path(".")
|
|
95
|
+
return dest
|
proteworks/orgs.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Organization list, selection, and the tutorial-aware default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OrgSelectionError(Exception):
|
|
9
|
+
"""The user must pick an organization before pull or push."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_tutorial(org: dict) -> bool:
|
|
13
|
+
return bool(org.get("is_tutorial_organization") or org.get("is_tutorial_generation_organization"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def regular_organizations(orgs: list[dict]) -> list[dict]:
|
|
17
|
+
return [org for org in orgs if not is_tutorial(org)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def select_organization(orgs: list[dict], configured_id: Optional[str]) -> dict:
|
|
21
|
+
"""
|
|
22
|
+
Use the saved organization when it is still a membership.
|
|
23
|
+
|
|
24
|
+
Otherwise prefer the only regular organization. Tutorial organizations are ignored.
|
|
25
|
+
"""
|
|
26
|
+
regular = regular_organizations(orgs)
|
|
27
|
+
if configured_id:
|
|
28
|
+
for org in regular:
|
|
29
|
+
if org.get("id") == configured_id:
|
|
30
|
+
return org
|
|
31
|
+
if not any(org.get("id") == configured_id and is_tutorial(org) for org in orgs):
|
|
32
|
+
raise OrgSelectionError("Saved organization is not in your memberships. Run proteworks organization set.")
|
|
33
|
+
if len(regular) == 1:
|
|
34
|
+
return regular[0]
|
|
35
|
+
if len(regular) > 1:
|
|
36
|
+
raise OrgSelectionError("You belong to more than one organization. Run proteworks organization set.")
|
|
37
|
+
raise OrgSelectionError("You do not belong to an organization.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_organization(orgs: list[dict], name_or_id: str) -> dict:
|
|
41
|
+
"""Match a name or id returned by organization list. Reject anything else."""
|
|
42
|
+
needle = name_or_id.strip()
|
|
43
|
+
orgs = regular_organizations(orgs)
|
|
44
|
+
by_id = [org for org in orgs if org.get("id") == needle]
|
|
45
|
+
if len(by_id) == 1:
|
|
46
|
+
return by_id[0]
|
|
47
|
+
by_name = [org for org in orgs if org.get("name") == needle]
|
|
48
|
+
if len(by_name) == 1:
|
|
49
|
+
return by_name[0]
|
|
50
|
+
if len(by_name) > 1:
|
|
51
|
+
raise OrgSelectionError("That name matches more than one organization. Set the organization id instead.")
|
|
52
|
+
raise OrgSelectionError("That organization was not returned by proteworks organization list.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_organization_line(org: dict, active_id: Optional[str]) -> str:
|
|
56
|
+
marker = "*" if org.get("id") == active_id else " "
|
|
57
|
+
return f"{marker} {org.get('name')}"
|
proteworks/pull.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Download a file, folder, or wildcard onto local disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Lock
|
|
8
|
+
|
|
9
|
+
from proteworks.api import ApiClient, ApiError
|
|
10
|
+
from proteworks.index import (
|
|
11
|
+
destination_for,
|
|
12
|
+
index_dir_for,
|
|
13
|
+
load_index,
|
|
14
|
+
local_relative,
|
|
15
|
+
save_index,
|
|
16
|
+
sha256_file,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# One content-urls call can mint this many links. The API rejects a larger batch.
|
|
20
|
+
_CONTENT_URL_BATCH = 100
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pull_path(api: ApiClient, remote: str, dest: Path, *, dry_run: bool, stderr) -> int:
|
|
24
|
+
"""Download readable files for a file-browser path. Returns a process status code."""
|
|
25
|
+
try:
|
|
26
|
+
page = api.manifest_all(remote)
|
|
27
|
+
except ApiError as exc:
|
|
28
|
+
print(str(exc), file=stderr)
|
|
29
|
+
return 1
|
|
30
|
+
items = list(page.get("items") or [])
|
|
31
|
+
single_file = len(items) == 1 and not remote.endswith("/") and "*" not in remote and "?" not in remote
|
|
32
|
+
print(f"{len(items)} file(s)", file=stderr)
|
|
33
|
+
if dry_run:
|
|
34
|
+
for item in items:
|
|
35
|
+
print(local_relative(remote, item["relative_path"]), file=stderr)
|
|
36
|
+
return 0
|
|
37
|
+
index_root = index_dir_for(dest, single_file=single_file)
|
|
38
|
+
if not single_file or dest.exists() and dest.is_dir() or str(dest) in {".", ""}:
|
|
39
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
index = load_index(index_root)
|
|
41
|
+
index_lock = Lock()
|
|
42
|
+
failures = 0
|
|
43
|
+
pending = []
|
|
44
|
+
for item in items:
|
|
45
|
+
if _already_local(dest, remote, item, single_file):
|
|
46
|
+
with index_lock:
|
|
47
|
+
index[item["relative_path"]] = _index_row(item)
|
|
48
|
+
print(f"skip {local_relative(remote, item['relative_path'])}", file=stderr)
|
|
49
|
+
continue
|
|
50
|
+
pending.append(item)
|
|
51
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
52
|
+
for start in range(0, len(pending), _CONTENT_URL_BATCH):
|
|
53
|
+
chunk = pending[start : start + _CONTENT_URL_BATCH]
|
|
54
|
+
try:
|
|
55
|
+
urls = api.content_urls([item["file_id"] for item in chunk])
|
|
56
|
+
except ApiError as exc:
|
|
57
|
+
for item in chunk:
|
|
58
|
+
print(f"{local_relative(remote, item['relative_path'])}: {exc}", file=stderr)
|
|
59
|
+
failures += len(chunk)
|
|
60
|
+
continue
|
|
61
|
+
futures = [
|
|
62
|
+
pool.submit(
|
|
63
|
+
_download_one,
|
|
64
|
+
api,
|
|
65
|
+
remote,
|
|
66
|
+
dest,
|
|
67
|
+
item,
|
|
68
|
+
single_file,
|
|
69
|
+
urls.get(item["file_id"]) or {},
|
|
70
|
+
index,
|
|
71
|
+
index_lock,
|
|
72
|
+
stderr,
|
|
73
|
+
)
|
|
74
|
+
for item in chunk
|
|
75
|
+
]
|
|
76
|
+
for future in as_completed(futures):
|
|
77
|
+
if not future.result():
|
|
78
|
+
failures += 1
|
|
79
|
+
save_index(index_root, index)
|
|
80
|
+
return 1 if failures else 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _already_local(dest: Path, remote: str, item: dict, single_file: bool) -> bool:
|
|
84
|
+
"""True when dest already holds this file.
|
|
85
|
+
|
|
86
|
+
A stored checksum must match. Workflow outputs often have no checksum, so a
|
|
87
|
+
file of the same size is treated as the copy that was already downloaded.
|
|
88
|
+
"""
|
|
89
|
+
target = destination_for(dest, remote, item["relative_path"], single_file=single_file)
|
|
90
|
+
if not target.is_file():
|
|
91
|
+
return False
|
|
92
|
+
expected = item.get("sha256")
|
|
93
|
+
if expected:
|
|
94
|
+
return sha256_file(target) == expected
|
|
95
|
+
size = item.get("size")
|
|
96
|
+
if size is None:
|
|
97
|
+
return False
|
|
98
|
+
return target.stat().st_size == int(size)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _download_one(
|
|
102
|
+
api: ApiClient,
|
|
103
|
+
remote: str,
|
|
104
|
+
dest: Path,
|
|
105
|
+
item: dict,
|
|
106
|
+
single_file: bool,
|
|
107
|
+
entry: dict,
|
|
108
|
+
index: dict,
|
|
109
|
+
index_lock: Lock,
|
|
110
|
+
stderr,
|
|
111
|
+
) -> bool:
|
|
112
|
+
relative = item["relative_path"]
|
|
113
|
+
target = destination_for(dest, remote, relative, single_file=single_file)
|
|
114
|
+
expected = item.get("sha256")
|
|
115
|
+
signed = entry.get("url")
|
|
116
|
+
if not signed:
|
|
117
|
+
print(f"{local_relative(remote, relative)}: Could not get a download URL", file=stderr)
|
|
118
|
+
return False
|
|
119
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
partial = target.with_suffix(target.suffix + ".partial")
|
|
121
|
+
try:
|
|
122
|
+
api.download_to(signed, partial)
|
|
123
|
+
digest = sha256_file(partial)
|
|
124
|
+
if expected and digest != expected:
|
|
125
|
+
partial.unlink(missing_ok=True)
|
|
126
|
+
print(f"{local_relative(remote, relative)}: hash mismatch", file=stderr)
|
|
127
|
+
return False
|
|
128
|
+
partial.replace(target)
|
|
129
|
+
except Exception:
|
|
130
|
+
partial.unlink(missing_ok=True)
|
|
131
|
+
print(f"{local_relative(remote, relative)}: download failed", file=stderr)
|
|
132
|
+
return False
|
|
133
|
+
with index_lock:
|
|
134
|
+
index[relative] = _index_row(item)
|
|
135
|
+
print(f"download {local_relative(remote, relative)}", file=stderr)
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _index_row(item: dict) -> dict:
|
|
140
|
+
return {
|
|
141
|
+
"file_id": item.get("file_id"),
|
|
142
|
+
"relative_path": item["relative_path"],
|
|
143
|
+
"size": item.get("size"),
|
|
144
|
+
"sha256": item.get("sha256"),
|
|
145
|
+
}
|
proteworks/push.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Upload a local file or directory into a remote folder the caller may write."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from proteworks.api import ApiClient, ApiError
|
|
9
|
+
from proteworks.index import sha256_file
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def push_directory(api: ApiClient, local_dir: Path, remote: str, *, dry_run: bool, stderr) -> int:
|
|
13
|
+
"""Upload files under local_dir. Skip a file when the remote hash already matches."""
|
|
14
|
+
if not remote.endswith("/"):
|
|
15
|
+
print("The remote path must be a folder ending with /.", file=stderr)
|
|
16
|
+
return 2
|
|
17
|
+
try:
|
|
18
|
+
files = _local_files(local_dir)
|
|
19
|
+
except ValueError as exc:
|
|
20
|
+
print(str(exc), file=stderr)
|
|
21
|
+
return 2
|
|
22
|
+
try:
|
|
23
|
+
page = api.manifest_all(remote)
|
|
24
|
+
except ApiError as exc:
|
|
25
|
+
print(str(exc), file=stderr)
|
|
26
|
+
return 1
|
|
27
|
+
root_id = page.get("root_folder_id")
|
|
28
|
+
if not root_id:
|
|
29
|
+
print("File not found", file=stderr)
|
|
30
|
+
return 1
|
|
31
|
+
remote_by_suffix = {}
|
|
32
|
+
prefix = remote.rstrip("/")
|
|
33
|
+
for item in page.get("items") or []:
|
|
34
|
+
relative = item.get("relative_path") or ""
|
|
35
|
+
if relative.startswith(prefix + "/"):
|
|
36
|
+
remote_by_suffix[relative[len(prefix) + 1 :]] = item
|
|
37
|
+
print(f"{len(files)} file(s)", file=stderr)
|
|
38
|
+
if dry_run:
|
|
39
|
+
for rel, _path in files:
|
|
40
|
+
print(rel.as_posix(), file=stderr)
|
|
41
|
+
return 0
|
|
42
|
+
folder_ids = {Path("."): root_id}
|
|
43
|
+
failures = 0
|
|
44
|
+
for rel, path in files:
|
|
45
|
+
try:
|
|
46
|
+
folder_id = _ensure_parent(api, folder_ids, rel.parent)
|
|
47
|
+
existing = remote_by_suffix.get(rel.as_posix())
|
|
48
|
+
digest = sha256_file(path)
|
|
49
|
+
if existing and existing.get("sha256") == digest:
|
|
50
|
+
print(f"skip {rel.as_posix()}", file=stderr)
|
|
51
|
+
continue
|
|
52
|
+
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
53
|
+
init = api.upload_init(path.name, folder_id, path.stat().st_size, content_type)
|
|
54
|
+
if not init.get("skipped"):
|
|
55
|
+
upload_url = init.get("upload_url")
|
|
56
|
+
if not upload_url:
|
|
57
|
+
print(f"{rel.as_posix()}: upload failed", file=stderr)
|
|
58
|
+
failures += 1
|
|
59
|
+
continue
|
|
60
|
+
# The signed PUT requires the same Content-Type sent to upload-init.
|
|
61
|
+
headers = dict(init.get("upload_headers") or {})
|
|
62
|
+
headers.setdefault("Content-Type", content_type)
|
|
63
|
+
api.put_file(upload_url, path, headers)
|
|
64
|
+
api.upload_complete(init["session_id"], path.stat().st_size)
|
|
65
|
+
print(f"upload {rel.as_posix()}", file=stderr)
|
|
66
|
+
except ApiError as exc:
|
|
67
|
+
print(f"{rel.as_posix()}: {exc}", file=stderr)
|
|
68
|
+
failures += 1
|
|
69
|
+
except Exception:
|
|
70
|
+
print(f"{rel.as_posix()}: upload failed", file=stderr)
|
|
71
|
+
failures += 1
|
|
72
|
+
return 1 if failures else 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _local_files(local_path: Path) -> list[tuple[Path, Path]]:
|
|
76
|
+
if local_path.is_file():
|
|
77
|
+
return [(Path(local_path.name), local_path)]
|
|
78
|
+
if not local_path.is_dir():
|
|
79
|
+
raise ValueError("The local path must be a file or directory.")
|
|
80
|
+
return _iter_local_files(local_path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _iter_local_files(local_dir: Path) -> list[tuple[Path, Path]]:
|
|
84
|
+
found: list[tuple[Path, Path]] = []
|
|
85
|
+
for path in sorted(local_dir.rglob("*")):
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
continue
|
|
88
|
+
rel = path.relative_to(local_dir)
|
|
89
|
+
if ".proteworks" in rel.parts:
|
|
90
|
+
continue
|
|
91
|
+
if any(part in {".", ".."} for part in rel.parts):
|
|
92
|
+
raise ValueError("A local path escapes the directory.")
|
|
93
|
+
resolved = path.resolve()
|
|
94
|
+
root = local_dir.resolve()
|
|
95
|
+
if root != resolved and root not in resolved.parents:
|
|
96
|
+
raise ValueError("A local path escapes the directory.")
|
|
97
|
+
found.append((rel, path))
|
|
98
|
+
return found
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ensure_parent(api: ApiClient, folder_ids: dict[Path, str], parent: Path) -> str:
|
|
102
|
+
if parent in folder_ids:
|
|
103
|
+
return folder_ids[parent]
|
|
104
|
+
ancestor = _ensure_parent(api, folder_ids, parent.parent)
|
|
105
|
+
name = parent.name
|
|
106
|
+
try:
|
|
107
|
+
created = api.create_folder(name, ancestor)
|
|
108
|
+
folder_id = created["id"]
|
|
109
|
+
except ApiError:
|
|
110
|
+
folder_id = None
|
|
111
|
+
for child in api.list_child_folders(ancestor):
|
|
112
|
+
if child.get("name") == name and child.get("id"):
|
|
113
|
+
folder_id = child["id"]
|
|
114
|
+
break
|
|
115
|
+
if not folder_id:
|
|
116
|
+
raise
|
|
117
|
+
folder_ids[parent] = folder_id
|
|
118
|
+
return folder_id
|
proteworks/redact.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Keep signed URLs and storage locations out of logs and saved state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_SECRET_KEYS = {"url", "upload_url", "cloud_storage_url", "upload_headers"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def redact(value: Any) -> Any:
|
|
12
|
+
"""Return a copy with storage URLs and signed-URL fields removed."""
|
|
13
|
+
if isinstance(value, dict):
|
|
14
|
+
cleaned = {}
|
|
15
|
+
for key, item in value.items():
|
|
16
|
+
if key in _SECRET_KEYS:
|
|
17
|
+
cleaned[key] = "[redacted]"
|
|
18
|
+
else:
|
|
19
|
+
cleaned[key] = redact(item)
|
|
20
|
+
return cleaned
|
|
21
|
+
if isinstance(value, list):
|
|
22
|
+
return [redact(item) for item in value]
|
|
23
|
+
if isinstance(value, str):
|
|
24
|
+
lowered = value.lower()
|
|
25
|
+
if lowered.startswith("gs://") or "storage.googleapis.com" in lowered:
|
|
26
|
+
return "[redacted]"
|
|
27
|
+
return value
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RedactFilter(logging.Filter):
|
|
32
|
+
"""Drop storage URLs from log records."""
|
|
33
|
+
|
|
34
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
35
|
+
if isinstance(record.msg, str):
|
|
36
|
+
record.msg = str(redact(record.msg))
|
|
37
|
+
if record.args:
|
|
38
|
+
record.args = redact(record.args)
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def install_redacting_logs() -> None:
|
|
43
|
+
"""Attach the redaction filter to the root logger once."""
|
|
44
|
+
root = logging.getLogger()
|
|
45
|
+
if any(isinstance(item, RedactFilter) for item in root.filters):
|
|
46
|
+
return
|
|
47
|
+
root.addFilter(RedactFilter())
|
|
@@ -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,15 @@
|
|
|
1
|
+
proteworks/__init__.py,sha256=JJg1U5dSx0JnlDnpWAWbwU3p_t-bsrbmvJkhtYXEQts,53
|
|
2
|
+
proteworks/api.py,sha256=KN7CC9yBl6q5UixWX6Dkux1dnnYROGX9X-IePeBwOKY,6740
|
|
3
|
+
proteworks/auth.py,sha256=PHaRqFgA2WcahRryitePyg9Zv5uE9Qhr_MSfM6nTC2k,4177
|
|
4
|
+
proteworks/cli.py,sha256=8oXiAcbygplAUQCLVKxy6BSHAV-37jycNilPwLFtQUQ,6840
|
|
5
|
+
proteworks/config.py,sha256=uCYbrdOpCjKk0SjgezEj26UbHaSywQ86Y5AE5-reN1g,2908
|
|
6
|
+
proteworks/index.py,sha256=Eudu50jWIZ1OYC91ge7_vZJQ-CDKR7Skkt1Mc-KSwIM,2935
|
|
7
|
+
proteworks/orgs.py,sha256=Z09udOHkeSVmlFKsOxaDClz-mjR0CLoteSkcV57eJeg,2234
|
|
8
|
+
proteworks/pull.py,sha256=posEsWWZJBqEXzvHMZRAIxJvYIEe7VJiWWSspFW33a4,5017
|
|
9
|
+
proteworks/push.py,sha256=U1xWcZ3AGwB6ZS37TS1I5UjEZQeezXXHsniSa6VpYYk,4547
|
|
10
|
+
proteworks/redact.py,sha256=cLV9XWXp5DcXEWFxgN7RCxCFXDbJjqwZQ_xHQkPlMfg,1456
|
|
11
|
+
proteworks-0.1.0.dist-info/METADATA,sha256=LAgmThg9SLf8Te3UJT-AdJgwPtNQ4n0qfX1fxi5oz34,1065
|
|
12
|
+
proteworks-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
proteworks-0.1.0.dist-info/entry_points.txt,sha256=Qq8OkFQyEVOqlq7aY89vbYcHrXGPtJqVn0kNx_fYf7s,51
|
|
14
|
+
proteworks-0.1.0.dist-info/top_level.txt,sha256=yWHfkVzhk5jS18i2usGFecejonxm7wAvnAQjsUSB6wc,11
|
|
15
|
+
proteworks-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
proteworks
|