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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Proteworks sync client."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)