From 609dbfd53d7d2c572c3dfa7051a07061780e600c Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Sun, 29 Mar 2026 12:50:57 -0700 Subject: [PATCH 01/11] python(feat): add progress indicators for job polling and file downloads using alive-progress --- python/lib/sift_client/_internal/util/file.py | 29 +++++++++++--- python/lib/sift_client/resources/jobs.py | 39 +++++++++++++++---- .../resources/sync_stubs/__init__.pyi | 12 +++++- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/python/lib/sift_client/_internal/util/file.py b/python/lib/sift_client/_internal/util/file.py index 55f937cc8..42c8d7fc7 100644 --- a/python/lib/sift_client/_internal/util/file.py +++ b/python/lib/sift_client/_internal/util/file.py @@ -4,6 +4,8 @@ import zipfile from typing import TYPE_CHECKING +from alive_progress import alive_bar + from sift_client.errors import SiftWarning if TYPE_CHECKING: @@ -12,13 +14,21 @@ from sift_client.transport.rest_transport import RestClient -def download_file(signed_url: str, output_path: Path, *, rest_client: RestClient) -> Path: +def download_file( + signed_url: str, + output_path: Path, + *, + rest_client: RestClient, + show_progress: bool = False, +) -> Path: """Download a file from a URL in streaming 4 MiB chunks. Args: url: The URL to download from. dest: Path where the file will be saved. Parent directories are created if needed. rest_client: The SDK rest client to use for the download. + show_progress: If True, display a progress bar during download. + Defaults to False. Returns: The path to the downloaded file. @@ -30,10 +40,19 @@ def download_file(signed_url: str, output_path: Path, *, rest_client: RestClient # Strip the session's default Authorization header, presigned URLs carry their own auth with rest_client.get(signed_url, stream=True, headers={"Authorization": None}) as response: response.raise_for_status() - with output_path.open("wb") as file: - for chunk in response.iter_content(chunk_size=4194304): # 4 MiB - if chunk: - file.write(chunk) + total_bytes = int(response.headers.get("Content-Length", 0)) or None + with alive_bar( + total_bytes, + title="Downloading", + unit="B", + scale="SI", + disable=not show_progress, + ) as bar: + with output_path.open("wb") as file: + for chunk in response.iter_content(chunk_size=4194304): # 4 MiB + if chunk: + file.write(chunk) + bar(len(chunk)) return output_path diff --git a/python/lib/sift_client/resources/jobs.py b/python/lib/sift_client/resources/jobs.py index a0b50649e..611a9fbfc 100644 --- a/python/lib/sift_client/resources/jobs.py +++ b/python/lib/sift_client/resources/jobs.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import TYPE_CHECKING +from alive_progress import alive_bar + from sift_client._internal.low_level_wrappers.jobs import JobsLowLevelClient from sift_client._internal.util.executor import run_sync_function from sift_client._internal.util.file import download_file, extract_zip @@ -169,6 +171,7 @@ async def wait_until_complete( *, polling_interval_secs: int = 5, timeout_secs: int | None = None, + show_progress: bool = False, ) -> Job: """Wait until the job is complete or the timeout is reached. @@ -180,6 +183,8 @@ async def wait_until_complete( polling_interval_secs: Seconds between status polls. Defaults to 5s. timeout_secs: Maximum seconds to wait. If None, polls indefinitely. Defaults to None (indefinite). + show_progress: If True, display an animated progress spinner alongisde + the job status while polling. Defaults to False. Returns: The Job in the completed state. @@ -187,13 +192,25 @@ async def wait_until_complete( job_id = job._id_or_error if isinstance(job, Job) else job start = time.monotonic() - while True: - job = await self.get(job_id) - if job.job_status in (JobStatus.FINISHED, JobStatus.FAILED, JobStatus.CANCELLED): - return job - if timeout_secs is not None and (time.monotonic() - start) >= timeout_secs: - raise TimeoutError(f"Job {job_id} did not complete within {timeout_secs} seconds") - await asyncio.sleep(polling_interval_secs) + with alive_bar( + title="Processing job", + bar=None, + spinner_length=5, + monitor=False, + stats=False, + disable=not show_progress, + ) as bar: + while True: + job = await self.get(job_id) + bar.title(f"Job status: {job.job_status.value}") + bar() + if job.job_status in (JobStatus.FINISHED, JobStatus.FAILED, JobStatus.CANCELLED): + return job + if timeout_secs is not None and (time.monotonic() - start) >= timeout_secs: + raise TimeoutError( + f"Job {job_id} did not complete within {timeout_secs} seconds" + ) + await asyncio.sleep(polling_interval_secs) async def wait_and_download( self, @@ -203,6 +220,7 @@ async def wait_and_download( timeout_secs: int | None = None, output_dir: str | Path | None = None, extract: bool = True, + show_progress: bool = True, ) -> list[Path]: """Wait for a job to complete and download the result files. @@ -219,6 +237,8 @@ async def wait_and_download( extract it and delete the archive, returning paths to the extracted files. Non-zip files are returned as-is regardless of this flag. + show_progress: If True (default), display an animated progress + spinner while waiting. Defaults to True. Returns: List of paths to the downloaded/extracted files. @@ -233,6 +253,7 @@ async def wait_and_download( job=job_id, polling_interval_secs=polling_interval_secs, timeout_secs=timeout_secs, + show_progress=show_progress, ) if completed_job.job_status == JobStatus.FAILED: if ( @@ -259,7 +280,9 @@ async def wait_and_download( # Run the synchronous download in a thread pool to avoid blocking the event loop rest_client = self.client.rest_client await run_sync_function( - lambda: download_file(presigned_url, download_path, rest_client=rest_client) + lambda: download_file( + presigned_url, download_path, rest_client=rest_client, show_progress=show_progress + ) ) if not extract or not zipfile.is_zipfile(download_path): diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 02e53aeb5..f4f221eb4 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -859,6 +859,7 @@ class JobsAPI: timeout_secs: int | None = None, output_dir: str | Path | None = None, extract: bool = True, + show_progress: bool = True, ) -> list[Path]: """Wait for a job to complete and download the result files. @@ -875,6 +876,8 @@ class JobsAPI: extract it and delete the archive, returning paths to the extracted files. Non-zip files are returned as-is regardless of this flag. + show_progress: If True (default), display an animated progress + spinner while waiting. Defaults to True. Returns: List of paths to the downloaded/extracted files. @@ -886,7 +889,12 @@ class JobsAPI: ... def wait_until_complete( - self, job: Job | str, *, polling_interval_secs: int = 5, timeout_secs: int | None = None + self, + job: Job | str, + *, + polling_interval_secs: int = 5, + timeout_secs: int | None = None, + show_progress: bool = False, ) -> Job: """Wait until the job is complete or the timeout is reached. @@ -898,6 +906,8 @@ class JobsAPI: polling_interval_secs: Seconds between status polls. Defaults to 5s. timeout_secs: Maximum seconds to wait. If None, polls indefinitely. Defaults to None (indefinite). + show_progress: If True, display an animated progress spinner alongisde + the job status while polling. Defaults to False. Returns: The Job in the completed state. From ae67ac8e08ee1befdf13812aa4d11993e384e5c5 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Sun, 29 Mar 2026 12:57:26 -0700 Subject: [PATCH 02/11] default to false for wait_and_download --- python/lib/sift_client/_internal/util/file.py | 2 +- python/lib/sift_client/resources/jobs.py | 8 ++++---- python/lib/sift_client/resources/sync_stubs/__init__.pyi | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/python/lib/sift_client/_internal/util/file.py b/python/lib/sift_client/_internal/util/file.py index 42c8d7fc7..35d345b3f 100644 --- a/python/lib/sift_client/_internal/util/file.py +++ b/python/lib/sift_client/_internal/util/file.py @@ -4,7 +4,7 @@ import zipfile from typing import TYPE_CHECKING -from alive_progress import alive_bar +from alive_progress import alive_bar # type: ignore[import-untyped] from sift_client.errors import SiftWarning diff --git a/python/lib/sift_client/resources/jobs.py b/python/lib/sift_client/resources/jobs.py index 611a9fbfc..0fab4dc46 100644 --- a/python/lib/sift_client/resources/jobs.py +++ b/python/lib/sift_client/resources/jobs.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from alive_progress import alive_bar +from alive_progress import alive_bar # type: ignore[import-untyped] from sift_client._internal.low_level_wrappers.jobs import JobsLowLevelClient from sift_client._internal.util.executor import run_sync_function @@ -220,7 +220,7 @@ async def wait_and_download( timeout_secs: int | None = None, output_dir: str | Path | None = None, extract: bool = True, - show_progress: bool = True, + show_progress: bool = False, ) -> list[Path]: """Wait for a job to complete and download the result files. @@ -237,8 +237,8 @@ async def wait_and_download( extract it and delete the archive, returning paths to the extracted files. Non-zip files are returned as-is regardless of this flag. - show_progress: If True (default), display an animated progress - spinner while waiting. Defaults to True. + show_progress: If True, display an animated progress spinner + while waiting and a download progress bar. Defaults to False. Returns: List of paths to the downloaded/extracted files. diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index f4f221eb4..7ca6f2f07 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -859,7 +859,7 @@ class JobsAPI: timeout_secs: int | None = None, output_dir: str | Path | None = None, extract: bool = True, - show_progress: bool = True, + show_progress: bool = False, ) -> list[Path]: """Wait for a job to complete and download the result files. @@ -876,8 +876,8 @@ class JobsAPI: extract it and delete the archive, returning paths to the extracted files. Non-zip files are returned as-is regardless of this flag. - show_progress: If True (default), display an animated progress - spinner while waiting. Defaults to True. + show_progress: If True, display an animated progress spinner + while waiting and a download progress bar. Defaults to False. Returns: List of paths to the downloaded/extracted files. From bdfd304024c96bda9d6ccfa1128e17a41fc8f923 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 31 Mar 2026 12:53:33 -0700 Subject: [PATCH 03/11] added sync tests and updated polling details --- .../sift_client/_tests/resources/test_jobs.py | 50 +++++++++++++++++++ python/lib/sift_client/resources/jobs.py | 7 +-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/python/lib/sift_client/_tests/resources/test_jobs.py b/python/lib/sift_client/_tests/resources/test_jobs.py index feb84707d..589ffcec7 100644 --- a/python/lib/sift_client/_tests/resources/test_jobs.py +++ b/python/lib/sift_client/_tests/resources/test_jobs.py @@ -7,6 +7,7 @@ - Error handling and edge cases """ +import asyncio from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch @@ -393,6 +394,24 @@ async def test_raises_timeout_error_when_not_complete_in_time(self, jobs_api_asy timeout_secs=0.1, ) + @pytest.mark.asyncio + async def test_concurrent_wait_with_progress_disabled(self, jobs_api_async): + """Concurrent wait_until_complete calls with show_progress=False should not raise.""" + mock_job = MagicMock() + mock_job.job_status = JobStatus.FINISHED + + with patch( + "sift_client.resources.jobs.JobsAPIAsync.get", + new_callable=AsyncMock, + return_value=mock_job, + ): + results = await asyncio.gather( + jobs_api_async.wait_until_complete(job="job-1", show_progress=False), + jobs_api_async.wait_until_complete(job="job-2", show_progress=False), + ) + + assert all(r.job_status == JobStatus.FINISHED for r in results) + class TestJobProperties: """Tests for job property methods.""" @@ -527,6 +546,37 @@ def test_basic_list(self, jobs_api_sync): if jobs: assert isinstance(jobs[0], Job) + class TestWaitUntilComplete: + """Tests for wait_until_complete through the sync wrapper.""" + + def test_wait_with_progress_enabled(self, jobs_api_sync): + """show_progress=True works through the sync wrapper without error.""" + mock_job = MagicMock() + mock_job.job_status = JobStatus.FINISHED + + with patch( + "sift_client.resources.jobs.JobsAPIAsync.get", + new_callable=AsyncMock, + return_value=mock_job, + ): + result = jobs_api_sync.wait_until_complete(job="job-1", show_progress=True) + + assert result.job_status == JobStatus.FINISHED + + def test_wait_with_progress_disabled(self, jobs_api_sync): + """show_progress=False works through the sync wrapper without error.""" + mock_job = MagicMock() + mock_job.job_status = JobStatus.FINISHED + + with patch( + "sift_client.resources.jobs.JobsAPIAsync.get", + new_callable=AsyncMock, + return_value=mock_job, + ): + result = jobs_api_sync.wait_until_complete(job="job-1", show_progress=False) + + assert result.job_status == JobStatus.FINISHED + class TestWaitAndDownload: @pytest.mark.asyncio diff --git a/python/lib/sift_client/resources/jobs.py b/python/lib/sift_client/resources/jobs.py index 0fab4dc46..85c4a5ee8 100644 --- a/python/lib/sift_client/resources/jobs.py +++ b/python/lib/sift_client/resources/jobs.py @@ -193,16 +193,17 @@ async def wait_until_complete( start = time.monotonic() with alive_bar( - title="Processing job", + title=f"Job {job_id}: polling", bar=None, - spinner_length=5, + spinner_length=7, + spinner="dots_waves", monitor=False, stats=False, disable=not show_progress, ) as bar: while True: job = await self.get(job_id) - bar.title(f"Job status: {job.job_status.value}") + bar.title(f"Job {job_id} ({job.job_type.value.lower()}): {job.job_status.value}") bar() if job.job_status in (JobStatus.FINISHED, JobStatus.FAILED, JobStatus.CANCELLED): return job From 5fa1b1ed8932a39f6f9fd05e2e85ccb1cd8d2e7e Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 31 Mar 2026 15:15:53 -0700 Subject: [PATCH 04/11] show_progress defaults to True for sync, False for async with global override via sift_client.show_progress --- python/lib/sift_client/__init__.py | 7 +++++ .../lib/sift_client/_internal/sync_wrapper.py | 1 + .../sift_client/_tests/resources/test_jobs.py | 31 ++++++++++++++++--- python/lib/sift_client/resources/jobs.py | 31 ++++++++++++++++--- .../resources/sync_stubs/__init__.pyi | 14 ++++++--- 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/python/lib/sift_client/__init__.py b/python/lib/sift_client/__init__.py index 0f510a146..98bfca52e 100644 --- a/python/lib/sift_client/__init__.py +++ b/python/lib/sift_client/__init__.py @@ -132,6 +132,8 @@ async def main(): 5. **Use type hints** to get full IDE support and catch errors early """ +from __future__ import annotations + import logging from sift_client.client import SiftClient @@ -143,3 +145,8 @@ async def main(): ] logging.getLogger(__name__).addHandler(logging.NullHandler()) + +# Global setting for progress bar display. +# - None (default): progress bars are shown for sync, hidden for async +# - False: disable progress bars everywhere +show_progress: bool | None = None diff --git a/python/lib/sift_client/_internal/sync_wrapper.py b/python/lib/sift_client/_internal/sync_wrapper.py index eb6d0240e..b0d990ee4 100644 --- a/python/lib/sift_client/_internal/sync_wrapper.py +++ b/python/lib/sift_client/_internal/sync_wrapper.py @@ -50,6 +50,7 @@ def generate_sync_api(cls: type[ResourceBase], sync_name: str) -> type: @wraps(orig_init) def __init__(self, *args, **kwargs): # noqa: N807 self._async_impl = cls(*args, **kwargs) + self._async_impl._is_sync = True def _run(self, coro): loop = self._async_impl.client.get_asyncio_loop() diff --git a/python/lib/sift_client/_tests/resources/test_jobs.py b/python/lib/sift_client/_tests/resources/test_jobs.py index 589ffcec7..d1ff330b3 100644 --- a/python/lib/sift_client/_tests/resources/test_jobs.py +++ b/python/lib/sift_client/_tests/resources/test_jobs.py @@ -549,8 +549,8 @@ def test_basic_list(self, jobs_api_sync): class TestWaitUntilComplete: """Tests for wait_until_complete through the sync wrapper.""" - def test_wait_with_progress_enabled(self, jobs_api_sync): - """show_progress=True works through the sync wrapper without error.""" + def test_wait_defaults_to_progress_enabled(self, jobs_api_sync): + """Sync wrapper defaults to show_progress=True when no kwarg is passed.""" mock_job = MagicMock() mock_job.job_status = JobStatus.FINISHED @@ -559,12 +559,12 @@ def test_wait_with_progress_enabled(self, jobs_api_sync): new_callable=AsyncMock, return_value=mock_job, ): - result = jobs_api_sync.wait_until_complete(job="job-1", show_progress=True) + result = jobs_api_sync.wait_until_complete(job="job-1") assert result.job_status == JobStatus.FINISHED - def test_wait_with_progress_disabled(self, jobs_api_sync): - """show_progress=False works through the sync wrapper without error.""" + def test_wait_with_progress_explicit_false(self, jobs_api_sync): + """Explicit show_progress=False overrides the sync default.""" mock_job = MagicMock() mock_job.job_status = JobStatus.FINISHED @@ -577,6 +577,27 @@ def test_wait_with_progress_disabled(self, jobs_api_sync): assert result.job_status == JobStatus.FINISHED + def test_namespace_override_disables_progress(self, jobs_api_sync): + """Setting sift_client.show_progress=False overrides the sync default.""" + import sift_client + + mock_job = MagicMock() + mock_job.job_status = JobStatus.FINISHED + + original = sift_client.show_progress + try: + sift_client.show_progress = False + with patch( + "sift_client.resources.jobs.JobsAPIAsync.get", + new_callable=AsyncMock, + return_value=mock_job, + ): + result = jobs_api_sync.wait_until_complete(job="job-1") + finally: + sift_client.show_progress = original + + assert result.job_status == JobStatus.FINISHED + class TestWaitAndDownload: @pytest.mark.asyncio diff --git a/python/lib/sift_client/resources/jobs.py b/python/lib/sift_client/resources/jobs.py index 85c4a5ee8..6dd01cc5e 100644 --- a/python/lib/sift_client/resources/jobs.py +++ b/python/lib/sift_client/resources/jobs.py @@ -9,6 +9,7 @@ from alive_progress import alive_bar # type: ignore[import-untyped] +import sift_client as _sift_client_module from sift_client._internal.low_level_wrappers.jobs import JobsLowLevelClient from sift_client._internal.util.executor import run_sync_function from sift_client._internal.util.file import download_file, extract_zip @@ -171,7 +172,7 @@ async def wait_until_complete( *, polling_interval_secs: int = 5, timeout_secs: int | None = None, - show_progress: bool = False, + show_progress: bool | None = None, ) -> Job: """Wait until the job is complete or the timeout is reached. @@ -183,13 +184,23 @@ async def wait_until_complete( polling_interval_secs: Seconds between status polls. Defaults to 5s. timeout_secs: Maximum seconds to wait. If None, polls indefinitely. Defaults to None (indefinite). - show_progress: If True, display an animated progress spinner alongisde - the job status while polling. Defaults to False. + show_progress: If True, display an animated progress spinner alongside + the job status while polling. Defaults to True for sync, False + for async. Use ``sift_client.show_progress = False`` to disable + globally for sync. Returns: The Job in the completed state. """ job_id = job._id_or_error if isinstance(job, Job) else job + if show_progress is None: + global_setting = _sift_client_module.show_progress + if global_setting is not None: + show_progress = global_setting + elif getattr(self, "_is_sync", False): + show_progress = True + else: + show_progress = False start = time.monotonic() with alive_bar( @@ -221,7 +232,7 @@ async def wait_and_download( timeout_secs: int | None = None, output_dir: str | Path | None = None, extract: bool = True, - show_progress: bool = False, + show_progress: bool | None = None, ) -> list[Path]: """Wait for a job to complete and download the result files. @@ -239,7 +250,9 @@ async def wait_and_download( extracted files. Non-zip files are returned as-is regardless of this flag. show_progress: If True, display an animated progress spinner - while waiting and a download progress bar. Defaults to False. + while waiting and a download progress bar. Defaults to True + for sync, False for async. Use ``sift_client.show_progress = False`` + to disable globally for sync. Returns: List of paths to the downloaded/extracted files. @@ -249,6 +262,14 @@ async def wait_and_download( TimeoutError: If the job does not complete within timeout_secs. """ job_id = job._id_or_error if isinstance(job, Job) else job + if show_progress is None: + global_setting = _sift_client_module.show_progress + if global_setting is not None: + show_progress = global_setting + elif getattr(self, "_is_sync", False): + show_progress = True + else: + show_progress = False completed_job = await self.wait_until_complete( job=job_id, diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 7ca6f2f07..e31b059e2 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -859,7 +859,7 @@ class JobsAPI: timeout_secs: int | None = None, output_dir: str | Path | None = None, extract: bool = True, - show_progress: bool = False, + show_progress: bool | None = True, ) -> list[Path]: """Wait for a job to complete and download the result files. @@ -877,7 +877,9 @@ class JobsAPI: extracted files. Non-zip files are returned as-is regardless of this flag. show_progress: If True, display an animated progress spinner - while waiting and a download progress bar. Defaults to False. + while waiting and a download progress bar. Defaults to True + for sync. Set to False to disable, or configure globally via + ``sift_client.show_progress``. Returns: List of paths to the downloaded/extracted files. @@ -894,7 +896,7 @@ class JobsAPI: *, polling_interval_secs: int = 5, timeout_secs: int | None = None, - show_progress: bool = False, + show_progress: bool | None = True, ) -> Job: """Wait until the job is complete or the timeout is reached. @@ -906,8 +908,10 @@ class JobsAPI: polling_interval_secs: Seconds between status polls. Defaults to 5s. timeout_secs: Maximum seconds to wait. If None, polls indefinitely. Defaults to None (indefinite). - show_progress: If True, display an animated progress spinner alongisde - the job status while polling. Defaults to False. + show_progress: If True, display an animated progress spinner alongside + the job status while polling. Defaults to True for sync. Set to + False to disable, or configure globally via + ``sift_client.show_progress``. Returns: The Job in the completed state. From 2cbc58af29682e2994e67164f78c5a0bb4fb36ca Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 31 Mar 2026 15:25:55 -0700 Subject: [PATCH 05/11] updated stubs --- .../sift_client/resources/sync_stubs/__init__.pyi | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index e31b059e2..287c93c91 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -859,7 +859,7 @@ class JobsAPI: timeout_secs: int | None = None, output_dir: str | Path | None = None, extract: bool = True, - show_progress: bool | None = True, + show_progress: bool | None = None, ) -> list[Path]: """Wait for a job to complete and download the result files. @@ -878,8 +878,8 @@ class JobsAPI: of this flag. show_progress: If True, display an animated progress spinner while waiting and a download progress bar. Defaults to True - for sync. Set to False to disable, or configure globally via - ``sift_client.show_progress``. + for sync, False for async. Use ``sift_client.show_progress = False`` + to disable globally for sync. Returns: List of paths to the downloaded/extracted files. @@ -896,7 +896,7 @@ class JobsAPI: *, polling_interval_secs: int = 5, timeout_secs: int | None = None, - show_progress: bool | None = True, + show_progress: bool | None = None, ) -> Job: """Wait until the job is complete or the timeout is reached. @@ -909,9 +909,9 @@ class JobsAPI: timeout_secs: Maximum seconds to wait. If None, polls indefinitely. Defaults to None (indefinite). show_progress: If True, display an animated progress spinner alongside - the job status while polling. Defaults to True for sync. Set to - False to disable, or configure globally via - ``sift_client.show_progress``. + the job status while polling. Defaults to True for sync, False + for async. Use ``sift_client.show_progress = False`` to disable + globally for sync. Returns: The Job in the completed state. From 27d881bb56ff1e2b3233ab8f485b6222c1a04aa5 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 31 Mar 2026 16:47:10 -0700 Subject: [PATCH 06/11] refactor to use a config dataclass singleton --- python/lib/sift_client/__init__.py | 36 ++++++++++++++++--- python/lib/sift_client/_internal/util/file.py | 2 ++ .../sift_client/_tests/resources/test_jobs.py | 8 ++--- python/lib/sift_client/resources/jobs.py | 8 ++--- .../resources/sync_stubs/__init__.pyi | 4 +-- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/python/lib/sift_client/__init__.py b/python/lib/sift_client/__init__.py index 98bfca52e..6b071096b 100644 --- a/python/lib/sift_client/__init__.py +++ b/python/lib/sift_client/__init__.py @@ -135,6 +135,7 @@ async def main(): from __future__ import annotations import logging +from dataclasses import dataclass, fields from sift_client.client import SiftClient from sift_client.transport import SiftConnectionConfig @@ -142,11 +143,38 @@ async def main(): __all__ = [ "SiftClient", "SiftConnectionConfig", + "config", ] logging.getLogger(__name__).addHandler(logging.NullHandler()) -# Global setting for progress bar display. -# - None (default): progress bars are shown for sync, hidden for async -# - False: disable progress bars everywhere -show_progress: bool | None = None + +@dataclass +class Config: + """Global configuration for the Sift client library. + + This is a singleton dataclass, use the module-level ``config`` instance + rather than creating your own:: + + import sift_client + + sift_client.config.show_progress = False + + Setting an attribute that doesn't exist raises ``AttributeError`` so + typos are caught immediately. + + Attributes: + show_progress: Controls progress-bar display for job polling and + file downloads. ``None`` (default) shows bars for sync calls + and hides them for async. Set to ``False`` to disable everywhere. + """ + + show_progress: bool | None = None + + def __setattr__(self, name: str, value: object) -> None: + if name not in {f.name for f in fields(self)}: + raise AttributeError(f"Unknown setting: {name!r}") + super().__setattr__(name, value) + + +config = Config() diff --git a/python/lib/sift_client/_internal/util/file.py b/python/lib/sift_client/_internal/util/file.py index 35d345b3f..518bce847 100644 --- a/python/lib/sift_client/_internal/util/file.py +++ b/python/lib/sift_client/_internal/util/file.py @@ -44,6 +44,8 @@ def download_file( with alive_bar( total_bytes, title="Downloading", + spinner="dots_waves", + spinner_length=7, unit="B", scale="SI", disable=not show_progress, diff --git a/python/lib/sift_client/_tests/resources/test_jobs.py b/python/lib/sift_client/_tests/resources/test_jobs.py index d1ff330b3..35908ce87 100644 --- a/python/lib/sift_client/_tests/resources/test_jobs.py +++ b/python/lib/sift_client/_tests/resources/test_jobs.py @@ -578,15 +578,15 @@ def test_wait_with_progress_explicit_false(self, jobs_api_sync): assert result.job_status == JobStatus.FINISHED def test_namespace_override_disables_progress(self, jobs_api_sync): - """Setting sift_client.show_progress=False overrides the sync default.""" + """Setting sift_client.config.show_progress=False overrides the sync default.""" import sift_client mock_job = MagicMock() mock_job.job_status = JobStatus.FINISHED - original = sift_client.show_progress + original = sift_client.config.show_progress try: - sift_client.show_progress = False + sift_client.config.show_progress = False with patch( "sift_client.resources.jobs.JobsAPIAsync.get", new_callable=AsyncMock, @@ -594,7 +594,7 @@ def test_namespace_override_disables_progress(self, jobs_api_sync): ): result = jobs_api_sync.wait_until_complete(job="job-1") finally: - sift_client.show_progress = original + sift_client.config.show_progress = original assert result.job_status == JobStatus.FINISHED diff --git a/python/lib/sift_client/resources/jobs.py b/python/lib/sift_client/resources/jobs.py index 6dd01cc5e..6ddaec6ca 100644 --- a/python/lib/sift_client/resources/jobs.py +++ b/python/lib/sift_client/resources/jobs.py @@ -186,7 +186,7 @@ async def wait_until_complete( Defaults to None (indefinite). show_progress: If True, display an animated progress spinner alongside the job status while polling. Defaults to True for sync, False - for async. Use ``sift_client.show_progress = False`` to disable + for async. Use ``sift_client.config.show_progress = False`` to disable globally for sync. Returns: @@ -194,7 +194,7 @@ async def wait_until_complete( """ job_id = job._id_or_error if isinstance(job, Job) else job if show_progress is None: - global_setting = _sift_client_module.show_progress + global_setting = _sift_client_module.config.show_progress if global_setting is not None: show_progress = global_setting elif getattr(self, "_is_sync", False): @@ -251,7 +251,7 @@ async def wait_and_download( of this flag. show_progress: If True, display an animated progress spinner while waiting and a download progress bar. Defaults to True - for sync, False for async. Use ``sift_client.show_progress = False`` + for sync, False for async. Use ``sift_client.config.show_progress = False`` to disable globally for sync. Returns: @@ -263,7 +263,7 @@ async def wait_and_download( """ job_id = job._id_or_error if isinstance(job, Job) else job if show_progress is None: - global_setting = _sift_client_module.show_progress + global_setting = _sift_client_module.config.show_progress if global_setting is not None: show_progress = global_setting elif getattr(self, "_is_sync", False): diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 287c93c91..fe87809cd 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -878,7 +878,7 @@ class JobsAPI: of this flag. show_progress: If True, display an animated progress spinner while waiting and a download progress bar. Defaults to True - for sync, False for async. Use ``sift_client.show_progress = False`` + for sync, False for async. Use ``sift_client.config.show_progress = False`` to disable globally for sync. Returns: @@ -910,7 +910,7 @@ class JobsAPI: Defaults to None (indefinite). show_progress: If True, display an animated progress spinner alongside the job status while polling. Defaults to True for sync, False - for async. Use ``sift_client.show_progress = False`` to disable + for async. Use ``sift_client.config.show_progress = False`` to disable globally for sync. Returns: From c8af49d5d5ebaff96ddb2a07e4f63969ffd5a9a4 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 31 Mar 2026 17:12:17 -0700 Subject: [PATCH 07/11] add Config to __all__ --- python/lib/sift_client/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/lib/sift_client/__init__.py b/python/lib/sift_client/__init__.py index 6b071096b..9a87175dc 100644 --- a/python/lib/sift_client/__init__.py +++ b/python/lib/sift_client/__init__.py @@ -141,9 +141,9 @@ async def main(): from sift_client.transport import SiftConnectionConfig __all__ = [ + "Config", "SiftClient", "SiftConnectionConfig", - "config", ] logging.getLogger(__name__).addHandler(logging.NullHandler()) From 474297200d61d079739b57b4fde040b8f6a6048d Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 31 Mar 2026 17:22:39 -0700 Subject: [PATCH 08/11] moved config to its own file --- python/lib/sift_client/__init__.py | 35 ++-------------------------- python/lib/sift_client/config.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 33 deletions(-) create mode 100644 python/lib/sift_client/config.py diff --git a/python/lib/sift_client/__init__.py b/python/lib/sift_client/__init__.py index 9a87175dc..e98ee783d 100644 --- a/python/lib/sift_client/__init__.py +++ b/python/lib/sift_client/__init__.py @@ -135,46 +135,15 @@ async def main(): from __future__ import annotations import logging -from dataclasses import dataclass, fields from sift_client.client import SiftClient +from sift_client.config import config from sift_client.transport import SiftConnectionConfig __all__ = [ - "Config", "SiftClient", "SiftConnectionConfig", + "config", ] logging.getLogger(__name__).addHandler(logging.NullHandler()) - - -@dataclass -class Config: - """Global configuration for the Sift client library. - - This is a singleton dataclass, use the module-level ``config`` instance - rather than creating your own:: - - import sift_client - - sift_client.config.show_progress = False - - Setting an attribute that doesn't exist raises ``AttributeError`` so - typos are caught immediately. - - Attributes: - show_progress: Controls progress-bar display for job polling and - file downloads. ``None`` (default) shows bars for sync calls - and hides them for async. Set to ``False`` to disable everywhere. - """ - - show_progress: bool | None = None - - def __setattr__(self, name: str, value: object) -> None: - if name not in {f.name for f in fields(self)}: - raise AttributeError(f"Unknown setting: {name!r}") - super().__setattr__(name, value) - - -config = Config() diff --git a/python/lib/sift_client/config.py b/python/lib/sift_client/config.py new file mode 100644 index 000000000..13f7810b7 --- /dev/null +++ b/python/lib/sift_client/config.py @@ -0,0 +1,37 @@ +"""Global configuration for the Sift client library.""" + +from __future__ import annotations + +from dataclasses import dataclass, fields + + +@dataclass +class Config: + """Global configuration for the Sift client library. + + This is a singleton dataclass, use the module-level ``config`` instance + rather than creating your own:: + + import sift_client + + sift_client.config.show_progress = False + + Setting an attribute that doesn't exist raises ``AttributeError`` so + typos are caught immediately. + + """ + + show_progress: bool | None = None + """Controls progress-bar display for job polling and file downloads. + + ``None`` (default) shows bars for sync calls and hides them for async. + Set to ``False`` to disable everywhere. + """ + + def __setattr__(self, name: str, value: object) -> None: + if name not in {f.name for f in fields(self)}: + raise AttributeError(f"Unknown setting: {name!r}") + super().__setattr__(name, value) + + +config = Config() From 71ff8247861fcd1af866022fe830a964aa4e92c0 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 31 Mar 2026 17:33:47 -0700 Subject: [PATCH 09/11] use slots to hide __setattr__ from autodocs, faster as well --- python/lib/sift_client/config.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/python/lib/sift_client/config.py b/python/lib/sift_client/config.py index 13f7810b7..03a8b0d8b 100644 --- a/python/lib/sift_client/config.py +++ b/python/lib/sift_client/config.py @@ -2,10 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import dataclass -@dataclass +@dataclass(slots=True) class Config: """Global configuration for the Sift client library. @@ -28,10 +28,5 @@ class Config: Set to ``False`` to disable everywhere. """ - def __setattr__(self, name: str, value: object) -> None: - if name not in {f.name for f in fields(self)}: - raise AttributeError(f"Unknown setting: {name!r}") - super().__setattr__(name, value) - config = Config() From 652b0f83f28657f96ea312305c43903ccd192a42 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 31 Mar 2026 17:40:36 -0700 Subject: [PATCH 10/11] testing: updated filters for private --- python/mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/mkdocs.yml b/python/mkdocs.yml index ed12ad4e7..fd52b8d29 100644 --- a/python/mkdocs.yml +++ b/python/mkdocs.yml @@ -84,7 +84,7 @@ plugins: show_source: false find_stubs_package: true show_if_no_docstring: true - filters: "public" + filters: ["!^__(?!init)", "!^_[^_]"] show_submodules: false # Styling group_by_category: true From d4c05134f0b5621e380b0aaf075932b0e87280f2 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 31 Mar 2026 17:49:51 -0700 Subject: [PATCH 11/11] removed slots from config, slots requires 3.10+ but CI is running an older version --- python/lib/sift_client/config.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/python/lib/sift_client/config.py b/python/lib/sift_client/config.py index 03a8b0d8b..13f7810b7 100644 --- a/python/lib/sift_client/config.py +++ b/python/lib/sift_client/config.py @@ -2,10 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, fields -@dataclass(slots=True) +@dataclass class Config: """Global configuration for the Sift client library. @@ -28,5 +28,10 @@ class Config: Set to ``False`` to disable everywhere. """ + def __setattr__(self, name: str, value: object) -> None: + if name not in {f.name for f in fields(self)}: + raise AttributeError(f"Unknown setting: {name!r}") + super().__setattr__(name, value) + config = Config()