proteworks 0.1.0__tar.gz → 0.1.1__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.1}/PKG-INFO +1 -1
- {proteworks-0.1.0 → proteworks-0.1.1}/pyproject.toml +1 -1
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/api.py +22 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/cli.py +18 -1
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/index.py +2 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/pull.py +32 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/PKG-INFO +1 -1
- {proteworks-0.1.0 → proteworks-0.1.1}/tests/test_cli.py +72 -1
- {proteworks-0.1.0 → proteworks-0.1.1}/README.md +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/setup.cfg +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/__init__.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/auth.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/config.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/orgs.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/push.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/redact.py +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/SOURCES.txt +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/dependency_links.txt +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/entry_points.txt +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/requires.txt +0 -0
- {proteworks-0.1.0 → proteworks-0.1.1}/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(
|
|
@@ -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:
|
|
@@ -34,11 +34,13 @@ class MemoryTokens:
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
class FakeApi(threading.Thread):
|
|
37
|
-
def __init__(self, orgs, items, root_folder_id="folder-1"):
|
|
37
|
+
def __init__(self, orgs, items, root_folder_id="folder-1", export_items=None, export_error=None):
|
|
38
38
|
super().__init__(daemon=True)
|
|
39
39
|
self.orgs = orgs
|
|
40
40
|
self.items = items
|
|
41
41
|
self.root_folder_id = root_folder_id
|
|
42
|
+
self.export_items = export_items
|
|
43
|
+
self.export_error = export_error
|
|
42
44
|
self.requests = []
|
|
43
45
|
self.signed_hits = []
|
|
44
46
|
self.put_content_types = []
|
|
@@ -100,6 +102,23 @@ class FakeApi(threading.Thread):
|
|
|
100
102
|
parent.last_manifest_org = query.get("organization_id", [None])[0]
|
|
101
103
|
parent.last_manifest_path = query.get("path", [None])[0]
|
|
102
104
|
return
|
|
105
|
+
if parsed.path == "/api/v1/sync/export":
|
|
106
|
+
if parent.export_error is not None:
|
|
107
|
+
status, payload = parent.export_error
|
|
108
|
+
self._send(status, payload)
|
|
109
|
+
return
|
|
110
|
+
exported = parent.export_items or []
|
|
111
|
+
self._send(
|
|
112
|
+
200,
|
|
113
|
+
{
|
|
114
|
+
"items": exported,
|
|
115
|
+
"total": len(exported),
|
|
116
|
+
"limit": 500,
|
|
117
|
+
"offset": 0,
|
|
118
|
+
"cloud_storage_url": GS_URL,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
return
|
|
103
122
|
if parsed.path == "/api/v1/folders/":
|
|
104
123
|
self._send(200, {"folders": [{"id": "child-1", "name": "nested"}]})
|
|
105
124
|
return
|
|
@@ -216,12 +235,14 @@ def test_root_help_lists_commands(capsys):
|
|
|
216
235
|
assert "proteworks pull '~/My Files/campaigns/' ./campaigns" in text
|
|
217
236
|
assert "proteworks pull '~/My Files/msa/*.cif' ./msa" in text
|
|
218
237
|
assert "proteworks push ./campaigns '~/My Files/campaigns/'" in text
|
|
238
|
+
assert "proteworks export ./backup" in text
|
|
219
239
|
|
|
220
240
|
|
|
221
241
|
def test_pull_and_push_help_show_examples(capsys):
|
|
222
242
|
for command, example in (
|
|
223
243
|
("pull", "proteworks pull '~/My Files/campaigns/' ./campaigns"),
|
|
224
244
|
("push", "proteworks push ./campaigns '~/My Files/campaigns/'"),
|
|
245
|
+
("export", "proteworks export ./backup"),
|
|
225
246
|
):
|
|
226
247
|
try:
|
|
227
248
|
main([command, "--help"])
|
|
@@ -361,6 +382,56 @@ def test_second_pull_skips_file_without_checksum_when_size_matches(tmp_path, cap
|
|
|
361
382
|
assert "skip designs.studio" in err
|
|
362
383
|
|
|
363
384
|
|
|
385
|
+
def test_export_dry_run_lists_users_and_shared_spaces_without_signed_urls(tmp_path, capsys):
|
|
386
|
+
personal = _item("users/Member/member.cif")
|
|
387
|
+
shared = _item("shared/Lab Share/shared.cif")
|
|
388
|
+
shared["file_id"] = "file-2"
|
|
389
|
+
server = FakeApi(
|
|
390
|
+
[_org("reg", "Research")],
|
|
391
|
+
[],
|
|
392
|
+
export_items=[personal, shared],
|
|
393
|
+
).start()
|
|
394
|
+
try:
|
|
395
|
+
code = main(
|
|
396
|
+
["export", str(tmp_path / "backup"), "--dry-run"],
|
|
397
|
+
settings=_settings(tmp_path, server.url),
|
|
398
|
+
token_store=MemoryTokens(),
|
|
399
|
+
)
|
|
400
|
+
finally:
|
|
401
|
+
server.stop()
|
|
402
|
+
err = capsys.readouterr().err
|
|
403
|
+
assert code == 0
|
|
404
|
+
assert "users/Member/member.cif" in err
|
|
405
|
+
assert "shared/Lab Share/shared.cif" in err
|
|
406
|
+
assert server.signed_hits == []
|
|
407
|
+
assert not any(path == "/api/v1/files/content-urls" for method, path, _body in server.requests if method == "POST")
|
|
408
|
+
assert "gs://" not in err
|
|
409
|
+
assert "secret-bucket" not in err
|
|
410
|
+
assert "storage.googleapis.com" not in err
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_export_not_found_hides_storage_locations(tmp_path, capsys):
|
|
414
|
+
server = FakeApi(
|
|
415
|
+
[_org("reg", "Research")],
|
|
416
|
+
[],
|
|
417
|
+
export_error=(404, {"detail": "gs://secret-bucket/org/users/file", "cloud_storage_url": GS_URL}),
|
|
418
|
+
).start()
|
|
419
|
+
try:
|
|
420
|
+
code = main(
|
|
421
|
+
["export", str(tmp_path / "backup")],
|
|
422
|
+
settings=_settings(tmp_path, server.url),
|
|
423
|
+
token_store=MemoryTokens(),
|
|
424
|
+
)
|
|
425
|
+
finally:
|
|
426
|
+
server.stop()
|
|
427
|
+
err = capsys.readouterr().err
|
|
428
|
+
assert code == 1
|
|
429
|
+
assert "gs://" not in err
|
|
430
|
+
assert "secret-bucket" not in err
|
|
431
|
+
assert "storage.googleapis.com" not in err
|
|
432
|
+
assert "cloud_storage_url" not in err
|
|
433
|
+
|
|
434
|
+
|
|
364
435
|
def test_pull_hash_mismatch_deletes_partial(tmp_path, capsys):
|
|
365
436
|
bad = dict(_item())
|
|
366
437
|
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
|
|
File without changes
|