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