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.
Files changed (21) hide show
  1. {proteworks-0.1.0 → proteworks-0.1.1}/PKG-INFO +1 -1
  2. {proteworks-0.1.0 → proteworks-0.1.1}/pyproject.toml +1 -1
  3. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/api.py +22 -0
  4. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/cli.py +18 -1
  5. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/index.py +2 -0
  6. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/pull.py +32 -0
  7. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/PKG-INFO +1 -1
  8. {proteworks-0.1.0 → proteworks-0.1.1}/tests/test_cli.py +72 -1
  9. {proteworks-0.1.0 → proteworks-0.1.1}/README.md +0 -0
  10. {proteworks-0.1.0 → proteworks-0.1.1}/setup.cfg +0 -0
  11. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/__init__.py +0 -0
  12. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/auth.py +0 -0
  13. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/config.py +0 -0
  14. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/orgs.py +0 -0
  15. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/push.py +0 -0
  16. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks/redact.py +0 -0
  17. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/SOURCES.txt +0 -0
  18. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/dependency_links.txt +0 -0
  19. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/entry_points.txt +0 -0
  20. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/requires.txt +0 -0
  21. {proteworks-0.1.0 → proteworks-0.1.1}/src/proteworks.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proteworks
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Sync files with Proteworks using file-browser paths.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proteworks"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "Sync files with Proteworks using file-browser paths."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proteworks
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Sync files with Proteworks using file-browser paths.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -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