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 ADDED
@@ -0,0 +1,3 @@
1
+ """Proteworks sync client."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ proteworks = proteworks.cli:main
@@ -0,0 +1 @@
1
+ proteworks