proteworks 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {proteworks-0.1.0 → proteworks-0.1.2}/PKG-INFO +1 -1
- {proteworks-0.1.0 → proteworks-0.1.2}/pyproject.toml +1 -1
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/api.py +22 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/auth.py +14 -2
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/cli.py +18 -1
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/index.py +2 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/pull.py +32 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks.egg-info/PKG-INFO +1 -1
- {proteworks-0.1.0 → proteworks-0.1.2}/tests/test_cli.py +101 -2
- {proteworks-0.1.0 → proteworks-0.1.2}/README.md +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/setup.cfg +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/__init__.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/config.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/orgs.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/push.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks/redact.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks.egg-info/SOURCES.txt +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks.egg-info/dependency_links.txt +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks.egg-info/entry_points.txt +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks.egg-info/requires.txt +0 -0
- {proteworks-0.1.0 → proteworks-0.1.2}/src/proteworks.egg-info/top_level.txt +0 -0
|
@@ -105,6 +105,28 @@ class ApiClient:
|
|
|
105
105
|
break
|
|
106
106
|
return {"items": items, "total": total or 0, "root_folder_id": root_folder_id}
|
|
107
107
|
|
|
108
|
+
def export_page(self, offset: int, limit: int = 500) -> dict:
|
|
109
|
+
return self.request(
|
|
110
|
+
"GET",
|
|
111
|
+
"/api/v1/sync/export",
|
|
112
|
+
params={"offset": offset, "limit": limit},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def export_all(self) -> dict:
|
|
116
|
+
"""Follow export pages. Returns every item for the active organization."""
|
|
117
|
+
offset = 0
|
|
118
|
+
items: list[dict] = []
|
|
119
|
+
total = None
|
|
120
|
+
while True:
|
|
121
|
+
page = self.export_page(offset)
|
|
122
|
+
batch = list(page.get("items") or [])
|
|
123
|
+
items.extend(batch)
|
|
124
|
+
total = int(page.get("total") or 0)
|
|
125
|
+
offset += len(batch)
|
|
126
|
+
if not batch or offset >= total:
|
|
127
|
+
break
|
|
128
|
+
return {"items": items, "total": total or 0}
|
|
129
|
+
|
|
108
130
|
def content_urls(self, file_ids: list[str]) -> dict[str, dict]:
|
|
109
131
|
"""Mint signed download URLs. Retries when the API asks the client to slow down."""
|
|
110
132
|
payload = self._request_with_backoff(
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
6
|
import hashlib
|
|
7
|
+
import os
|
|
7
8
|
import secrets
|
|
8
9
|
import webbrowser
|
|
9
10
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
@@ -65,6 +66,18 @@ def loopback_redirect_uri(port: int = LOOPBACK_PORT) -> str:
|
|
|
65
66
|
return f"http://127.0.0.1:{port}{LOOPBACK_REDIRECT_PATH}"
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
def open_browser(url: str) -> bool:
|
|
70
|
+
"""Open the sign-in page. The browser's own startup errors stay hidden."""
|
|
71
|
+
saved_stderr = os.dup(2)
|
|
72
|
+
try:
|
|
73
|
+
with open(os.devnull, "wb") as discarded:
|
|
74
|
+
os.dup2(discarded.fileno(), 2)
|
|
75
|
+
return bool(webbrowser.open(url))
|
|
76
|
+
finally:
|
|
77
|
+
os.dup2(saved_stderr, 2)
|
|
78
|
+
os.close(saved_stderr)
|
|
79
|
+
|
|
80
|
+
|
|
68
81
|
def login(settings: Settings, token_store: TokenStore, stderr) -> None:
|
|
69
82
|
"""Open a browser, receive the loopback redirect, and save the access token."""
|
|
70
83
|
verifier, challenge = pkce_pair()
|
|
@@ -77,9 +90,8 @@ def login(settings: Settings, token_store: TokenStore, stderr) -> None:
|
|
|
77
90
|
) from exc
|
|
78
91
|
redirect_uri = loopback_redirect_uri()
|
|
79
92
|
url = authorize_url(settings, redirect_uri, state, challenge)
|
|
80
|
-
print(f"Auth0 must allow this callback URL: {redirect_uri}", file=stderr)
|
|
81
93
|
print("Opening a browser to sign in.", file=stderr)
|
|
82
|
-
opened =
|
|
94
|
+
opened = open_browser(url)
|
|
83
95
|
if not opened:
|
|
84
96
|
print("Open this URL to sign in:", file=stderr)
|
|
85
97
|
print(url, file=stderr)
|
|
@@ -16,7 +16,7 @@ from proteworks.orgs import (
|
|
|
16
16
|
regular_organizations,
|
|
17
17
|
select_organization,
|
|
18
18
|
)
|
|
19
|
-
from proteworks.pull import pull_path
|
|
19
|
+
from proteworks.pull import export_organization, pull_path
|
|
20
20
|
from proteworks.push import push_directory
|
|
21
21
|
from proteworks.redact import install_redacting_logs
|
|
22
22
|
|
|
@@ -33,6 +33,11 @@ examples:
|
|
|
33
33
|
proteworks push ./campaigns '~/My Files/campaigns/'
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
+
_EXPORT_EXAMPLES = """\
|
|
37
|
+
examples:
|
|
38
|
+
proteworks export ./backup
|
|
39
|
+
"""
|
|
40
|
+
|
|
36
41
|
|
|
37
42
|
def build_parser() -> argparse.ArgumentParser:
|
|
38
43
|
"""Build the parser, then describe commands by walking what was registered."""
|
|
@@ -70,6 +75,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
70
75
|
push.add_argument("remote", help="Remote folder path ending with /")
|
|
71
76
|
push.add_argument("--dry-run", action="store_true", help="Print the files that would be uploaded")
|
|
72
77
|
|
|
78
|
+
export = sub.add_parser(
|
|
79
|
+
"export",
|
|
80
|
+
help="Download the active organization's files (organization admins)",
|
|
81
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
82
|
+
epilog=_EXPORT_EXAMPLES,
|
|
83
|
+
)
|
|
84
|
+
export.add_argument("dest", help="Local directory")
|
|
85
|
+
export.add_argument("--dry-run", action="store_true", help="Print the files that would be downloaded")
|
|
86
|
+
|
|
73
87
|
commands = "\n".join(f" {line}" for line in _registered_commands(parser))
|
|
74
88
|
parser.description = (
|
|
75
89
|
f"Commands:\n{commands}\n\n"
|
|
@@ -79,6 +93,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
79
93
|
" proteworks pull '~/My Files/msa/*.cif' ./msa\n"
|
|
80
94
|
" proteworks push ./1UBQ.pdb '~/My Files/test/'\n"
|
|
81
95
|
" proteworks push ./campaigns '~/My Files/campaigns/'\n"
|
|
96
|
+
" proteworks export ./backup\n"
|
|
82
97
|
)
|
|
83
98
|
return parser
|
|
84
99
|
|
|
@@ -133,6 +148,8 @@ def main(argv: list[str] | None = None, *, settings: Settings | None = None, tok
|
|
|
133
148
|
api.organization_id = org["id"]
|
|
134
149
|
if args.command == "pull":
|
|
135
150
|
return pull_path(api, args.remote, Path(args.dest), dry_run=args.dry_run, stderr=sys.stderr)
|
|
151
|
+
if args.command == "export":
|
|
152
|
+
return export_organization(api, Path(args.dest), dry_run=args.dry_run, stderr=sys.stderr)
|
|
136
153
|
if args.command == "push":
|
|
137
154
|
return push_directory(api, Path(args.local_dir), args.remote, dry_run=args.dry_run, stderr=sys.stderr)
|
|
138
155
|
parser.print_help(sys.stderr)
|
|
@@ -64,6 +64,8 @@ def sha256_bytes(payload: bytes) -> str:
|
|
|
64
64
|
def local_relative(remote: str, relative_path: str) -> str:
|
|
65
65
|
"""Path under the destination, relative to the requested remote prefix."""
|
|
66
66
|
prefix = remote.rstrip("/")
|
|
67
|
+
if not prefix:
|
|
68
|
+
return relative_path[2:] if relative_path.startswith("~/") else relative_path
|
|
67
69
|
if relative_path == prefix:
|
|
68
70
|
return Path(relative_path).name
|
|
69
71
|
marker = prefix + "/"
|
|
@@ -29,6 +29,38 @@ def pull_path(api: ApiClient, remote: str, dest: Path, *, dry_run: bool, stderr)
|
|
|
29
29
|
return 1
|
|
30
30
|
items = list(page.get("items") or [])
|
|
31
31
|
single_file = len(items) == 1 and not remote.endswith("/") and "*" not in remote and "?" not in remote
|
|
32
|
+
return download_items(api, items, dest, remote=remote, dry_run=dry_run, stderr=stderr, single_file=single_file)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def export_organization(api: ApiClient, dest: Path, *, dry_run: bool, stderr) -> int:
|
|
36
|
+
"""Download every live file in the active organization. Returns a process status code."""
|
|
37
|
+
try:
|
|
38
|
+
page = api.export_all()
|
|
39
|
+
except ApiError as exc:
|
|
40
|
+
print(str(exc), file=stderr)
|
|
41
|
+
return 1
|
|
42
|
+
return download_items(
|
|
43
|
+
api,
|
|
44
|
+
list(page.get("items") or []),
|
|
45
|
+
dest,
|
|
46
|
+
remote="",
|
|
47
|
+
dry_run=dry_run,
|
|
48
|
+
stderr=stderr,
|
|
49
|
+
single_file=False,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def download_items(
|
|
54
|
+
api: ApiClient,
|
|
55
|
+
items: list[dict],
|
|
56
|
+
dest: Path,
|
|
57
|
+
*,
|
|
58
|
+
remote: str,
|
|
59
|
+
dry_run: bool,
|
|
60
|
+
stderr,
|
|
61
|
+
single_file: bool,
|
|
62
|
+
) -> int:
|
|
63
|
+
"""Write manifest items under dest. ``remote`` is stripped from each local path."""
|
|
32
64
|
print(f"{len(items)} file(s)", file=stderr)
|
|
33
65
|
if dry_run:
|
|
34
66
|
for item in items:
|
|
@@ -5,12 +5,15 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
7
|
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
8
11
|
import threading
|
|
9
12
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
13
|
from pathlib import Path
|
|
11
14
|
from urllib.parse import parse_qs, urlparse
|
|
12
15
|
|
|
13
|
-
from proteworks.auth import loopback_redirect_uri
|
|
16
|
+
from proteworks.auth import loopback_redirect_uri, open_browser
|
|
14
17
|
from proteworks.cli import main
|
|
15
18
|
from proteworks.config import Settings
|
|
16
19
|
from proteworks.push import _iter_local_files
|
|
@@ -34,11 +37,13 @@ class MemoryTokens:
|
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
class FakeApi(threading.Thread):
|
|
37
|
-
def __init__(self, orgs, items, root_folder_id="folder-1"):
|
|
40
|
+
def __init__(self, orgs, items, root_folder_id="folder-1", export_items=None, export_error=None):
|
|
38
41
|
super().__init__(daemon=True)
|
|
39
42
|
self.orgs = orgs
|
|
40
43
|
self.items = items
|
|
41
44
|
self.root_folder_id = root_folder_id
|
|
45
|
+
self.export_items = export_items
|
|
46
|
+
self.export_error = export_error
|
|
42
47
|
self.requests = []
|
|
43
48
|
self.signed_hits = []
|
|
44
49
|
self.put_content_types = []
|
|
@@ -100,6 +105,23 @@ class FakeApi(threading.Thread):
|
|
|
100
105
|
parent.last_manifest_org = query.get("organization_id", [None])[0]
|
|
101
106
|
parent.last_manifest_path = query.get("path", [None])[0]
|
|
102
107
|
return
|
|
108
|
+
if parsed.path == "/api/v1/sync/export":
|
|
109
|
+
if parent.export_error is not None:
|
|
110
|
+
status, payload = parent.export_error
|
|
111
|
+
self._send(status, payload)
|
|
112
|
+
return
|
|
113
|
+
exported = parent.export_items or []
|
|
114
|
+
self._send(
|
|
115
|
+
200,
|
|
116
|
+
{
|
|
117
|
+
"items": exported,
|
|
118
|
+
"total": len(exported),
|
|
119
|
+
"limit": 500,
|
|
120
|
+
"offset": 0,
|
|
121
|
+
"cloud_storage_url": GS_URL,
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
return
|
|
103
125
|
if parsed.path == "/api/v1/folders/":
|
|
104
126
|
self._send(200, {"folders": [{"id": "child-1", "name": "nested"}]})
|
|
105
127
|
return
|
|
@@ -201,6 +223,31 @@ def test_login_callback_url_is_fixed():
|
|
|
201
223
|
assert loopback_redirect_uri() == "http://127.0.0.1:8765/callback"
|
|
202
224
|
|
|
203
225
|
|
|
226
|
+
def test_open_browser_hides_child_stderr(monkeypatch):
|
|
227
|
+
def noisy_open(_url: str) -> bool:
|
|
228
|
+
subprocess.run(
|
|
229
|
+
[sys.executable, "-c", "import os; os.write(2, b'zygote broken pipe\\n')"],
|
|
230
|
+
check=False,
|
|
231
|
+
)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
monkeypatch.setattr("proteworks.auth.webbrowser.open", noisy_open)
|
|
235
|
+
read_fd, write_fd = os.pipe()
|
|
236
|
+
saved = os.dup(2)
|
|
237
|
+
os.dup2(write_fd, 2)
|
|
238
|
+
os.close(write_fd)
|
|
239
|
+
try:
|
|
240
|
+
assert open_browser("https://example.test/authorize") is True
|
|
241
|
+
os.write(2, b"still open\n")
|
|
242
|
+
finally:
|
|
243
|
+
os.dup2(saved, 2)
|
|
244
|
+
os.close(saved)
|
|
245
|
+
captured = os.read(read_fd, 1024)
|
|
246
|
+
os.close(read_fd)
|
|
247
|
+
assert b"zygote" not in captured
|
|
248
|
+
assert b"still open" in captured
|
|
249
|
+
|
|
250
|
+
|
|
204
251
|
def test_root_help_lists_commands(capsys):
|
|
205
252
|
try:
|
|
206
253
|
main(["--help"])
|
|
@@ -216,12 +263,14 @@ def test_root_help_lists_commands(capsys):
|
|
|
216
263
|
assert "proteworks pull '~/My Files/campaigns/' ./campaigns" in text
|
|
217
264
|
assert "proteworks pull '~/My Files/msa/*.cif' ./msa" in text
|
|
218
265
|
assert "proteworks push ./campaigns '~/My Files/campaigns/'" in text
|
|
266
|
+
assert "proteworks export ./backup" in text
|
|
219
267
|
|
|
220
268
|
|
|
221
269
|
def test_pull_and_push_help_show_examples(capsys):
|
|
222
270
|
for command, example in (
|
|
223
271
|
("pull", "proteworks pull '~/My Files/campaigns/' ./campaigns"),
|
|
224
272
|
("push", "proteworks push ./campaigns '~/My Files/campaigns/'"),
|
|
273
|
+
("export", "proteworks export ./backup"),
|
|
225
274
|
):
|
|
226
275
|
try:
|
|
227
276
|
main([command, "--help"])
|
|
@@ -361,6 +410,56 @@ def test_second_pull_skips_file_without_checksum_when_size_matches(tmp_path, cap
|
|
|
361
410
|
assert "skip designs.studio" in err
|
|
362
411
|
|
|
363
412
|
|
|
413
|
+
def test_export_dry_run_lists_users_and_shared_spaces_without_signed_urls(tmp_path, capsys):
|
|
414
|
+
personal = _item("users/Member/member.cif")
|
|
415
|
+
shared = _item("shared/Lab Share/shared.cif")
|
|
416
|
+
shared["file_id"] = "file-2"
|
|
417
|
+
server = FakeApi(
|
|
418
|
+
[_org("reg", "Research")],
|
|
419
|
+
[],
|
|
420
|
+
export_items=[personal, shared],
|
|
421
|
+
).start()
|
|
422
|
+
try:
|
|
423
|
+
code = main(
|
|
424
|
+
["export", str(tmp_path / "backup"), "--dry-run"],
|
|
425
|
+
settings=_settings(tmp_path, server.url),
|
|
426
|
+
token_store=MemoryTokens(),
|
|
427
|
+
)
|
|
428
|
+
finally:
|
|
429
|
+
server.stop()
|
|
430
|
+
err = capsys.readouterr().err
|
|
431
|
+
assert code == 0
|
|
432
|
+
assert "users/Member/member.cif" in err
|
|
433
|
+
assert "shared/Lab Share/shared.cif" in err
|
|
434
|
+
assert server.signed_hits == []
|
|
435
|
+
assert not any(path == "/api/v1/files/content-urls" for method, path, _body in server.requests if method == "POST")
|
|
436
|
+
assert "gs://" not in err
|
|
437
|
+
assert "secret-bucket" not in err
|
|
438
|
+
assert "storage.googleapis.com" not in err
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_export_not_found_hides_storage_locations(tmp_path, capsys):
|
|
442
|
+
server = FakeApi(
|
|
443
|
+
[_org("reg", "Research")],
|
|
444
|
+
[],
|
|
445
|
+
export_error=(404, {"detail": "gs://secret-bucket/org/users/file", "cloud_storage_url": GS_URL}),
|
|
446
|
+
).start()
|
|
447
|
+
try:
|
|
448
|
+
code = main(
|
|
449
|
+
["export", str(tmp_path / "backup")],
|
|
450
|
+
settings=_settings(tmp_path, server.url),
|
|
451
|
+
token_store=MemoryTokens(),
|
|
452
|
+
)
|
|
453
|
+
finally:
|
|
454
|
+
server.stop()
|
|
455
|
+
err = capsys.readouterr().err
|
|
456
|
+
assert code == 1
|
|
457
|
+
assert "gs://" not in err
|
|
458
|
+
assert "secret-bucket" not in err
|
|
459
|
+
assert "storage.googleapis.com" not in err
|
|
460
|
+
assert "cloud_storage_url" not in err
|
|
461
|
+
|
|
462
|
+
|
|
364
463
|
def test_pull_hash_mismatch_deletes_partial(tmp_path, capsys):
|
|
365
464
|
bad = dict(_item())
|
|
366
465
|
bad["sha256"] = "0" * 64
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|