proteworks 0.1.1__tar.gz → 0.1.3__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.1 → proteworks-0.1.3}/PKG-INFO +1 -1
  2. {proteworks-0.1.1 → proteworks-0.1.3}/pyproject.toml +1 -1
  3. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/api.py +8 -4
  4. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/auth.py +14 -2
  5. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/pull.py +11 -2
  6. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/push.py +4 -1
  7. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks.egg-info/PKG-INFO +1 -1
  8. {proteworks-0.1.1 → proteworks-0.1.3}/tests/test_cli.py +68 -8
  9. {proteworks-0.1.1 → proteworks-0.1.3}/README.md +0 -0
  10. {proteworks-0.1.1 → proteworks-0.1.3}/setup.cfg +0 -0
  11. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/__init__.py +0 -0
  12. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/cli.py +0 -0
  13. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/config.py +0 -0
  14. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/index.py +0 -0
  15. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/orgs.py +0 -0
  16. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks/redact.py +0 -0
  17. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks.egg-info/SOURCES.txt +0 -0
  18. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks.egg-info/dependency_links.txt +0 -0
  19. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks.egg-info/entry_points.txt +0 -0
  20. {proteworks-0.1.1 → proteworks-0.1.3}/src/proteworks.egg-info/requires.txt +0 -0
  21. {proteworks-0.1.1 → proteworks-0.1.3}/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.1
3
+ Version: 0.1.3
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.1"
3
+ version = "0.1.3"
4
4
  description = "Sync files with Proteworks using file-browser paths."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -88,19 +88,21 @@ class ApiClient:
88
88
  params={"path": path, "offset": offset, "limit": limit},
89
89
  )
90
90
 
91
- def manifest_all(self, path: str) -> dict:
91
+ def manifest_all(self, path: str, *, on_list_progress=None, page_size: int = 500) -> dict:
92
92
  """Follow pages. Returns items plus root_folder_id from the first page."""
93
93
  offset = 0
94
94
  items: list[dict] = []
95
95
  root_folder_id = None
96
96
  total = None
97
97
  while True:
98
- page = self.manifest(path, offset)
98
+ page = self.manifest(path, offset, limit=page_size)
99
99
  root_folder_id = root_folder_id or page.get("root_folder_id")
100
100
  batch = list(page.get("items") or [])
101
101
  items.extend(batch)
102
102
  total = int(page.get("total") or 0)
103
103
  offset += len(batch)
104
+ if on_list_progress is not None:
105
+ on_list_progress(offset, total)
104
106
  if not batch or offset >= total:
105
107
  break
106
108
  return {"items": items, "total": total or 0, "root_folder_id": root_folder_id}
@@ -112,17 +114,19 @@ class ApiClient:
112
114
  params={"offset": offset, "limit": limit},
113
115
  )
114
116
 
115
- def export_all(self) -> dict:
117
+ def export_all(self, *, on_list_progress=None, page_size: int = 500) -> dict:
116
118
  """Follow export pages. Returns every item for the active organization."""
117
119
  offset = 0
118
120
  items: list[dict] = []
119
121
  total = None
120
122
  while True:
121
- page = self.export_page(offset)
123
+ page = self.export_page(offset, limit=page_size)
122
124
  batch = list(page.get("items") or [])
123
125
  items.extend(batch)
124
126
  total = int(page.get("total") or 0)
125
127
  offset += len(batch)
128
+ if on_list_progress is not None:
129
+ on_list_progress(offset, total)
126
130
  if not batch or offset >= total:
127
131
  break
128
132
  return {"items": items, "total": total or 0}
@@ -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)
@@ -20,10 +20,19 @@ from proteworks.index import (
20
20
  _CONTENT_URL_BATCH = 100
21
21
 
22
22
 
23
+ def _list_progress(stderr):
24
+ """Print how many paths the API has returned so far."""
25
+
26
+ def report(listed: int, total: int) -> None:
27
+ print(f"Listed {listed}/{total}", file=stderr)
28
+
29
+ return report
30
+
31
+
23
32
  def pull_path(api: ApiClient, remote: str, dest: Path, *, dry_run: bool, stderr) -> int:
24
33
  """Download readable files for a file-browser path. Returns a process status code."""
25
34
  try:
26
- page = api.manifest_all(remote)
35
+ page = api.manifest_all(remote, on_list_progress=_list_progress(stderr))
27
36
  except ApiError as exc:
28
37
  print(str(exc), file=stderr)
29
38
  return 1
@@ -35,7 +44,7 @@ def pull_path(api: ApiClient, remote: str, dest: Path, *, dry_run: bool, stderr)
35
44
  def export_organization(api: ApiClient, dest: Path, *, dry_run: bool, stderr) -> int:
36
45
  """Download every live file in the active organization. Returns a process status code."""
37
46
  try:
38
- page = api.export_all()
47
+ page = api.export_all(on_list_progress=_list_progress(stderr))
39
48
  except ApiError as exc:
40
49
  print(str(exc), file=stderr)
41
50
  return 1
@@ -20,7 +20,10 @@ def push_directory(api: ApiClient, local_dir: Path, remote: str, *, dry_run: boo
20
20
  print(str(exc), file=stderr)
21
21
  return 2
22
22
  try:
23
- page = api.manifest_all(remote)
23
+ page = api.manifest_all(
24
+ remote,
25
+ on_list_progress=lambda listed, total: print(f"Listed {listed}/{total}", file=stderr),
26
+ )
24
27
  except ApiError as exc:
25
28
  print(str(exc), file=stderr)
26
29
  return 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proteworks
3
- Version: 0.1.1
3
+ Version: 0.1.3
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,16 @@ 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.api import ApiClient
17
+ from proteworks.auth import loopback_redirect_uri, open_browser
14
18
  from proteworks.cli import main
15
19
  from proteworks.config import Settings
16
20
  from proteworks.push import _iter_local_files
@@ -88,13 +92,16 @@ class FakeApi(threading.Thread):
88
92
  return
89
93
  if parsed.path == "/api/v1/sync/manifest":
90
94
  query = parse_qs(parsed.query)
95
+ offset = int((query.get("offset") or ["0"])[0])
96
+ limit = int((query.get("limit") or ["500"])[0])
97
+ page = parent.items[offset : offset + limit]
91
98
  self._send(
92
99
  200,
93
100
  {
94
- "items": parent.items,
101
+ "items": page,
95
102
  "total": len(parent.items),
96
- "limit": 500,
97
- "offset": 0,
103
+ "limit": limit,
104
+ "offset": offset,
98
105
  "root_folder_id": parent.root_folder_id,
99
106
  "cloud_storage_url": GS_URL,
100
107
  },
@@ -107,14 +114,18 @@ class FakeApi(threading.Thread):
107
114
  status, payload = parent.export_error
108
115
  self._send(status, payload)
109
116
  return
117
+ query = parse_qs(parsed.query)
118
+ offset = int((query.get("offset") or ["0"])[0])
119
+ limit = int((query.get("limit") or ["500"])[0])
110
120
  exported = parent.export_items or []
121
+ page = exported[offset : offset + limit]
111
122
  self._send(
112
123
  200,
113
124
  {
114
- "items": exported,
125
+ "items": page,
115
126
  "total": len(exported),
116
- "limit": 500,
117
- "offset": 0,
127
+ "limit": limit,
128
+ "offset": offset,
118
129
  "cloud_storage_url": GS_URL,
119
130
  },
120
131
  )
@@ -220,6 +231,31 @@ def test_login_callback_url_is_fixed():
220
231
  assert loopback_redirect_uri() == "http://127.0.0.1:8765/callback"
221
232
 
222
233
 
234
+ def test_open_browser_hides_child_stderr(monkeypatch):
235
+ def noisy_open(_url: str) -> bool:
236
+ subprocess.run(
237
+ [sys.executable, "-c", "import os; os.write(2, b'zygote broken pipe\\n')"],
238
+ check=False,
239
+ )
240
+ return True
241
+
242
+ monkeypatch.setattr("proteworks.auth.webbrowser.open", noisy_open)
243
+ read_fd, write_fd = os.pipe()
244
+ saved = os.dup(2)
245
+ os.dup2(write_fd, 2)
246
+ os.close(write_fd)
247
+ try:
248
+ assert open_browser("https://example.test/authorize") is True
249
+ os.write(2, b"still open\n")
250
+ finally:
251
+ os.dup2(saved, 2)
252
+ os.close(saved)
253
+ captured = os.read(read_fd, 1024)
254
+ os.close(read_fd)
255
+ assert b"zygote" not in captured
256
+ assert b"still open" in captured
257
+
258
+
223
259
  def test_root_help_lists_commands(capsys):
224
260
  try:
225
261
  main(["--help"])
@@ -305,7 +341,29 @@ def test_pull_with_two_regular_orgs_stops_before_manifest(tmp_path, capsys):
305
341
  assert not any(path == "/api/v1/sync/manifest" for _method, path, _body in server.requests)
306
342
 
307
343
 
308
- def test_pull_mints_download_urls_in_one_batch(tmp_path):
344
+ def test_manifest_all_reports_listing_progress():
345
+ items = []
346
+ for index in range(5):
347
+ item = _item(f"~/My Files/msa/{index}.cif")
348
+ item["file_id"] = f"f{index}"
349
+ items.append(item)
350
+ server = FakeApi([_org("reg", "Research")], items).start()
351
+ progress = []
352
+ try:
353
+ api = ApiClient(server.url, "token", organization_id="reg")
354
+ page = api.manifest_all(
355
+ "~/My Files/msa/",
356
+ on_list_progress=lambda listed, total: progress.append((listed, total)),
357
+ page_size=2,
358
+ )
359
+ finally:
360
+ api.close()
361
+ server.stop()
362
+ assert len(page["items"]) == 5
363
+ assert progress == [(2, 5), (4, 5), (5, 5)]
364
+
365
+
366
+ def test_pull_mints_download_urls_in_one_batch(tmp_path, capsys):
309
367
  items = []
310
368
  for name in ("a.cif", "b.cif", "c.cif"):
311
369
  item = _item(f"~/My Files/msa/{name}")
@@ -327,7 +385,9 @@ def test_pull_mints_download_urls_in_one_batch(tmp_path):
327
385
  for method, path, body in server.requests
328
386
  if method == "POST" and path == "/api/v1/files/content-urls"
329
387
  ]
388
+ err = capsys.readouterr().err
330
389
  assert code == 0
390
+ assert "Listed 3/3" in err
331
391
  assert len(posts) == 1
332
392
  assert set(posts[0]["file_ids"]) == {"a.cif", "b.cif", "c.cif"}
333
393
  assert posts[0]["purpose"] == "download"
File without changes
File without changes