Skip to content

Commit d178675

Browse files
authored
python(feat): add progress indicators for job polling and file downlo… (#517)
1 parent 4003b91 commit d178675

8 files changed

Lines changed: 208 additions & 15 deletions

File tree

python/lib/sift_client/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,18 @@ async def main():
132132
5. **Use type hints** to get full IDE support and catch errors early
133133
"""
134134

135+
from __future__ import annotations
136+
135137
import logging
136138

137139
from sift_client.client import SiftClient
140+
from sift_client.config import config
138141
from sift_client.transport import SiftConnectionConfig
139142

140143
__all__ = [
141144
"SiftClient",
142145
"SiftConnectionConfig",
146+
"config",
143147
]
144148

145149
logging.getLogger(__name__).addHandler(logging.NullHandler())

python/lib/sift_client/_internal/sync_wrapper.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def generate_sync_api(cls: type[ResourceBase], sync_name: str) -> type:
5050
@wraps(orig_init)
5151
def __init__(self, *args, **kwargs): # noqa: N807
5252
self._async_impl = cls(*args, **kwargs)
53+
self._async_impl._is_sync = True
5354

5455
def _run(self, coro):
5556
loop = self._async_impl.client.get_asyncio_loop()

python/lib/sift_client/_internal/util/file.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import zipfile
55
from typing import TYPE_CHECKING
66

7+
from alive_progress import alive_bar # type: ignore[import-untyped]
8+
79
from sift_client.errors import SiftWarning
810

911
if TYPE_CHECKING:
@@ -12,13 +14,21 @@
1214
from sift_client.transport.rest_transport import RestClient
1315

1416

15-
def download_file(signed_url: str, output_path: Path, *, rest_client: RestClient) -> Path:
17+
def download_file(
18+
signed_url: str,
19+
output_path: Path,
20+
*,
21+
rest_client: RestClient,
22+
show_progress: bool = False,
23+
) -> Path:
1624
"""Download a file from a URL in streaming 4 MiB chunks.
1725
1826
Args:
1927
url: The URL to download from.
2028
dest: Path where the file will be saved. Parent directories are created if needed.
2129
rest_client: The SDK rest client to use for the download.
30+
show_progress: If True, display a progress bar during download.
31+
Defaults to False.
2232
2333
Returns:
2434
The path to the downloaded file.
@@ -30,10 +40,21 @@ def download_file(signed_url: str, output_path: Path, *, rest_client: RestClient
3040
# Strip the session's default Authorization header, presigned URLs carry their own auth
3141
with rest_client.get(signed_url, stream=True, headers={"Authorization": None}) as response:
3242
response.raise_for_status()
33-
with output_path.open("wb") as file:
34-
for chunk in response.iter_content(chunk_size=4194304): # 4 MiB
35-
if chunk:
36-
file.write(chunk)
43+
total_bytes = int(response.headers.get("Content-Length", 0)) or None
44+
with alive_bar(
45+
total_bytes,
46+
title="Downloading",
47+
spinner="dots_waves",
48+
spinner_length=7,
49+
unit="B",
50+
scale="SI",
51+
disable=not show_progress,
52+
) as bar:
53+
with output_path.open("wb") as file:
54+
for chunk in response.iter_content(chunk_size=4194304): # 4 MiB
55+
if chunk:
56+
file.write(chunk)
57+
bar(len(chunk))
3758
return output_path
3859

3960

python/lib/sift_client/_tests/resources/test_jobs.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Error handling and edge cases
88
"""
99

10+
import asyncio
1011
from datetime import datetime, timedelta, timezone
1112
from unittest.mock import AsyncMock, MagicMock, patch
1213

@@ -393,6 +394,24 @@ async def test_raises_timeout_error_when_not_complete_in_time(self, jobs_api_asy
393394
timeout_secs=0.1,
394395
)
395396

397+
@pytest.mark.asyncio
398+
async def test_concurrent_wait_with_progress_disabled(self, jobs_api_async):
399+
"""Concurrent wait_until_complete calls with show_progress=False should not raise."""
400+
mock_job = MagicMock()
401+
mock_job.job_status = JobStatus.FINISHED
402+
403+
with patch(
404+
"sift_client.resources.jobs.JobsAPIAsync.get",
405+
new_callable=AsyncMock,
406+
return_value=mock_job,
407+
):
408+
results = await asyncio.gather(
409+
jobs_api_async.wait_until_complete(job="job-1", show_progress=False),
410+
jobs_api_async.wait_until_complete(job="job-2", show_progress=False),
411+
)
412+
413+
assert all(r.job_status == JobStatus.FINISHED for r in results)
414+
396415
class TestJobProperties:
397416
"""Tests for job property methods."""
398417

@@ -527,6 +546,58 @@ def test_basic_list(self, jobs_api_sync):
527546
if jobs:
528547
assert isinstance(jobs[0], Job)
529548

549+
class TestWaitUntilComplete:
550+
"""Tests for wait_until_complete through the sync wrapper."""
551+
552+
def test_wait_defaults_to_progress_enabled(self, jobs_api_sync):
553+
"""Sync wrapper defaults to show_progress=True when no kwarg is passed."""
554+
mock_job = MagicMock()
555+
mock_job.job_status = JobStatus.FINISHED
556+
557+
with patch(
558+
"sift_client.resources.jobs.JobsAPIAsync.get",
559+
new_callable=AsyncMock,
560+
return_value=mock_job,
561+
):
562+
result = jobs_api_sync.wait_until_complete(job="job-1")
563+
564+
assert result.job_status == JobStatus.FINISHED
565+
566+
def test_wait_with_progress_explicit_false(self, jobs_api_sync):
567+
"""Explicit show_progress=False overrides the sync default."""
568+
mock_job = MagicMock()
569+
mock_job.job_status = JobStatus.FINISHED
570+
571+
with patch(
572+
"sift_client.resources.jobs.JobsAPIAsync.get",
573+
new_callable=AsyncMock,
574+
return_value=mock_job,
575+
):
576+
result = jobs_api_sync.wait_until_complete(job="job-1", show_progress=False)
577+
578+
assert result.job_status == JobStatus.FINISHED
579+
580+
def test_namespace_override_disables_progress(self, jobs_api_sync):
581+
"""Setting sift_client.config.show_progress=False overrides the sync default."""
582+
import sift_client
583+
584+
mock_job = MagicMock()
585+
mock_job.job_status = JobStatus.FINISHED
586+
587+
original = sift_client.config.show_progress
588+
try:
589+
sift_client.config.show_progress = False
590+
with patch(
591+
"sift_client.resources.jobs.JobsAPIAsync.get",
592+
new_callable=AsyncMock,
593+
return_value=mock_job,
594+
):
595+
result = jobs_api_sync.wait_until_complete(job="job-1")
596+
finally:
597+
sift_client.config.show_progress = original
598+
599+
assert result.job_status == JobStatus.FINISHED
600+
530601

531602
class TestWaitAndDownload:
532603
@pytest.mark.asyncio

python/lib/sift_client/config.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Global configuration for the Sift client library."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, fields
6+
7+
8+
@dataclass
9+
class Config:
10+
"""Global configuration for the Sift client library.
11+
12+
This is a singleton dataclass, use the module-level ``config`` instance
13+
rather than creating your own::
14+
15+
import sift_client
16+
17+
sift_client.config.show_progress = False
18+
19+
Setting an attribute that doesn't exist raises ``AttributeError`` so
20+
typos are caught immediately.
21+
22+
"""
23+
24+
show_progress: bool | None = None
25+
"""Controls progress-bar display for job polling and file downloads.
26+
27+
``None`` (default) shows bars for sync calls and hides them for async.
28+
Set to ``False`` to disable everywhere.
29+
"""
30+
31+
def __setattr__(self, name: str, value: object) -> None:
32+
if name not in {f.name for f in fields(self)}:
33+
raise AttributeError(f"Unknown setting: {name!r}")
34+
super().__setattr__(name, value)
35+
36+
37+
config = Config()

python/lib/sift_client/resources/jobs.py

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from pathlib import Path
88
from typing import TYPE_CHECKING
99

10+
from alive_progress import alive_bar # type: ignore[import-untyped]
11+
12+
import sift_client as _sift_client_module
1013
from sift_client._internal.low_level_wrappers.jobs import JobsLowLevelClient
1114
from sift_client._internal.util.executor import run_sync_function
1215
from sift_client._internal.util.file import download_file, extract_zip
@@ -169,6 +172,7 @@ async def wait_until_complete(
169172
*,
170173
polling_interval_secs: int = 5,
171174
timeout_secs: int | None = None,
175+
show_progress: bool | None = None,
172176
) -> Job:
173177
"""Wait until the job is complete or the timeout is reached.
174178
@@ -180,20 +184,45 @@ async def wait_until_complete(
180184
polling_interval_secs: Seconds between status polls. Defaults to 5s.
181185
timeout_secs: Maximum seconds to wait. If None, polls indefinitely.
182186
Defaults to None (indefinite).
187+
show_progress: If True, display an animated progress spinner alongside
188+
the job status while polling. Defaults to True for sync, False
189+
for async. Use ``sift_client.config.show_progress = False`` to disable
190+
globally for sync.
183191
184192
Returns:
185193
The Job in the completed state.
186194
"""
187195
job_id = job._id_or_error if isinstance(job, Job) else job
196+
if show_progress is None:
197+
global_setting = _sift_client_module.config.show_progress
198+
if global_setting is not None:
199+
show_progress = global_setting
200+
elif getattr(self, "_is_sync", False):
201+
show_progress = True
202+
else:
203+
show_progress = False
188204

189205
start = time.monotonic()
190-
while True:
191-
job = await self.get(job_id)
192-
if job.job_status in (JobStatus.FINISHED, JobStatus.FAILED, JobStatus.CANCELLED):
193-
return job
194-
if timeout_secs is not None and (time.monotonic() - start) >= timeout_secs:
195-
raise TimeoutError(f"Job {job_id} did not complete within {timeout_secs} seconds")
196-
await asyncio.sleep(polling_interval_secs)
206+
with alive_bar(
207+
title=f"Job {job_id}: polling",
208+
bar=None,
209+
spinner_length=7,
210+
spinner="dots_waves",
211+
monitor=False,
212+
stats=False,
213+
disable=not show_progress,
214+
) as bar:
215+
while True:
216+
job = await self.get(job_id)
217+
bar.title(f"Job {job_id} ({job.job_type.value.lower()}): {job.job_status.value}")
218+
bar()
219+
if job.job_status in (JobStatus.FINISHED, JobStatus.FAILED, JobStatus.CANCELLED):
220+
return job
221+
if timeout_secs is not None and (time.monotonic() - start) >= timeout_secs:
222+
raise TimeoutError(
223+
f"Job {job_id} did not complete within {timeout_secs} seconds"
224+
)
225+
await asyncio.sleep(polling_interval_secs)
197226

198227
async def wait_and_download(
199228
self,
@@ -203,6 +232,7 @@ async def wait_and_download(
203232
timeout_secs: int | None = None,
204233
output_dir: str | Path | None = None,
205234
extract: bool = True,
235+
show_progress: bool | None = None,
206236
) -> list[Path]:
207237
"""Wait for a job to complete and download the result files.
208238
@@ -219,6 +249,10 @@ async def wait_and_download(
219249
extract it and delete the archive, returning paths to the
220250
extracted files. Non-zip files are returned as-is regardless
221251
of this flag.
252+
show_progress: If True, display an animated progress spinner
253+
while waiting and a download progress bar. Defaults to True
254+
for sync, False for async. Use ``sift_client.config.show_progress = False``
255+
to disable globally for sync.
222256
223257
Returns:
224258
List of paths to the downloaded/extracted files.
@@ -228,11 +262,20 @@ async def wait_and_download(
228262
TimeoutError: If the job does not complete within timeout_secs.
229263
"""
230264
job_id = job._id_or_error if isinstance(job, Job) else job
265+
if show_progress is None:
266+
global_setting = _sift_client_module.config.show_progress
267+
if global_setting is not None:
268+
show_progress = global_setting
269+
elif getattr(self, "_is_sync", False):
270+
show_progress = True
271+
else:
272+
show_progress = False
231273

232274
completed_job = await self.wait_until_complete(
233275
job=job_id,
234276
polling_interval_secs=polling_interval_secs,
235277
timeout_secs=timeout_secs,
278+
show_progress=show_progress,
236279
)
237280
if completed_job.job_status == JobStatus.FAILED:
238281
if (
@@ -259,7 +302,9 @@ async def wait_and_download(
259302
# Run the synchronous download in a thread pool to avoid blocking the event loop
260303
rest_client = self.client.rest_client
261304
await run_sync_function(
262-
lambda: download_file(presigned_url, download_path, rest_client=rest_client)
305+
lambda: download_file(
306+
presigned_url, download_path, rest_client=rest_client, show_progress=show_progress
307+
)
263308
)
264309

265310
if not extract or not zipfile.is_zipfile(download_path):

python/lib/sift_client/resources/sync_stubs/__init__.pyi

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,7 @@ class JobsAPI:
859859
timeout_secs: int | None = None,
860860
output_dir: str | Path | None = None,
861861
extract: bool = True,
862+
show_progress: bool | None = None,
862863
) -> list[Path]:
863864
"""Wait for a job to complete and download the result files.
864865
@@ -875,6 +876,10 @@ class JobsAPI:
875876
extract it and delete the archive, returning paths to the
876877
extracted files. Non-zip files are returned as-is regardless
877878
of this flag.
879+
show_progress: If True, display an animated progress spinner
880+
while waiting and a download progress bar. Defaults to True
881+
for sync, False for async. Use ``sift_client.config.show_progress = False``
882+
to disable globally for sync.
878883
879884
Returns:
880885
List of paths to the downloaded/extracted files.
@@ -886,7 +891,12 @@ class JobsAPI:
886891
...
887892

888893
def wait_until_complete(
889-
self, job: Job | str, *, polling_interval_secs: int = 5, timeout_secs: int | None = None
894+
self,
895+
job: Job | str,
896+
*,
897+
polling_interval_secs: int = 5,
898+
timeout_secs: int | None = None,
899+
show_progress: bool | None = None,
890900
) -> Job:
891901
"""Wait until the job is complete or the timeout is reached.
892902
@@ -898,6 +908,10 @@ class JobsAPI:
898908
polling_interval_secs: Seconds between status polls. Defaults to 5s.
899909
timeout_secs: Maximum seconds to wait. If None, polls indefinitely.
900910
Defaults to None (indefinite).
911+
show_progress: If True, display an animated progress spinner alongside
912+
the job status while polling. Defaults to True for sync, False
913+
for async. Use ``sift_client.config.show_progress = False`` to disable
914+
globally for sync.
901915
902916
Returns:
903917
The Job in the completed state.

python/mkdocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ plugins:
8484
show_source: false
8585
find_stubs_package: true
8686
show_if_no_docstring: true
87-
filters: "public"
87+
filters: ["!^__(?!init)", "!^_[^_]"]
8888
show_submodules: false
8989
# Styling
9090
group_by_category: true

0 commit comments

Comments
 (0)