diff --git a/gateway/config/settings/base.py b/gateway/config/settings/base.py index 87fb85ab4..edd935d60 100644 --- a/gateway/config/settings/base.py +++ b/gateway/config/settings/base.py @@ -8,6 +8,7 @@ from urllib.parse import urlparse from celery.schedules import crontab +from celery.schedules import schedule from environs import env from config.settings.logs import ColoredFormatter @@ -543,7 +544,7 @@ def _strip_endpoint_scheme(endpoint_url: str) -> str: }, "monitor-services-health": { "task": "sds_gateway.monitoring.tasks.monitor_services_health", - "schedule": crontab(minute="*/1"), + "schedule": schedule(run_every=60), "options": {"expires": 50}, }, } diff --git a/gateway/docs/changelog.md b/gateway/docs/changelog.md index 4a5336712..fb732c52e 100644 --- a/gateway/docs/changelog.md +++ b/gateway/docs/changelog.md @@ -74,13 +74,13 @@ - Fixes: - [**Added M2M relationships for assets to - datasets**](https://github.com/spectrumx/sds-code/pull/226): Currently the - foreign key (FK) relationships only allow assets to be connected to a single - dataset. This PR expands the schema (with future contraction planned) to correct - the schema issues. + datasets**](https://github.com/spectrumx/sds-code/pull/226): Currently the + foreign key (FK) relationships only allow assets to be connected to a single + dataset. This PR expands the schema (with future contraction planned) to correct + the schema issues. - Requires a data migration for existing datasets (and captures) from the FK - field to M2M field. Run management command `migrate_fk_to_m2m` to transfer - foreign key model relationship to many-to-many field (only adds, does NOT - nullify FK). + field to M2M field. Run management command `migrate_fk_to_m2m` to transfer + foreign key model relationship to many-to-many field (only adds, does NOT + nullify FK). - This is meant to be run on systems with data under the current schema that - require migrations to new schema. + require migrations to new schema. diff --git a/gateway/docs/dev-notes.md b/gateway/docs/dev-notes.md index 2e4520ecb..91823e8d1 100644 --- a/gateway/docs/dev-notes.md +++ b/gateway/docs/dev-notes.md @@ -45,22 +45,22 @@ migrations, or before upgrading Postgres versions. Up to one snapshot per day of: + [x] **PostgreSQL database** - Complete database dump via `pg_dumpall` (all schemas, - tables, and data) + tables, and data) + [x] **OpenSearch indices** - Snapshots of all indices and their data + [x] **Gateway secrets** - Environment files (`.envs/`, `.env.sh`, etc.) and - configuration files + configuration files + [x] **Git repository state** - Branch info, commit SHA, uncommitted changes, staged - changes, and commit archive. + changes, and commit archive. ### What is NOT backed up + ❌ **MinIO objects** - S3-compatible storage data is not included in snapshots (see - warning below). When configuring MinIO, you can configure erasure coding for data - redundancy across multiple drives or nodes. Consider using MinIO's own backup and - replication features for protecting this data instead of this snapshot procedure. + warning below). When configuring MinIO, you can configure erasure coding for data + redundancy across multiple drives or nodes. Consider using MinIO's own backup and + replication features for protecting this data instead of this snapshot procedure. + ❌ **Docker volumes** - Any data stored in Docker volumes outside of the database - container is not included, such as temporary zip files, `uv` cache, virtual - environments, generated static files. + container is not included, such as temporary zip files, `uv` cache, virtual + environments, generated static files. ### Creating Backups @@ -69,17 +69,17 @@ Up to one snapshot per day of: 3. Run `just snapshot` to create a _daily_ backup snapshot of Postgres database. + See `scripts/create-snapshot.sh` for details of what is executed. + Database credentials are automatically loaded from the postgres container, so the - user running the snapshot must have privileges to manage the database container, - and the container must be running with those env variables set. + user running the snapshot must have privileges to manage the database container, + and the container must be running with those env variables set. + The Postgres snapshot is created with `pg_dumpall`. See the docs for details: - [Postgres docs](https://postgresql.org/docs/current/app-pg-dumpall.html). + [Postgres docs](https://postgresql.org/docs/current/app-pg-dumpall.html). + The snapshot creation is non-interactive, so it can be run from automated scripts; but interaction might be required for backup exfiltration. + **Note:** MinIO objects are not included in this snapshot. See [What is NOT backed up](#what-is-not-backed-up) above. 4. Access the created files in `./data/backups/`. + Note you can set up a remote server in `.env.sh` to automatically copy the backups - there after creation using `rsync` over `ssh`. + there after creation using `rsync` over `ssh`. ### Backups Restoration @@ -95,7 +95,7 @@ READ THIS ENTIRE SECTION BEFORE RESTORING A BACKUP. What can be automatically restored: + [x] **PostgreSQL database** - Full restoration of the database from the latest - snapshot. See [Backups Restoration](#backups-restoration) below. + snapshot. See [Backups Restoration](#backups-restoration) below. Everything else (OpenSearch indices, secrets, git state) can be manually restored from the snapshot files, but there is no automated restoration procedure for them yet. @@ -104,11 +104,11 @@ Note that, while the database can be restored, it will contain references to oth sources that might exist or not. Most notably: + OpenSearch indices - The restored database might contain references to documents in - OpenSearch that do not exist if those indices were not manually restored. + OpenSearch that do not exist if those indices were not manually restored. + MinIO objects - The restored database might contain references to objects in MinIO - that do not exist if those objects were not manually restored. This is the main - challenge when restoring a production backup to a staging/QA machine, as most - objects will not exist there due to the commonly large size of object data. + that do not exist if those objects were not manually restored. This is the main + challenge when restoring a production backup to a staging/QA machine, as most + objects will not exist there due to the commonly large size of object data. Run `scripts/restore-snapshot.sh` when you are ready to restore the most recent snapshot. @@ -123,9 +123,9 @@ production environment. ## MinIO Configuration + MinIO config for SDS: [see the production deploy - instructions](./detailed-deploy.md#production-deploy). + instructions](./detailed-deploy.md#production-deploy). + [MinIO reference - document](https://github.com/minio/minio/blob/master/docs/config/README.md). + document](https://github.com/minio/minio/blob/master/docs/config/README.md). ## OpenSearch Cluster Maintenance diff --git a/gateway/sds_gateway/monitoring/tests/test_celery_schedule.py b/gateway/sds_gateway/monitoring/tests/test_celery_schedule.py index 82092a37e..8be4d3a3e 100644 --- a/gateway/sds_gateway/monitoring/tests/test_celery_schedule.py +++ b/gateway/sds_gateway/monitoring/tests/test_celery_schedule.py @@ -1,4 +1,4 @@ -from celery.schedules import crontab +from celery.schedules import schedule from django.conf import settings from django.utils import timezone @@ -12,7 +12,7 @@ def test_monitoring_task_is_in_celery_beat_schedule() -> None: schedule_config["task"] == "sds_gateway.monitoring.tasks.monitor_services_health" ) - assert isinstance(schedule_config["schedule"], crontab) + assert isinstance(schedule_config["schedule"], schedule) _is_due, next_run_seconds = schedule_config["schedule"].is_due(timezone.now()) check_period_sec: int = 60 expected_expires_sec = check_period_sec - 10 diff --git a/sdk/docs/README.md b/sdk/docs/README.md index a8de6dacf..1b9469bc5 100644 --- a/sdk/docs/README.md +++ b/sdk/docs/README.md @@ -338,3 +338,28 @@ If this is needed, SDK users have a few options: One writer (an SDK client that creates, updates, and/or deletes contents) and multiple readers are generally safe. + +## Structured Logging + +The SDK writes structured JSONL logs to `~/.local/state/spectrumx/logs/YYYY-MM-DD.jsonl`. +Each line is a JSON object with fields for timestamp, severity, category, and message. +Filter them with `jq`: + +```bash +# Show only filesystem operations +jq 'select(.cat == "filesystem")' ~/.local/state/spectrumx/logs/*.jsonl + +# Show only download operations +jq 'select(.cat == "download")' ~/.local/state/spectrumx/logs/*.jsonl + +# Show warnings and errors +jq 'select(.lvl == "WARNING" or .lvl == "ERROR")' ~/.local/state/spectrumx/logs/*.jsonl + +# Compact view per line +jq -r '"\(.ts[11:19]) [\(.lvl)] [\(.cat)] \(.msg)"' ~/.local/state/spectrumx/logs/*.jsonl + +# Count messages per category +jq -r '.cat' ~/.local/state/spectrumx/logs/*.jsonl | sort | uniq -c | sort -rn +``` + +See the [full structured logging docs](https://sds.crc.nd.edu/sdk/advanced/structured-logging/) for more examples. diff --git a/sdk/docs/mkdocs.yaml b/sdk/docs/mkdocs.yaml index 9e869041b..de7b4ad2e 100644 --- a/sdk/docs/mkdocs.yaml +++ b/sdk/docs/mkdocs.yaml @@ -25,6 +25,7 @@ nav: - "Changelog": "changelog.md" - "Advanced": - "Concurrent Access": "advanced/concurrent-access.md" + - "Structured Logging": "advanced/structured-logging.md" - "API Reference": - "Client": "api/client.md" - "Errors": "api/errors.md" diff --git a/sdk/docs/mkdocs/advanced/structured-logging.md b/sdk/docs/mkdocs/advanced/structured-logging.md new file mode 100644 index 000000000..e8f48caeb --- /dev/null +++ b/sdk/docs/mkdocs/advanced/structured-logging.md @@ -0,0 +1,101 @@ +# Structured Logging + +The SDK writes structured JSONL logs to `~/.local/state/spectrumx/logs/YYYY-MM-DD.jsonl`. +Each line is a JSON object with fields like `ts`, `pid`, `lvl`, `cat`, and `msg`. + +## Filtering with jq + +Use [`jq`](https://jqlang.org/) to filter and analyze log files on the command line. + +### By category + +```bash +# Filesystem operations only +jq 'select(.cat == "filesystem")' ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Network operations +jq 'select(.cat == "network")' ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Upload-specific messages +jq 'select(.cat == "upload")' ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Download-specific messages +jq 'select(.cat == "download")' ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Auth messages +jq 'select(.cat == "auth")' ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Config messages +jq 'select(.cat == "config")' ~/.local/state/spectrumx/logs/2026-06-29.jsonl +``` + +### By severity + +```bash +# Warnings and errors only +jq 'select(.lvl == "WARNING" or .lvl == "ERROR")' ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Errors only with full exception info +jq 'select(.lvl == "ERROR")' ~/.local/state/spectrumx/logs/2026-06-29.jsonl +``` + +### Compound filters + +```bash +# Filesystem warnings and errors +jq 'select(.cat == "filesystem" and (.lvl == "WARNING" or .lvl == "ERROR"))' ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Everything except generic "log" category +jq 'select(.cat != "log")' ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Messages with exception details +jq 'select(.exc_info != null)' ~/.local/state/spectrumx/logs/2026-06-29.jsonl +``` + +### Aggregations + +```bash +# Count messages per category +jq -r '.cat' ~/.local/state/spectrumx/logs/2026-06-29.jsonl | sort | uniq -c | sort -rn + +# Count messages per severity level +jq -r '.lvl' ~/.local/state/spectrumx/logs/2026-06-29.jsonl | sort | uniq -c | sort -rn + +# Extract just the messages for a category +jq -r 'select(.cat == "network") | .msg' ~/.local/state/spectrumx/logs/2026-06-29.jsonl +``` + +### Pretty-print and format + +```bash +# Colorized output (jq highlights strings by default) +jq . ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Compact view: timestamp + level + category + message +jq -r '"\(.ts[11:19]) [\(.lvl)] [\(.cat)] \(.msg)"' ~/.local/state/spectrumx/logs/2026-06-29.jsonl + +# Tabular output with header +jq -r '["TS","LVL","CAT","MSG"], ["---","---","---","---"], (.[] | [.ts[11:19], .lvl, .cat, .msg]) | @tsv' ~/.local/state/spectrumx/logs/2026-06-29.jsonl | column -t -s $'\t' +``` + +### Watch in real time + +```bash +# Tail and filter: show only filesystem logs as they arrive +tail -f ~/.local/state/spectrumx/logs/2026-06-29.jsonl | jq 'select(.cat == "filesystem")' + +# Tail with compact format +tail -f ~/.local/state/spectrumx/logs/2026-06-29.jsonl | jq -r '"\(.ts[11:19]) [\(.lvl)] [\(.cat)] \(.msg)"' +``` + +## Log categories reference + +| Category | Description | Examples | +|--------------|---------------------------------|---------------------------------------| +| `log` | Generic/default messages | Dry-run notices, user-facing messages | +| `config` | SDK configuration | Config loading, env variable warnings | +| `auth` | Authentication operations | Token validation, auth success/failure| +| `network` | HTTP/network operations | Response info, connection errors | +| `filesystem` | File and capture CRUD | Upload/download paths, capture ops | +| `download` | File download operations | Download skip/reason, dry-run notices, path resolution | +| `upload` | File upload metadata operations | Upload status, metadata updates | diff --git a/sdk/justfile b/sdk/justfile index ad1c9d794..c351e811b 100644 --- a/sdk/justfile +++ b/sdk/justfile @@ -116,6 +116,7 @@ pyrefly *args: test python=python_version *pytest_args: #!/usr/bin/env bash set -euo pipefail + export COLUMNS=88 echo -e "\n\t\033[34mRunning tests against Python {{ python }}\033[0m\n" # no coverage if -k is used (likely a focused test run) @@ -145,6 +146,7 @@ test python=python_version *pytest_args: test-all *args: #!/usr/bin/env bash set -euo pipefail + COLUMNS=80 echo -e "\n\t\033[34mRunning all local (non-integration) tests\033[0m\n" # run higher bound tests against default python version @@ -169,7 +171,7 @@ test-integration python=python_version workers="auto" *pytest_args: @echo -e "\n\t\033[33mRunning integration tests in parallel\033[0m" @echo -e "\t\033[33m Workers: {{ workers }}\033[0m" @echo -e "\t\033[33m Pass -n 1 or use 'just test-integration-sequential' for sequential execution\033[0m\n" - uv run --resolution highest -p '{{ python }}' pytest \ + COLUMNS=88 uv run --resolution highest -p '{{ python }}' pytest \ -m '{{ test_marker_integration }}' \ -n {{ workers }} \ --dist loadgroup \ @@ -181,7 +183,7 @@ test-integration python=python_version workers="auto" *pytest_args: test-integration-sequential python=python_version *pytest_args: @echo -e "\n\t\033[33mRunning integration tests sequentially\033[0m" @echo -e "\t\033[33m Use 'just test-integration' for parallel execution\033[0m\n" - uv run --resolution highest -p '{{ python }}' pytest \ + COLUMNS=88 uv run --resolution highest -p '{{ python }}' pytest \ -m '{{ test_marker_integration }}' \ {{ pytest_args }} \ tests @@ -190,7 +192,7 @@ test-integration-sequential python=python_version *pytest_args: [group('qa')] test-lowest python=python_version *pytest_args: @echo "Running lowest dep resolution tests for Python '{{ python }}'" - uv run --resolution lowest-direct -p '{{ python }}' pytest \ + COLUMNS=88 uv run --resolution lowest-direct -p '{{ python }}' pytest \ -m '{{ test_marker_default }}' \ {{ pytest_args }} \ tests @@ -198,7 +200,7 @@ test-lowest python=python_version *pytest_args: # runs local tests in verbose mode with stdout capture; useful for debugging test failures [group('qa')] test-verbose python=python_version *pytest_args: - uv run --resolution highest -p '{{ python }}' pytest \ + COLUMNS=88 uv run --resolution highest -p '{{ python }}' pytest \ -vvv \ -m '{{ test_marker_default }}' \ --show-capture=stdout \ @@ -213,7 +215,7 @@ test-verbose python=python_version *pytest_args: test-integration-verbose python=python_version *pytest_args: @echo -e "\n\t\033[33mRunning integration tests: make sure you set up the web application\033[0m" @echo -e "\t\033[33mand configure 'tests/integration/integration.env'\033[0m\n" - uv run --resolution highest -p '{{ python }}' pytest \ + COLUMNS=88 uv run --resolution highest -p '{{ python }}' pytest \ -m '{{ test_marker_integration }}' \ -vvv \ --capture=no \ @@ -226,7 +228,7 @@ test-integration-quick python=python_version workers="auto" *pytest_args: @echo -e "\n\t\033[33mRunning quick integration tests in parallel (skipping heavy tests)\033[0m" @echo -e "\t\033[33m Workers: {{ workers }}\033[0m" @echo -e "\t\033[33m Marker: {{ test_marker_integration }} and not heavy\033[0m\n" - uv run --resolution highest -p '{{ python }}' pytest \ + COLUMNS=88 uv run --resolution highest -p '{{ python }}' pytest \ -m '{{ test_marker_integration }} and not heavy' \ -n {{ workers }} \ --dist loadgroup \ @@ -258,3 +260,8 @@ publish: update: uv sync --upgrade --dev uv run prek autoupdate + +# runs the progress bars demo with the given file count and rate +[group('development')] +demo-progress-bars count="9" rate="10": + uv run scripts/demo_progress_bars.py --file-count {{ count }} --rate {{ rate }} diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 3f9ebd238..40a0c037c 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -419,6 +419,9 @@ # Tests commonly use boolean fixtures as positional args — intentional "sdk/tests/**" = ["FBT001", "FBT002"] + # Demo scripts intentionally use print(), lazy imports, private member access for mocking + "sdk/scripts/**" = ["T201", "PLC0415", "SLF001", "S108", "RUF001"] + [tool.ruff.format] indent-style = "space" line-ending = "auto" diff --git a/sdk/scripts/demo_progress_bars.py b/sdk/scripts/demo_progress_bars.py new file mode 100755 index 000000000..58967d39b --- /dev/null +++ b/sdk/scripts/demo_progress_bars.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +"""Demo the real byte-level progress bars from the SDK. + +Creates test files, mocks the gateway HTTP layer to simulate transfers +at a given rate, and runs the real download/upload code paths so you +can see the tqdm bars animate smoothly. +""" + +from __future__ import annotations + +import atexit +import json +import os +import shutil +import time +import uuid +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from textwrap import dedent + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +FILE_COUNT = 4 +FILE_SIZE = 10_000_000 # 10 MB per file +RATE_MBPS = 1 # 1 MB/s default +MOCK_CHUNK = 256 # tiny internal chunks -> smooth tqdm animation +_MIN_SLEEP_THRESHOLD = 0.001 # 1 ms: minimum sleep the OS scheduler can deliver +_BYTE_KB = 1024 # bytes per kilobyte + +# Empirical correction: the time-budget accumulator only accounts for +# sleep duration, not the Python overhead per chunk (byte generation, +# yield, loop, monotonic calls). In practice the actual throughput ends +# up ~5% higher than the nominal rate, so we discount the rate used for +# timing calculations by this factor to match the target. +_RATE_OVERHEAD_FUDGE = 0.954 + +# Set dynamically by main() via globals() update; declare here for linting. +rate_bps: float = RATE_MBPS * _BYTE_KB * _BYTE_KB + + +def _human_bytes(n: int) -> str: + value = float(n) + for unit in ("B", "KB", "MB", "GB"): + if abs(value) < _BYTE_KB: + return f"{value:.1f} {unit}" + value /= _BYTE_KB + return f"{value:.1f} PB" + + +def _human_rate(bps: float) -> str: + return _human_bytes(int(bps)).replace("B", "B/s") + + +# --------------------------------------------------------------------------- +# Mock HTTP layer +# --------------------------------------------------------------------------- + + +class _MockResponse: + """A requests.Response look-alike that the gateway code accepts.""" + + status_code = 200 + ok = True + reason = "OK" + + def __init__(self, content: bytes, file_size: int | None = None): + self._content = content + self._file_size = file_size + self.headers = {} + if file_size is not None: + self.headers["Content-Length"] = str(file_size) + + @property + def content(self): + return self._content + + def json(self): + return json.loads(self._content) + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def iter_content(self, chunk_size: int = 8192): + """Yield tiny chunks at the rate, for smooth tqdm updates. + + Instead of calling ``time.sleep`` per chunk (which is + unreliable for sub-millisecond intervals), we compare cumulative + expected time against wall clock and only sleep when the deficit + exceeds 1 ms. This aggregates many tiny delays into fewer but + longer sleeps that the OS scheduler can deliver accurately. + """ + if self._file_size is not None: + inner = min(chunk_size, MOCK_CHUNK) + offset = 0 + _t0 = time.monotonic() + _expected = 0.0 + while offset < self._file_size: + n = min(inner, self._file_size - offset) + _expected += n / (rate_bps * _RATE_OVERHEAD_FUDGE) + _deficit = _expected - (time.monotonic() - _t0) + if _deficit > _MIN_SLEEP_THRESHOLD: + time.sleep(_deficit) + yield b"\x00" * n + offset += n + elif self._content: + yield self._content + + +def make_mock_response(content: bytes, *, file_size: int | None = None): + """Build a requests.Response look-alike that the gateway code accepts.""" + return _MockResponse(content, file_size=file_size) + + +def make_file_json(i: int, file_uuid: str, size: int, rel: str) -> bytes: + """Return serialised File JSON that the gateway returns for this file.""" + now = datetime.now(tz=UTC).isoformat() + later = (datetime.now(tz=UTC) + timedelta(days=30)).isoformat() + info = { + "uuid": file_uuid, + "name": f"demo_file_{i:02d}.bin", + "media_type": "application/octet-stream", + "size": size, + "directory": rel, + "permissions": "rw-rw-r--", + "created_at": now, + "updated_at": now, + "expiration_date": later, + "sum_blake3": None, + } + return json.dumps(info).encode() + + +# --------------------------------------------------------------------------- +# Demos +# --------------------------------------------------------------------------- + + +def demo_downloads(tmp: Path) -> None: + """Run a full download flow using the real Client code paths. + + The gateway._request is patched so every HTTP call returns a local + mock response. All progress bars are the real ones from gateway.py + and client.py. + """ + from spectrumx.client import Client + from spectrumx.gateway import Endpoints + from spectrumx.gateway import HTTPMethods + from spectrumx.models.files import File + + os.environ["SPECTRUMX_API_KEY"] = "demo_key" + + client = Client(host="localhost", verbose=True) + client.dry_run = False + gateway = client._gateway + + uuids_hex = [uuid.uuid4().hex for _ in range(FILE_COUNT)] + file_json_map: dict[str, bytes] = {} + demo_files: list[File] = [] + for i, uid_hex in enumerate(uuids_hex): + j = make_file_json(i, uid_hex, FILE_SIZE, "/demo") + file_json_map[uid_hex] = j + f = File.model_validate_json(j) + f.size = FILE_SIZE + f.local_path = tmp / f.name + demo_files.append(f) + + total_bytes_h = _human_bytes(FILE_COUNT * FILE_SIZE) + rate_h = _human_rate(rate_bps) + print(f" ↓ Downloading {FILE_COUNT} files ({total_bytes_h} at {rate_h})…\n") + + orig_request = gateway._request + + def _mock_request( + method=None, + endpoint=None, + *, + asset_id=None, + endpoint_args=None, + stream=False, + timeout=None, + verbose=False, + **kw, + ): + nonlocal orig_request, file_json_map + + if endpoint == Endpoints.FILES and method == HTTPMethods.GET and asset_id: + uid = asset_id + if uid in file_json_map: + return make_mock_response(file_json_map[uid]) + + elif endpoint == Endpoints.FILE_DOWNLOAD: + uid = (endpoint_args or {}).get("uuid") or asset_id + if uid in file_json_map: + j = json.loads(file_json_map[uid]) + sz = j.get("size", FILE_SIZE) + return make_mock_response(b"", file_size=sz) + + return make_mock_response(b'{"detail":"mocked ok"}') + + try: + gateway._request = _mock_request + results = client.download( + files_to_download=demo_files, + to_local_path=tmp / "downloads", + verbose=True, + overwrite=True, + ) + ok = sum(1 for r in results if r) + fail = len(results) - ok + print(f" ↓ Done: {ok} ok, {fail} failed\n") + finally: + gateway._request = orig_request + + +def demo_uploads(tmp: Path) -> None: + """Run a real upload flow with the gateway patched to simulate slow I/O. + + The _ProgressFile wrapper in upload_new_file tracks bytes read from the + source file, which updates the tqdm progress bar. We patch _request + so that the FILES endpoint reads from the _ProgressFile (triggering the + bar) at the configured rate before returning a success response. + """ + from spectrumx.client import Client + from spectrumx.gateway import Endpoints + + os.environ["SPECTRUMX_API_KEY"] = "demo_key" + os.environ.pop("PYTEST_CURRENT_TEST", None) + + client = Client(host="localhost", verbose=True) + client.dry_run = False + gateway = client._gateway + + upload_root = tmp / "to_upload" + upload_root.mkdir(parents=True, exist_ok=True) + for i in range(FILE_COUNT): + p = upload_root / f"src_file_{i:02d}.bin" + sz_left = FILE_SIZE + with p.open("wb") as f: + while sz_left: + n = min(65536, sz_left) + f.write(os.urandom(n)) + sz_left -= n + + total_h = _human_bytes(FILE_COUNT * FILE_SIZE) + print(f" ↑ Uploading {FILE_COUNT} files ({total_h} at {_human_rate(rate_bps)})…\n") + + orig_request = gateway._request + + def _mock_request( + method=None, + endpoint=None, + *, + asset_id=None, + endpoint_args=None, + stream=False, + timeout=None, + verbose=False, + **kw, + ): + nonlocal orig_request + + if endpoint == Endpoints.FILE_CONTENTS_CHECK: + return make_mock_response( + json.dumps( + { + "file_exists_in_tree": False, + "file_contents_exist_for_user": False, + "user_mutable_attributes_differ": False, + "asset_id": None, + } + ).encode() + ) + if endpoint == Endpoints.FILES and kw.get("files"): + files = kw["files"] + fp = files.get("file") + if fp is not None: + _t0 = time.monotonic() + _expected = 0.0 + while True: + chunk = fp.read(MOCK_CHUNK) + if not chunk: + break + # discounted by _RATE_OVERHEAD_FUDGE (see Config) + _expected += len(chunk) / (rate_bps * _RATE_OVERHEAD_FUDGE) + _deficit = _expected - (time.monotonic() - _t0) + if _deficit > _MIN_SLEEP_THRESHOLD: + time.sleep(_deficit) + return make_mock_response( + make_file_json( + i=0, + file_uuid=str(uuid.uuid4()), + size=FILE_SIZE, + rel="/demo_uploads", + ) + ) + return make_mock_response(b'{"detail":"mocked"}') + + try: + gateway._request = _mock_request + results = client.upload( + local_path=upload_root, + sds_path="/demo_uploads", + verbose=True, + persist_state=False, + ) + ok = sum(1 for r in results if r) + fail = len(results) - ok + print(f" ↑ Done: {ok} ok, {fail} failed\n") + finally: + gateway._request = orig_request + + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +TMP_DIR = Path("/tmp") / f"sdk_demo_progress_{uuid.uuid4().hex[:8]}" + + +def _cleanup() -> None: + if TMP_DIR.exists(): + shutil.rmtree(TMP_DIR, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser( + description=dedent("""\ + Demo real SDK progress bars for byte-level upload/download tracking. + + Creates test files and mocks the gateway HTTP layer so the SDK's + own tqdm progress bars (in gateway.py, client.py, uploads.py) + render with realistic timing — no real server needed. + """) + ) + parser.add_argument( + "--demo", + choices=["download", "upload", "all"], + default="all", + ) + parser.add_argument( + "--rate", + type=float, + default=RATE_MBPS, + help=f"Transfer rate in MB/s (default {RATE_MBPS})", + ) + parser.add_argument( + "--file-count", + type=int, + default=FILE_COUNT, + help=f"Number of test files (default {FILE_COUNT})", + ) + args = parser.parse_args() + + globals().update( + rate_bps=args.rate * 1024 * 1024, + FILE_COUNT=args.file_count, + ) + + # Clean slate (leftover files from crashed runs confuse the upload scanner) + _cleanup() + atexit.register(_cleanup) + TMP_DIR.mkdir(parents=True, exist_ok=True) + + size_h = _human_bytes(FILE_SIZE) + rate_h = _human_rate(rate_bps) + print( + f"\nSDK progress bars demo | {args.file_count} files x {size_h} | {rate_h}\n" + ) + + if args.demo in ("download", "all"): + print("═" * 56) + print(" DOWNLOAD — outer progress bar (client.py)") + print("═" * 56) + print(" Shows file count with byte postfix per completed file") + print() + demo_downloads(TMP_DIR) + + if args.demo in ("upload", "all"): + print("\n") + print("═" * 56) + print(" UPLOAD — workload progress bar (uploads.py)") + print("═" * 56) + print(" Shows per-file completion with total bytes transferred") + print() + demo_uploads(TMP_DIR) + + print("─" * 56) + print(" Demo complete. See the SDK code at:\n") + print(" sdk/src/spectrumx/gateway.py") + print(" sdk/src/spectrumx/client.py") + print(" sdk/src/spectrumx/api/uploads.py\n") + + +if __name__ == "__main__": + main() diff --git a/sdk/src/spectrumx/__init__.py b/sdk/src/spectrumx/__init__.py index 8c5cb173c..9367adb73 100644 --- a/sdk/src/spectrumx/__init__.py +++ b/sdk/src/spectrumx/__init__.py @@ -26,7 +26,13 @@ try: from loguru import logger as log - log.disable(LIB_NAME) + # Remove the default stderr handler that Loguru adds on first import. + # Add a no-op null handler to prevent Loguru from re-adding the default + # handler on the first log call. Individual features + # (enable_logging, enable_structured_logging) add their own sinks + # as needed. + log.remove() + log.add(lambda msg: None, level="TRACE") except ImportError: pass @@ -69,8 +75,9 @@ def enable_logging() -> None: } ] ) - log.enable(LIB_NAME) # pyright: ignore[reportPossiblyUnboundVariable] - log.info(f"Enabled logging for '{LIB_NAME}'") # pyright: ignore[reportPossiblyUnboundVariable] + from spectrumx.utils import LogCategory # noqa: PLC0415 + + log.bind(cat=LogCategory.CONFIG).info(f"Enabled logging for '{LIB_NAME}'") # pyright: ignore[reportPossiblyUnboundVariable] except NameError: import logging # noqa: PLC0415 @@ -79,6 +86,11 @@ def enable_logging() -> None: logger.warning("Install Loguru to enable additional spectrumx logging.") +# ------------------- +# structured logging + +from spectrumx.utils import enable_structured_logging + # ------------------- # package exports @@ -86,6 +98,7 @@ def enable_logging() -> None: "Client", "__version__", "enable_logging", + "enable_structured_logging", "experiments", "models", ] diff --git a/sdk/src/spectrumx/api/captures.py b/sdk/src/spectrumx/api/captures.py index cb2ba6722..5a9367c8b 100644 --- a/sdk/src/spectrumx/api/captures.py +++ b/sdk/src/spectrumx/api/captures.py @@ -19,6 +19,7 @@ from spectrumx.models.captures import CaptureOrigin from spectrumx.models.captures import CaptureType from spectrumx.models.user import User +from spectrumx.utils import LogCategory from spectrumx.utils import log_user_warning if TYPE_CHECKING: @@ -89,23 +90,27 @@ def create( or the user doesn't have permission to create it. """ if index_name: - log.warning( + log.bind(cat=LogCategory.LOG).warning( "The 'index_name' parameter is deprecated and " "will be removed in future versions." ) index_name = index_mapping.get(capture_type, index_name) if not index_name: - log.warning(f"Could not find an index for {capture_type=}") + log.bind(cat=LogCategory.LOG).warning( + f"Could not find an index for {capture_type=}" + ) top_level_dir = PurePosixPath(top_level_dir) if self.verbose: - log.debug( + log.bind(cat=LogCategory.FILESYSTEM).debug( f"Creating capture with {top_level_dir=}, " f"{channel=}, {capture_type=}, {index_name=}, {scan_group=}, {name=}" ) if self.dry_run: - log.debug("Dry run enabled: simulating the capture creation") + log.bind(cat=LogCategory.FILESYSTEM).debug( + "Dry run enabled: simulating the capture creation" + ) return Capture( capture_props={}, capture_type=capture_type, @@ -138,7 +143,9 @@ def create( ) capture = Capture.model_validate_json(capture_raw) if self.verbose: - log.debug(f"Capture created with UUID {capture.uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Capture created with UUID {capture.uuid}" + ) return capture def listing(self, *, capture_type: CaptureType | None = None) -> list[Capture]: @@ -153,9 +160,13 @@ def listing(self, *, capture_type: CaptureType | None = None) -> list[Capture]: A list of the RF captures found owned by the requesting user. """ if self.verbose: - log.debug(f"Listing captures of type {capture_type}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Listing captures of type {capture_type}" + ) if self.dry_run: - log.debug("Dry run enabled: simulating capture listing") + log.bind(cat=LogCategory.FILESYSTEM).debug( + "Dry run enabled: simulating capture listing" + ) num_captures: int = 3 rng = random.Random() # noqa: S311 return [ @@ -167,7 +178,9 @@ def listing(self, *, capture_type: CaptureType | None = None) -> list[Capture]: captures_raw = self.gateway.list_captures(capture_type=capture_type) captures_list_raw, has_more = _extract_page_from_payload(captures_raw) if has_more: - log.warning("Not all capture results may be listed. ") + log.bind(cat=LogCategory.FILESYSTEM).warning( + "Not all capture results may be listed. " + ) # TODO: request more pages if needed captures: list[Capture] = [] for captures_raw in captures_list_raw: @@ -176,10 +189,12 @@ def listing(self, *, capture_type: CaptureType | None = None) -> list[Capture]: captures.append(capture) except ValidationError as err: log_user_warning(f"Validation error loading capture: {captures_raw}") - log.exception(err) + log.bind(cat=LogCategory.FILESYSTEM).exception(err) continue if self.verbose: - log.debug(f"Listing {len(captures)} captures") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Listing {len(captures)} captures" + ) return captures def update( @@ -196,10 +211,14 @@ def update( None """ if self.verbose: - log.debug(f"Updating capture with UUID {capture_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Updating capture with UUID {capture_uuid}" + ) if self.dry_run: - log.debug(f"Dry run enabled: simulating capture update {capture_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Dry run enabled: simulating capture update {capture_uuid}" + ) return capture_raw = self.gateway.update_capture( @@ -208,7 +227,9 @@ def update( ) capture = Capture.model_validate_json(capture_raw) if self.verbose: - log.debug(f"Capture updated with UUID {capture.uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Capture updated with UUID {capture.uuid}" + ) def read( self, @@ -226,12 +247,16 @@ def read( The capture object. """ if self.verbose: - log.debug(f"Reading capture with UUID {capture_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Reading capture with UUID {capture_uuid}" + ) capture_raw = self.gateway.read_capture(capture_uuid=capture_uuid) capture = Capture.model_validate_json(capture_raw) if self.verbose: - log.debug(f"Capture read with UUID {capture.uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Capture read with UUID {capture.uuid}" + ) return capture def delete( @@ -249,17 +274,23 @@ def delete( if it has already been deleted; if this user doesn't own it. """ if self.verbose: - log.debug(f"Deleting capture with UUID {capture_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Deleting capture with UUID {capture_uuid}" + ) if self.dry_run: - log.debug(f"Dry run enabled: would delete capture {capture_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Dry run enabled: would delete capture {capture_uuid}" + ) return True self.gateway.delete_capture( capture_uuid=capture_uuid, ) if self.verbose: - log.debug(f"Capture deleted with UUID {capture_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Capture deleted with UUID {capture_uuid}" + ) return True def revoke_share_permissions(self, capture_uuid: uuid.UUID) -> bool: @@ -268,9 +299,13 @@ def revoke_share_permissions(self, capture_uuid: uuid.UUID) -> bool: Use this (or the web portal) before :meth:`delete` when the capture is shared. """ if self.verbose: - log.debug(f"Revoking share permissions for capture {capture_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Revoking share permissions for capture {capture_uuid}" + ) if self.dry_run: - log.debug("Dry run enabled: would revoke capture share permissions") + log.bind(cat=LogCategory.FILESYSTEM).debug( + "Dry run enabled: would revoke capture share permissions" + ) return True self.gateway.revoke_capture_share_permissions(capture_uuid=capture_uuid) return True @@ -281,9 +316,13 @@ def detach_from_datasets(self, capture_uuid: uuid.UUID) -> bool: Use before :meth:`delete` when the capture is still linked to datasets. """ if self.verbose: - log.debug(f"Detaching capture {capture_uuid} from datasets") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Detaching capture {capture_uuid} from datasets" + ) if self.dry_run: - log.debug("Dry run enabled: would detach capture from datasets") + log.bind(cat=LogCategory.FILESYSTEM).debug( + "Dry run enabled: would detach capture from datasets" + ) return True self.gateway.detach_capture_from_datasets(capture_uuid=capture_uuid) return True @@ -322,7 +361,9 @@ def advanced_search( # TODO: adapt this function to return a Paginator[Capture] object if self.dry_run: - log.debug("Dry run enabled: simulating search results") + log.bind(cat=LogCategory.FILESYSTEM).debug( + "Dry run enabled: simulating search results" + ) rng = random.Random() # noqa: S311 return [ _generate_capture(capture_type=rng.choice(list(CaptureType))) @@ -347,12 +388,14 @@ def advanced_search( log_user_warning( f"Validation error loading search result: {result_raw}" ) - log.exception(err) + log.bind(cat=LogCategory.FILESYSTEM).exception(err) continue else: captures.append(capture) if self.verbose: - log.debug(f"Search returned {len(captures)} captures") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Search returned {len(captures)} captures" + ) return captures diff --git a/sdk/src/spectrumx/api/datasets.py b/sdk/src/spectrumx/api/datasets.py index 9ae8f7b36..8cf0ccd33 100644 --- a/sdk/src/spectrumx/api/datasets.py +++ b/sdk/src/spectrumx/api/datasets.py @@ -11,6 +11,7 @@ from spectrumx.models.datasets import Dataset from spectrumx.models.files import File from spectrumx.ops.pagination import Paginator +from spectrumx.utils import LogCategory from spectrumx.utils import log_user if TYPE_CHECKING: @@ -118,11 +119,15 @@ def get_files( log_user("Dry run enabled: files will be simulated") if artifacts_only and capture_uuids: - log.warning("Capture UUIDs are not allowed when artifacts_only is True.") + log.bind(cat=LogCategory.FILESYSTEM).warning( + "Capture UUIDs are not allowed when artifacts_only is True." + ) capture_uuids = None if artifacts_only and top_level_dirs: - log.info("Top level directories included will ONLY return artifact files.") + log.bind(cat=LogCategory.FILESYSTEM).info( + "Top level directories included will ONLY return artifact files." + ) list_kwargs: dict[str, Any] = {"dataset_uuid": dataset_uuid} if capture_uuids is not None: @@ -155,17 +160,23 @@ def delete( True if the dataset was deleted successfully, or if in dry run mode. """ if self.verbose: - log.debug(f"Deleting dataset with UUID {dataset_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Deleting dataset with UUID {dataset_uuid}" + ) if self.dry_run: - log.debug(f"Dry run enabled: would delete dataset {dataset_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Dry run enabled: would delete dataset {dataset_uuid}" + ) return True self.gateway.delete_dataset( dataset_uuid=dataset_uuid, ) if self.verbose: - log.debug(f"Dataset deleted with UUID {dataset_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Dataset deleted with UUID {dataset_uuid}" + ) return True def revoke_share_permissions(self, dataset_uuid: UUID) -> bool: @@ -174,9 +185,13 @@ def revoke_share_permissions(self, dataset_uuid: UUID) -> bool: Use this (or the web portal) before :meth:`delete` when the dataset is shared. """ if self.verbose: - log.debug(f"Revoking share permissions for dataset {dataset_uuid}") + log.bind(cat=LogCategory.FILESYSTEM).debug( + f"Revoking share permissions for dataset {dataset_uuid}" + ) if self.dry_run: - log.debug("Dry run enabled: would revoke dataset share permissions") + log.bind(cat=LogCategory.FILESYSTEM).debug( + "Dry run enabled: would revoke dataset share permissions" + ) return True self.gateway.revoke_dataset_share_permissions(dataset_uuid=dataset_uuid) return True diff --git a/sdk/src/spectrumx/api/sds_files.py b/sdk/src/spectrumx/api/sds_files.py index eaee3c625..d0fd40dc7 100644 --- a/sdk/src/spectrumx/api/sds_files.py +++ b/sdk/src/spectrumx/api/sds_files.py @@ -3,6 +3,7 @@ # ruff: noqa: SLF001 # pyright: reportPrivateUsage=false +import collections.abc import os import tempfile import uuid @@ -23,6 +24,7 @@ from spectrumx.models.files import File from spectrumx.ops import files from spectrumx.ops.pagination import Paginator +from spectrumx.utils import LogCategory from spectrumx.utils import log_user from spectrumx.utils import log_user_warning @@ -78,6 +80,7 @@ def download_file( to_local_path: Path | str | None = None, skip_contents: bool = False, warn_missing_path: bool = True, + progress_callback: collections.abc.Callable[[int], None] | None = None, ) -> File: """Downloads a file from SDS: metadata and contents (unless skip_contents=True). @@ -109,6 +112,7 @@ def download_file( valid_uuid=valid_uuid, valid_local_path_or_none=valid_local_path_or_none, skip_contents=skip_contents, + progress_callback=progress_callback, ) return file_instance @@ -162,6 +166,7 @@ def upload_file( client: Client, local_file: File | Path | str, sds_path: PurePosixPath | Path | str = "/", + progress_callback: collections.abc.Callable[[int], None] | None = None, ) -> File: """Uploads a file to SDS. @@ -196,7 +201,9 @@ def upload_file( file_instance = files.construct_file(local_file, sds_path=sds_path) del local_file - return __upload_file_mux(client=client, file_instance=file_instance) + return __upload_file_mux( + client=client, file_instance=file_instance, progress_callback=progress_callback + ) def detach_file_from_datasets( @@ -287,19 +294,18 @@ def __download_file_contents_if_applicable( valid_uuid: UUID4, valid_local_path_or_none: Path | None, skip_contents: bool = False, + progress_callback: collections.abc.Callable[[int], None] | None = None, ) -> None: if skip_contents: - msg = ( - "A file instance was provided and skip_contents " - "is True: nothing to download." + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=1).warning( + "Download skipped: skip_contents=True, no file contents to download" ) - log_user_warning(msg) return if client.dry_run: file_instance.local_path = valid_local_path_or_none - log_user( - "Dry run enabled: file contents would be " - f"downloaded as {file_instance.local_path}" + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=2).info( + f"Download dry-run: file contents would be saved to " + f"{file_instance.local_path}" ) return downloaded_path: Path = __download_file_contents( @@ -307,6 +313,7 @@ def __download_file_contents_if_applicable( file_uuid=valid_uuid, target_path=valid_local_path_or_none, contents_lock=file_instance.contents_lock, # pyright: ignore[reportPrivateUsage] + progress_callback=progress_callback, ) file_instance.local_path = downloaded_path @@ -317,6 +324,7 @@ def __download_file_contents( file_uuid: UUID4 | str, contents_lock: RLock, target_path: Path | None = None, + progress_callback: collections.abc.Callable[[int], None] | None = None, ) -> Path: """Downloads the contents of a file from SDS to a location on disk. @@ -327,8 +335,10 @@ def __download_file_contents( to ensure partial downloads don't leave incomplete files in place. Args: - file_uuid: The UUID of the file to download from SDS. - target_path: The local path to save the downloaded file to. + file_uuid: The UUID of the file to download from SDS. + target_path: The local path to save the downloaded file to. + progress_callback: Optional callable invoked with byte count as + chunks are received from the stream (for progress tracking). Returns: The local path to the downloaded file. """ @@ -345,7 +355,9 @@ def __download_file_contents( ) if client.dry_run: - log_user(f"Dry run enabled: file would be saved as {target_path}") + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=2).info( + f"Download dry-run: file contents would be saved to {target_path}" + ) return target_path target_path.parent.mkdir(parents=True, exist_ok=True) @@ -370,6 +382,8 @@ def __download_file_contents( with download_path.open(mode="wb") as file_ptr, contents_lock: for chunk in client._gateway.get_file_contents_by_id(uuid=uuid_to_set.hex): file_ptr.write(chunk) + if progress_callback: + progress_callback(len(chunk)) file_ptr.flush() os.fsync(file_ptr.fileno()) @@ -397,11 +411,9 @@ def __extract_download_info_from_file_instance( if to_local_path: file_instance.local_path = Path(to_local_path) if file_instance.local_path is None and warn_missing_path: - msg = ( - "The file instance passed is missing a local path to " - "download to. A temporary file will be created on disk." + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=1).warning( + "Download: no local path set on file instance; using temporary file" ) - log_user_warning(msg) valid_uuid = file_instance.uuid valid_local_path_or_none = file_instance.local_path return file_instance, valid_uuid, valid_local_path_or_none @@ -465,8 +477,9 @@ def __pre_fetch_file_for_download( msg = "Expected a file instance or UUID to download." raise ValueError(msg) if to_local_path is None and warn_missing_path: - msg = "The file will be downloaded as temporary." - log_user_warning(msg) + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=1).warning( + "Download: file will be saved to a temporary location" + ) valid_local_path_or_none: Path | None = ( Path(to_local_path) if to_local_path else None @@ -476,7 +489,9 @@ def __pre_fetch_file_for_download( uuid.UUID(file_uuid) if isinstance(file_uuid, str) else file_uuid ) if client.dry_run: - log_user("Dry run enabled: a sample file is being returned instead") + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=2).info( + "Download dry-run: returning a sample file instead of downloading" + ) file_instance: File = files.generate_sample_file(valid_uuid) else: file_bytes: bytes = client._gateway.get_file_by_id(uuid=valid_uuid.hex) @@ -484,7 +499,12 @@ def __pre_fetch_file_for_download( return file_instance, valid_uuid, valid_local_path_or_none -def __upload_file_mux(*, client: Client, file_instance: File) -> File: # noqa: C901 +def __upload_file_mux( # noqa: C901 + *, + client: Client, + file_instance: File, + progress_callback: collections.abc.Callable[[int], None] | None = None, +) -> File: """Uploads a file instance to SDS, choosing the right upload mode.""" file_path = file_instance.local_path # check whether sds already has this file for this user @@ -506,12 +526,14 @@ def __upload_file_mux(*, client: Client, file_instance: File) -> File: # noqa: if verbose: log_user(f"Uploading contents and metadata for '{file_path}'") return __upload_contents_and_metadata( - client=client, file_instance=file_instance + client=client, + file_instance=file_instance, + progress_callback=progress_callback, ) case FileUploadMode.UPLOAD_METADATA_ONLY: if verbose: log_user(f"Uploading only metadata for '{file_path}'") - log.debug( + log.bind(cat=LogCategory.UPLOAD).debug( f"Uploading only metadata '{file_path}' with sibling '{asset_id}'" ) if asset_id is None: @@ -523,7 +545,9 @@ def __upload_file_mux(*, client: Client, file_instance: File) -> File: # noqa: case FileUploadMode.UPDATE_METADATA_ONLY: # pragma: no cover if verbose: log_user(f"Updating metadata for '{file_path}'") - log.debug(f"Updating metadata '{file_path}' with asset '{asset_id}'") + log.bind(cat=LogCategory.UPLOAD).debug( + f"Updating metadata '{file_path}' with asset '{asset_id}'" + ) assert asset_id is not None, "Expected an asset ID when updating metadata" return __update_existing_file_metadata_only( client=client, file_instance=file_instance, asset_id=asset_id @@ -537,6 +561,7 @@ def __upload_contents_and_metadata( *, client: Client, file_instance: File, + progress_callback: collections.abc.Callable[[int], None] | None = None, ) -> File: """UPLOADS a new file instance to SDS with contents and metadata. @@ -551,7 +576,9 @@ def __upload_contents_and_metadata( return file_instance assert not client.dry_run, "Internal error: expected dry run to be disabled." - file_response = client._gateway.upload_new_file(file_instance=file_instance) + file_response = client._gateway.upload_new_file( + file_instance=file_instance, progress_callback=progress_callback + ) uploaded_file = File.model_validate_json(file_response) uploaded_file.local_path = file_instance.local_path return uploaded_file diff --git a/sdk/src/spectrumx/api/uploads.py b/sdk/src/spectrumx/api/uploads.py index f2a936742..720f19be2 100644 --- a/sdk/src/spectrumx/api/uploads.py +++ b/sdk/src/spectrumx/api/uploads.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio +import contextlib import hashlib import json import sys +import threading from datetime import UTC from datetime import datetime from pathlib import Path @@ -14,6 +16,7 @@ from typing import NoReturn from anyio import Path as AsyncPath +from loguru import logger as log from pydantic import BaseModel from pydantic import Field from tqdm import tqdm @@ -30,8 +33,11 @@ # pydantic complains if not defined out of type checking block ) from spectrumx.ops import files as file_ops +from spectrumx.utils import LogCategory +from spectrumx.utils import credit_unstreamed_file_bytes from spectrumx.utils import get_prog_bar from spectrumx.utils import is_test_env +from spectrumx.utils import log_context from spectrumx.utils import log_user from spectrumx.utils import log_user_warning from spectrumx.vendor.xdg_base_dirs import xdg_state_home @@ -275,6 +281,7 @@ class UploadWorkload(BaseModel): sds_path: PurePosixPath = Field(default_factory=lambda: PurePosixPath("/")) max_concurrent_uploads: int = 5 persist_state: bool = True + progress_log_period_secs: int = 30 # file buffers fq_discovered: list[File] = Field(default_factory=list) @@ -295,6 +302,7 @@ class UploadWorkload(BaseModel): tqdm[NoReturn] | None # pyrefly: ignore[bad-specialization] ) = None _persistence_manager: UploadPersistenceManager | None = None + _progress_log_task: asyncio.Task | None = None model_config = { "populate_by_name": True, @@ -304,6 +312,7 @@ class UploadWorkload(BaseModel): def model_post_init(self, context) -> None: """Initialize the workload with an async lock.""" self._state_lock = asyncio.Lock() + self._prog_bar_lock = threading.Lock() self._workload_id = self._compute_workload_id() self._persistence_manager = UploadPersistenceManager( local_root=self.local_root, @@ -316,7 +325,6 @@ def model_post_init(self, context) -> None: **upload_prog_bar_kwargs, # pyrefly: ignore[bad-argument-type] leave=True, ) - self._prog_uploaded_bytes.disable = True self._prog_uploaded_bytes.clear() super().model_post_init(context) @@ -476,16 +484,32 @@ async def _mark_completed_result(self, successful_result: Result[File]) -> None: self.fq_completed.append(uploaded_file) assert self._persistence_manager is not None await self._persistence_manager.save_persisted_upload(uploaded_file) - await self._update_prog_bars(num_bytes=uploaded_file.size) + await self._update_prog_bars() + log.bind(cat=LogCategory.UPLOAD).info( + f"Uploaded: {uploaded_file.name}", + file_name=uploaded_file.name, + file_size=uploaded_file.size, + ) + + def _update_prog_bar_bytes(self, num_bytes: int) -> None: + """Thread-safe byte counter update for the shared upload progress bar.""" + bar = self._prog_uploaded_bytes + if bar is None: + return + with self._prog_bar_lock: + bar.update(num_bytes) async def _update_prog_bars(self, num_bytes: int | None = None) -> None: """Update progress bars based on current upload state.""" if self.verbose and isinstance(self._prog_uploaded_bytes, tqdm): async with self._state_lock: - self._prog_uploaded_bytes.disable = False - self._prog_uploaded_bytes.set_description(self._get_progress_string()) - if num_bytes: - self._prog_uploaded_bytes.update(num_bytes) + with self._prog_bar_lock: + self._prog_uploaded_bytes.disable = False + self._prog_uploaded_bytes.set_description( + self._get_progress_string() + ) + if num_bytes: + self._prog_uploaded_bytes.update(num_bytes) def _get_progress_string(self) -> str: """Returns a progress string for the upload progress bar.""" @@ -529,7 +553,8 @@ async def _reset_progress(self) -> None: self.fq_completed.clear() self.fq_failed.clear() if isinstance(self._prog_uploaded_bytes, tqdm): - self._prog_uploaded_bytes.clear() + with self._prog_bar_lock: + self._prog_uploaded_bytes.clear() @property def total_files(self) -> int: @@ -560,14 +585,44 @@ async def _upload_next_file(self) -> Result[File]: return Result(exception=StopAsyncIteration("No more files to upload")) try: + # Throttled wrapper: accumulate bytes and only push to tqdm + # every ~100 KB, to avoid excessive refresh overhead (mirrors + # the same pattern used in _download_files). + _acc: list[int] = [0] + _streamed: list[int] = [0] + _throttle = 100_000 + bar = self._prog_uploaded_bytes + + def _throttled_update(n: int) -> None: + if bar is None: + return + _acc[0] += n + _streamed[0] += n + if _acc[0] >= _throttle: + self._update_prog_bar_bytes(_acc[0]) + _acc[0] = 0 + result = Result( value=await asyncio.to_thread( self.client._sds_files.upload_file, # noqa: SLF001 # pyright: ignore[reportPrivateUsage] client=self.client, local_file=next_file, sds_path=self.sds_path, + progress_callback=_throttled_update if bar is not None else None, ) ) + # Flush any remaining bytes that didn't reach the threshold + if bar is not None and _acc[0] > 0: + self._update_prog_bar_bytes(_acc[0]) + _acc[0] = 0 + if bar is not None: + credited = credit_unstreamed_file_bytes( + file_size=next_file.size, + bytes_streamed=_streamed[0], + prog_bar=None, + ) + if credited: + self._update_prog_bar_bytes(credited) await self._mark_completed_result(successful_result=result) except SDSError as err: await self._mark_failed_file(sds_file=next_file, reason=str(err)) @@ -603,16 +658,50 @@ async def _has_pending_files(self) -> bool: async with self._state_lock: return bool(self.fq_pending) + async def _periodic_progress_logger(self) -> None: + """Emit consolidated progress log every N seconds.""" + while True: + await asyncio.sleep(self.progress_log_period_secs) + async with self._state_lock: + completed = len(self.fq_completed) + total = len(self.fq_discovered) + total_bytes = self.total_bytes + uploaded_bytes = sum(f.size for f in self.fq_completed) + failures = len(self.fq_failed) + in_progress = len(self.fq_in_progress) + skipped = len(self.fq_skipped) + log.bind(cat=LogCategory.UPLOAD).info( + "Upload progress", + completed=completed, + total=total, + bytes_uploaded=uploaded_bytes, + bytes_total=total_bytes, + failures=failures, + in_progress=in_progress, + skipped=skipped, + ) + async def _execute_uploads(self) -> None: """Execute uploads concurrently using worker coroutines.""" await self._reset_progress() + # Start periodic progress logger + self._progress_log_task = asyncio.create_task(self._periodic_progress_logger()) + workers = [self._upload_worker(i) for i in range(self.max_concurrent_uploads)] - await asyncio.gather(*workers) + try: + await asyncio.gather(*workers) + finally: + # Cancel progress logger + if self._progress_log_task: + self._progress_log_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._progress_log_task # close progress bar if isinstance(self._prog_uploaded_bytes, tqdm): - self._prog_uploaded_bytes.close() + with self._prog_bar_lock: + self._prog_uploaded_bytes.close() async def _reset_state(self) -> None: """Reset all state buffers.""" @@ -632,7 +721,8 @@ async def _register_discovered_file(self, file_model: File) -> None: self.fq_pending.append(file_model) self.total_bytes += file_model.size if isinstance(self._prog_uploaded_bytes, tqdm): - self._prog_uploaded_bytes.total = self.total_bytes + with self._prog_bar_lock: + self._prog_uploaded_bytes.total = self.total_bytes async def _add_skipped(self, skipped: SkippedUpload) -> None: """Add a skipped file entry.""" @@ -649,26 +739,60 @@ async def run(self, client: Client) -> list[Result[File]]: List of results for each uploaded file. """ self.client = client - await self._discover_files() - await self._execute_uploads() + with log_context( + upload_id=self._workload_id, + upload_dir=str(self.local_root), + ): + await self._discover_files() + await self._execute_uploads() + + # Completion summary + elapsed = datetime.now(UTC) - ( + self.discovery_started_at or datetime.now(UTC) + ) + elapsed_sec = elapsed.total_seconds() + uploaded_bytes = sum(f.size for f in self.fq_completed) + avg_speed_bps: float | None = ( + uploaded_bytes / elapsed_sec if elapsed_sec > 0 else None + ) + status = "clean" if len(self.fq_failed) == 0 else "interrupted" + log.bind( + cat=LogCategory.UPLOAD, + total_files=len(self.fq_discovered), + total_bytes=self.total_bytes, + uploaded=len(self.fq_completed), + failed=len(self.fq_failed), + skipped=len(self.fq_skipped), + elapsed_seconds=elapsed_sec, + avg_speed_bps=avg_speed_bps, + status=status, + ).info( + "Upload complete", + ) - results = [Result(value=file_obj) for file_obj in self.fq_completed] - results.extend( - Result( - exception=error, - error_info={ - "sds_file": error.sds_file, - "reason": error.reason, - "error": str(error), - }, + results = [Result(value=file_obj) for file_obj in self.fq_completed] + results.extend( + Result( + exception=error, + error_info={ + "sds_file": error.sds_file, + "reason": error.reason, + "error": str(error), + }, + ) + for error in self.fq_failed ) - for error in self.fq_failed - ) - return results + return results @staticmethod def _remove_from_buffer(sds_file: File, buffer: list[File]) -> None: + """Remove from buffer, matching by local_path (persists through upload).""" + if sds_file.local_path: + for i, f in enumerate(buffer): + if f.local_path and f.local_path == sds_file.local_path: + buffer.pop(i) + return try: buffer.remove(sds_file) except ValueError: @@ -719,6 +843,7 @@ def upload_resumable( verbose=verbose, warn_skipped=warn_skipped, persist_state=persist_state, + progress_log_period_secs=client._config.progress_log_period_secs, # noqa: SLF001 ) return asyncio.run(upload_workload.run(client=client)) diff --git a/sdk/src/spectrumx/client.py b/sdk/src/spectrumx/client.py index 7ff6b4450..158416c78 100644 --- a/sdk/src/spectrumx/client.py +++ b/sdk/src/spectrumx/client.py @@ -1,7 +1,10 @@ """Client for the SpectrumX Data System.""" +import collections.abc +import time from collections.abc import Collection from collections.abc import Mapping +from datetime import UTC from datetime import datetime from pathlib import Path from pathlib import PurePosixPath @@ -23,15 +26,20 @@ from spectrumx.ops.pagination import Paginator from . import __version__ +from . import utils from .config import SDSConfig from .gateway import GatewayClient from .models.files import File from .ops import files +from .utils import LogCategory from .utils import clean_local_path +from .utils import credit_unstreamed_file_bytes +from .utils import enable_structured_logging from .utils import get_prog_bar from .utils import log_user from .utils import log_user_error from .utils import log_user_warning +from .utils import set_persistent_log_context def _normalize_top_level_dir_prefix( @@ -96,6 +104,7 @@ def __init__( env_file: Path | None = None, env_config: Mapping[str, Any] | None = None, verbose: bool = False, + log_file: Path | None = None, ) -> None: # avoids circular imports from spectrumx.api import sds_files as _sds_files_api # noqa: PLC0415 @@ -112,6 +121,22 @@ def __init__( env_config=env_config, sds_host=host, verbose=self.verbose, + log_file=log_file, + ) + + # Enable structured logging + _log_path = self._config.log_file + enable_structured_logging(log_path=_log_path) + log_user(f"Structured log: {utils._current_log_path}") # noqa: SLF001 + + # Bind persistent context + api_key_prefix = "" + if self._config.api_key: + api_key_prefix = self._config.api_key.split(".")[0] + + set_persistent_log_context( + api_key_prefix=api_key_prefix, + timeout=self._config.timeout, ) # initialize the gateway @@ -139,11 +164,7 @@ def __str__(self) -> str: def _issue_user_alerts(self) -> None: """Logs important messages on initialization.""" - if __version__.startswith("0.1."): - log_user_warning( - "This version of the SDK is in early development. " - "Expect breaking changes in the future." - ) + log_user(f"SDK version: {__version__}") if self.dry_run: log_user("Dry run enabled: no SDS requests will be made or files written") @@ -203,9 +224,11 @@ def authenticate(self) -> None: log_user("Dry run is enabled: assuming successful authentication") log_user("To authenticate against SDS, set Client.dry_run to False") else: - log.warning(f"Dry run DISABLED: authenticating against '{self.host}'") + log.bind(cat=LogCategory.AUTH).warning( + f"Dry run DISABLED: authenticating against '{self.host}'" + ) self._gateway.authenticate() - log.info("Authenticated successfully") + log.bind(cat=LogCategory.AUTH).info("Authenticated successfully") self.is_authenticated = True # ======= FILE METHODS @@ -315,7 +338,9 @@ def _prepare_download_directory(self, to_local_path: Path) -> None: """Prepare the download directory.""" if not to_local_path.exists(): if self.dry_run: - log_user(f"Dry run: would create the directory '{to_local_path}'") + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=2).info( + f"Download dry-run: directory would be created at '{to_local_path}'" + ) else: to_local_path.mkdir(parents=True) @@ -330,12 +355,13 @@ def _get_files_to_download( ) -> DownloadFileSource: """Get the list of files to download.""" if self.dry_run: - log_user( - "Called download() in dry run mode: " - "files are fabricated and not written to disk." + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=2).info( + "Download dry-run: files are simulated (not written to disk)" ) files_to_download = files.generate_random_files(num_files=10) - log_user(f"Dry run: discovered {len(files_to_download)} files (samples)") + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=2).info( + f"Download dry-run: discovered {len(files_to_download)} sample files" + ) else: if from_sds_path is not None: files_to_download = self.list_files( @@ -350,7 +376,10 @@ def _get_files_to_download( ) raise ValueError(error_msg) if verbose: - log_user(f"Discovered {len(files_to_download)} files") + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=2).info( + f"Download: discovered {len(files_to_download)} files " + f"in SDS path '{from_sds_path}'" + ) return files_to_download @@ -365,10 +394,183 @@ def _download_files( ) -> list[Result[File]]: """Download the files and return results.""" prefix = "Dry-run: simulating download:" if self.dry_run else "Downloading:" + + total_files = len(files_to_download) + + # Mutable container so per-chunk closure can update byte count + bytes_downloaded_shared: list[int] = [0] + _download_start = datetime.now(UTC) + + # Only compute total bytes for lists; Paginator would be fully consumed. + if isinstance(files_to_download, list): + total_bytes_total = sum(f.size for f in files_to_download) + results = self._download_files_with_byte_progress( + files_to_download=files_to_download, + total_bytes_total=total_bytes_total, + to_local_path=to_local_path, + skip_contents=skip_contents, + overwrite=overwrite, + verbose=verbose, + prefix=prefix, + total_files=total_files, + period=self._config.progress_log_period_secs, + bytes_downloaded_shared=bytes_downloaded_shared, + ) + else: + total_bytes_total = None + results = self._download_files_fallback( + files_to_download=files_to_download, + to_local_path=to_local_path, + skip_contents=skip_contents, + overwrite=overwrite, + verbose=verbose, + prefix=prefix, + ) + + # Completion summary (shared by both branches) + completed = sum(1 for r in results if r) + failed = len(results) - completed + elapsed = datetime.now(UTC) - _download_start + elapsed_sec = elapsed.total_seconds() + transferred_bytes = ( + bytes_downloaded_shared[0] if total_bytes_total is not None else None + ) + avg_speed_bps: float | None = ( + transferred_bytes / elapsed_sec + if transferred_bytes is not None and elapsed_sec > 0 + else None + ) + status = "clean" if failed == 0 else "interrupted" + log.bind( + cat=LogCategory.DOWNLOAD, + total_files=total_files, + total_bytes=total_bytes_total, + downloaded=completed, + failed=failed, + elapsed_seconds=elapsed_sec, + avg_speed_bps=avg_speed_bps, + status=status, + ).info( + "Download complete", + ) + + return results + + def _download_files_with_byte_progress( # noqa: PLR0913 + self, + *, + files_to_download: list[File], + total_bytes_total: int, + to_local_path: Path, + skip_contents: bool, + overwrite: bool, + verbose: bool, + prefix: str, + total_files: int, + period: float, + bytes_downloaded_shared: list[int], + ) -> list[Result[File]]: + """Byte-level progress bar with throttled per-chunk callback.""" prog_bar = get_prog_bar( - files_to_download, desc="Downloading", disable=not verbose + total=total_bytes_total, + desc="Downloading", + unit="B", + unit_scale=True, + unit_divisor=1024, + disable=not verbose or utils.is_test_env(), ) + # Throttle callback updates: ~100 KB between tqdm refreshes + _acc: list[int] = [0] + _file_streamed: list[int] = [0] + _throttle = 100_000 + + def _on_download_bytes(n: int) -> None: + """Per-chunk callback: accumulate bytes, update bar every ~100 KB.""" + _acc[0] += n + _file_streamed[0] += n + bytes_downloaded_shared[0] += n + if _acc[0] >= _throttle: + prog_bar.update(_acc[0]) + _acc[0] = 0 + + _last_progress_log = 0.0 + results: list[Result[File]] = [] + for file_info in files_to_download: + _file_streamed[0] = 0 + prog_bar.set_description(f"{prefix} '{file_info.name}'") + result = self.download_single_file( + file_info=file_info, + to_local_path=to_local_path, + skip_contents=skip_contents, + overwrite=overwrite, + progress_callback=_on_download_bytes, + ) + results.append(result) + + if _acc[0] > 0: + prog_bar.update(_acc[0]) + _acc[0] = 0 + if result: + credit_unstreamed_file_bytes( + file_size=file_info.size, + bytes_streamed=_file_streamed[0], + prog_bar=prog_bar, + bytes_accounted=bytes_downloaded_shared, + ) + + # Per-file completion log + if result: + log.bind(cat=LogCategory.DOWNLOAD).info( + f"Downloaded: {file_info.name}", + file_name=file_info.name, + file_size=file_info.size, + ) + else: + log.bind(cat=LogCategory.DOWNLOAD).warning( + f"Download failed: {file_info.name}", + file_name=file_info.name, + error=str(result.exception_or(Exception("Unknown"))), + ) + + # Periodic progress log + now = time.monotonic() + if now - _last_progress_log >= period: + _last_progress_log = now + completed = sum(1 for r in results if r) + failed = len(results) - completed + log.bind(cat=LogCategory.DOWNLOAD).info( + "Download progress", + completed=completed, + total=total_files, + bytes_downloaded=bytes_downloaded_shared[0], + bytes_total=total_bytes_total, + failures=failed, + ) + + # Flush any remaining accumulated bytes + if _acc[0] > 0: + prog_bar.update(_acc[0]) + prog_bar.refresh() + + return results + + def _download_files_fallback( + self, + *, + files_to_download: DownloadFileSource, + to_local_path: Path, + skip_contents: bool, + overwrite: bool, + verbose: bool, + prefix: str, + ) -> list[Result[File]]: + """Fallback: file-level progress bar (total bytes unknown, e.g. Paginator).""" + prog_bar = get_prog_bar( + files_to_download, + desc="Downloading", + disable=not verbose or utils.is_test_env(), + ) results: list[Result[File]] = [] for file_info in prog_bar: prog_bar.set_description(f"{prefix} '{file_info.name}'") @@ -380,6 +582,20 @@ def _download_files( ) results.append(result) + # Per-file completion log + if result: + log.bind(cat=LogCategory.DOWNLOAD).info( + f"Downloaded: {file_info.name}", + file_name=file_info.name, + file_size=file_info.size, + ) + else: + log.bind(cat=LogCategory.DOWNLOAD).warning( + f"Download failed: {file_info.name}", + file_name=file_info.name, + error=str(result.exception_or(Exception("Unknown"))), + ) + return results def download_single_file( @@ -389,6 +605,7 @@ def download_single_file( to_local_path: Path, skip_contents: bool, overwrite: bool, + progress_callback: collections.abc.Callable[[int], None] | None = None, ) -> Result[File]: """Download a single file and return the result.""" # Handle local files without UUID @@ -417,22 +634,31 @@ def download_single_file( # skip file download when local exists and not overwriting if not skip_contents and local_file_path.exists() and not overwrite: - log_user(f"Skipping local file: '{local_file_path}'") + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=2).info( + f"Download skipped: local file exists " + f"(set overwrite=True to replace): '{local_file_path}'" + ) return Result(value=file_info) # never re-download identical files if not skip_contents and local_file_path.exists() and overwrite: if file_info.sum_blake3 == file_info.compute_sum_blake3(): - log_user(f"Skipping identical file: '{local_file_path}'") + log.bind(cat=LogCategory.DOWNLOAD).opt(depth=2).info( + f"Download skipped: local copy is identical to SDS version " + f"(BLAKE3 match): '{local_file_path}'" + ) return Result(value=file_info) # download the file try: - log.debug(f"Dw: {local_file_path}") + log.bind(cat=LogCategory.DOWNLOAD).debug( + f"Downloading file contents to: {local_file_path}" + ) downloaded_file = self.download_file( file_uuid=file_info.uuid, to_local_path=local_file_path, skip_contents=skip_contents, + progress_callback=progress_callback, ) return Result(value=downloaded_file) except SDSError as err: @@ -454,8 +680,10 @@ def _is_path_relative_to_target( """Check if the local file path is relative to the target directory.""" local_file_path = local_file_path.resolve() to_local_path = to_local_path.resolve() - log.debug(f"Resolved path: {local_file_path}") - log.debug(f"Target path: {to_local_path}") + log.bind(cat=LogCategory.DOWNLOAD).debug( + f"Download path resolved: {local_file_path}" + ) + log.bind(cat=LogCategory.DOWNLOAD).debug(f"Download target: {to_local_path}") return local_file_path.is_relative_to(to_local_path) def list_files( @@ -493,6 +721,7 @@ def download_file( to_local_path: Path | str | None = None, skip_contents: bool = False, warn_missing_path: bool = True, + progress_callback: collections.abc.Callable[[int], None] | None = None, ) -> File: """Downloads a file from SDS: metadata and maybe contents. @@ -503,12 +732,15 @@ def download_file( otherwise the download will create a temporary file on disk. Args: - file_instance: The file instance to download. - file_uuid: The UUID of the file to download. - to_local_path: The local path to save the downloaded file to. - skip_contents: When True, only the metadata is downloaded - and no files are created on disk. - warn_missing_path: Show a warning when the download location is undefined. + file_instance: The file instance to download. + file_uuid: The UUID of the file to download. + to_local_path: The local path to save the downloaded file to. + skip_contents: When True, only the metadata is downloaded + and no files are created on disk. + warn_missing_path: Show a warning when the download location + is undefined. + progress_callback: Optional callable invoked with byte count as + data is received from the stream (for progress tracking). Returns: The file instance with updated attributes, or a sample when in dry run. """ @@ -519,6 +751,7 @@ def download_file( to_local_path=to_local_path, skip_contents=skip_contents, warn_missing_path=warn_missing_path, + progress_callback=progress_callback, ) def download_dataset( diff --git a/sdk/src/spectrumx/config.py b/sdk/src/spectrumx/config.py index bb3e1e0b1..0ca1126de 100644 --- a/sdk/src/spectrumx/config.py +++ b/sdk/src/spectrumx/config.py @@ -14,6 +14,7 @@ from spectrumx.errors import Unset from .models import SDSModel +from .utils import LogCategory from .utils import into_human_bool from .utils import log_user from .utils import log_user_warning @@ -40,8 +41,10 @@ class Attr: CFG_NAME_LOOKUP: dict[str, Attr] = { "dry_run": Attr(attr_name="dry_run", cast_fn=into_human_bool), "http_timeout": Attr(attr_name="timeout", cast_fn=int), + "log_file": Attr(attr_name="log_file"), "sds_host": Attr(attr_name="sds_host"), "sds_secret_token": Attr(attr_name="api_key"), + "progress_log_period_secs": Attr(attr_name="progress_log_period_secs", cast_fn=int), } @@ -74,6 +77,8 @@ class SDSConfig: api_key: str = "" dry_run: bool = True # safer default timeout: int = DEFAULT_HTTP_TIMEOUT + log_file: Path | None = None + progress_log_period_secs: int = 30 _active_config: list[Attr] _env_file: Path | None = None @@ -85,6 +90,7 @@ def __init__( env_config: Mapping[str, Any] | None = None, sds_host: None | str = None, verbose: bool = False, + log_file: Path | None = None, ) -> None: """Initialize the configuration. Args: @@ -92,8 +98,10 @@ def __init__( env_config: Overrides for the environment file. sds_host: The host to connect to (the config file has priority over this). verbose: Show which config files are loaded and which attributes are set. + log_file: Path for the structured JSONL log file. """ self.sds_host = sds_host + self.log_file = log_file self.init_config(env_file=env_file, env_config=env_config, verbose=verbose) def init_config( @@ -126,7 +134,7 @@ def init_config( def show_config(self, log_fn: Callable[[str], None] = print) -> None: """Show the active configuration.""" header = "SDS_Config: active configuration:" - log.debug(header) # for sdk developers + log.bind(cat=LogCategory.CONFIG).debug(header) # for sdk developers log_fn(header) # for users for attr in self._active_config: _log_redacted( @@ -138,16 +146,15 @@ def show_config(self, log_fn: Callable[[str], None] = print) -> None: def _set_config(self, clean_config: list[Attr]) -> None: """Sets the instance attributes.""" for attr in clean_config: - setattr( - self, - attr.attr_name, - attr.attr_value, - ) - _log_redacted(key=attr.attr_name, value=attr.attr_value) + value = attr.attr_value + if attr.attr_name == "log_file" and isinstance(value, str): + value = Path(value) + setattr(self, attr.attr_name, value) + _log_redacted(key=attr.attr_name, value=value) # validate attributes / show user warnings if not self.api_key: - log.error( + log.bind(cat=LogCategory.CONFIG).error( "SDS_Config: API key not set. Check your environment" " file has an SDS_SECRET_TOKEN set." ) @@ -177,7 +184,9 @@ def __load_config( if secret := os.environ.get("SDS_SECRET_TOKEN"): env_vars["SDS_SECRET_TOKEN"] = secret env_vars = {k: v for k, v in env_vars.items() if v is not None} - log.debug(f"SDS_Config: from local env: {list(env_vars.keys())}") + log.bind(cat=LogCategory.CONFIG).debug( + f"SDS_Config: from local env: {list(env_vars.keys())}" + ) # merge file, cli, and env vars configs if ( @@ -225,14 +234,14 @@ def _clean_config( # skip if no value if sensitive_value is None: - log.warning(f"SDS_Config: {key} has no value") + log.bind(cat=LogCategory.CONFIG).warning(f"SDS_Config: {key} has no value") continue attr: Attr | None = name_lookup.get(normalized_key) # warn of invalid config if attr is None: msg = f"SDS_Config: {key} not recognized" - log.warning(msg) + log.bind(cat=LogCategory.CONFIG).warning(msg) logger.warning(msg) continue @@ -250,7 +259,7 @@ def _clean_config( def _log_redacted( *, key: str, - value: str | float | bool | None, + value: Any, log_fn: Callable[[str], None] = logger.info, depth: int = 1, ) -> None: @@ -269,7 +278,7 @@ def _log_redacted( ) del value msg = f"\tSDS_Config: set {key}={safe_value}" - log.opt(depth=depth).debug(msg) # for sdk developers + log.bind(cat=LogCategory.CONFIG).opt(depth=depth).debug(msg) # for sdk developers log_fn(msg) diff --git a/sdk/src/spectrumx/gateway.py b/sdk/src/spectrumx/gateway.py index a4d644755..511e436a8 100644 --- a/sdk/src/spectrumx/gateway.py +++ b/sdk/src/spectrumx/gateway.py @@ -2,6 +2,7 @@ import json import uuid +from collections.abc import Callable from collections.abc import Collection from collections.abc import Iterator from enum import StrEnum @@ -10,6 +11,7 @@ from pathlib import PurePosixPath from typing import Annotated from typing import Any +from typing import BinaryIO import requests from loguru import logger as log @@ -27,6 +29,8 @@ from .models.files import FileUpload from .models.files import PermissionRepresentation from .ops import network +from .utils import LogCategory +from .utils import LogContext from .utils import is_test_env from .utils import log_user_warning @@ -72,6 +76,33 @@ class FileContentsCheck(BaseModel): asset_id: Annotated[uuid.UUID | None, Field()] = None +class _ProgressFileReader: + """Wraps a binary file to report bytes read via a callback.""" + + def __init__( + self, + file: BinaryIO, + callback: Callable[[int], None], + ) -> None: + self._file = file + self._callback = callback + + def read(self, n: int = -1) -> bytes: + data = self._file.read(n) + if data: + self._callback(len(data)) + return data + + def __getattr__(self, name: str) -> Any: + return getattr(self._file, name) + + def __enter__(self): + return self + + def __exit__(self, *args: object) -> None: + self._file.close() + + class GatewayClient: """Communicates with the SDS API.""" @@ -177,15 +208,25 @@ def _request( debug_str += f"\nparams={kwargs['params']}" if "asset_id" in kwargs: debug_str += f"\nasset_id={kwargs['asset_id']}" - log.opt(depth=1).debug(debug_str) - - return requests.request( - timeout=self.timeout if timeout is None else timeout, - method=method, - stream=stream, - **payload, - **kwargs, - ) + log.bind(cat=LogCategory.NETWORK).opt(depth=1).debug(debug_str) + + url = payload["url"] + with LogContext(url=url): + try: + response = requests.request( + timeout=self.timeout if timeout is None else timeout, + method=method, + stream=stream, + **payload, + **kwargs, + ) + with LogContext(http_status=response.status_code): + return response + except requests.exceptions.RequestException as err: + log.bind(cat=LogCategory.NETWORK).opt(depth=1).error( + f"Network error: {method} {url}: {err}" + ) + raise @property def base_url(self) -> str: @@ -208,7 +249,7 @@ def authenticate(self, *, verbose: bool = False) -> None: msg = "No response code received from authentication request." raise AuthError(msg) status = HTTPStatus(code) - log.debug(f"Authentication response: {status}") + log.bind(cat=LogCategory.NETWORK).debug(f"Authentication response: {status}") if status.is_success: return msg = f"Authentication failed: {response.text}" @@ -328,13 +369,21 @@ def check_file_contents_exist( content: bytes | Any = response.content return FileContentsCheck.model_validate_json(content) - def upload_new_file(self, file_instance: File, *, verbose: bool = False) -> bytes: + def upload_new_file( + self, + file_instance: File, + *, + verbose: bool = False, + progress_callback: Callable[[int], None] | None = None, + ) -> bytes: """Uploads a local file to the SDS API. Uploads file contents and metadata. Args: - file_instance: The file to upload, as a models.File instance. + file_instance: The file to upload, as a models.File instance. + progress_callback: Optional callable invoked with byte count as + data is read from the source file (for progress tracking). """ if file_instance.local_path is None: msg = "Attempting to upload a remote file. Download it first." @@ -344,8 +393,13 @@ def upload_new_file(self, file_instance: File, *, verbose: bool = False) -> byte context={"mode": PermissionRepresentation.STRING} ) all_chunks: bytes = b"" + + file_ptr: BinaryIO | _ProgressFileReader = file_instance.local_path.open("rb") + if progress_callback is not None: + file_ptr = _ProgressFileReader(file_ptr, progress_callback) + with ( - file_instance.local_path.open("rb") as file_ptr, + file_ptr, self._request( method=HTTPMethods.POST, endpoint=Endpoints.FILES, @@ -643,7 +697,9 @@ def delete_capture( """ endpoint = f"{Endpoints.CAPTURES}/{capture_uuid.hex}" if self.verbose: - log.debug(f"Sending DELETE request to {endpoint}") + log.bind(cat=LogCategory.NETWORK).debug( + f"Sending DELETE request to {endpoint}" + ) response = self._request( method=HTTPMethods.DELETE, @@ -652,7 +708,9 @@ def delete_capture( ) network.success_or_raise(response, ContextException=CaptureError) if self.verbose: - log.debug(f"Capture with UUID {capture_uuid} deleted successfully") + log.bind(cat=LogCategory.NETWORK).debug( + f"Capture with UUID {capture_uuid} deleted successfully" + ) def revoke_capture_share_permissions( self, diff --git a/sdk/src/spectrumx/ops/files.py b/sdk/src/spectrumx/ops/files.py index 365640359..dd00097a2 100644 --- a/sdk/src/spectrumx/ops/files.py +++ b/sdk/src/spectrumx/ops/files.py @@ -11,6 +11,7 @@ from loguru import logger as log from spectrumx.models.files import File +from spectrumx.utils import LogCategory from spectrumx.utils import log_user from spectrumx.utils import log_user_warning @@ -38,7 +39,7 @@ def _load_undesired_globs(ignore_file: Path | None = None) -> list[str]: ] undesired_basenames = sorted([line for line in undesired_basenames if line]) else: - log.info("No .sds-ignore file found") + log.bind(cat=LogCategory.FILESYSTEM).info("No .sds-ignore file found") return undesired_basenames diff --git a/sdk/src/spectrumx/ops/network.py b/sdk/src/spectrumx/ops/network.py index 352c355ae..aa2ef3313 100644 --- a/sdk/src/spectrumx/ops/network.py +++ b/sdk/src/spectrumx/ops/network.py @@ -3,6 +3,7 @@ import sys from http import HTTPStatus +from spectrumx.utils import LogCategory from spectrumx.utils import is_test_env # python 3.11 backport @@ -33,7 +34,7 @@ def success_or_raise( if status.is_success: return - log.info(response) + log.bind(cat=LogCategory.NETWORK).info(response) try: error_json = response.json() error_details = error_json.get("detail", error_json) @@ -45,7 +46,7 @@ def success_or_raise( ) error_details = str(error_details) - log.opt(depth=1).exception(error_details) + log.bind(cat=LogCategory.NETWORK).opt(depth=1).exception(error_details) if status in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: raise errors.AuthError(message=error_details) if status.is_client_error: @@ -62,7 +63,9 @@ def extract_error_details_from_html(response: requests.Response) -> str: BeautifulSoup, # pyright: ignore[reportMissingModuleSource] ) except ImportError: - log.warning("Install 'BeautifulSoup' to have a more detailed error message.") + log.bind(cat=LogCategory.NETWORK).warning( + "Install 'BeautifulSoup' to have a more detailed error message." + ) reason = response.reason if reason is None: reason = "Unknown reason" @@ -78,4 +81,11 @@ def extract_error_details_from_html(response: requests.Response) -> str: combined_text = "\n".join(texts) no_blanks = "\n".join(filter(None, combined_text.splitlines())) - return no_blanks.strip() + extracted = no_blanks.strip() + if extracted: + return extracted + + reason = response.reason + if reason is None: + reason = "Unknown reason" + return reason diff --git a/sdk/src/spectrumx/ops/pagination.py b/sdk/src/spectrumx/ops/pagination.py index c96eab586..c4309a344 100644 --- a/sdk/src/spectrumx/ops/pagination.py +++ b/sdk/src/spectrumx/ops/pagination.py @@ -18,6 +18,7 @@ from spectrumx.gateway import GatewayClient from spectrumx.models import SDSModel from spectrumx.ops import files +from spectrumx.utils import LogCategory from spectrumx.utils import log_user_warning if TYPE_CHECKING: @@ -192,7 +193,7 @@ def _total_pages(self) -> int: def _debug(self, message: str, depth: int = 1) -> None: """Logs a debug message if verbose mode is enabled.""" if self._verbose: # pragma: no cover - log.opt(depth=depth).debug(message) + log.bind(cat=LogCategory.FILESYSTEM).opt(depth=depth).debug(message) @property def _has_next_page(self) -> bool: @@ -204,7 +205,9 @@ def _has_next_page(self) -> bool: def _fetch_next_page(self) -> None: """Fetches the next page of results.""" if self._is_fetching: # pragma: no cover - log.warning("Already fetching the next page.") + log.bind(cat=LogCategory.FILESYSTEM).warning( + "Already fetching the next page." + ) return self._is_fetching = True # try-finally to unset self._is_fetching when done @@ -234,7 +237,7 @@ def _fetch_next_page(self) -> None: # log an unexpected FileError if it happens if "invalid page" not in str(err).lower(): msg = "Unexpected error while fetching the next page:" - log.exception(msg) + log.bind(cat=LogCategory.FILESYSTEM).exception(msg) msg = "No more pages available." raise StopIteration(msg) from err self._next_page += 1 @@ -301,7 +304,7 @@ def _process_file_fake(my_file: files.File) -> None: # pragma: no cover def main() -> None: # pragma: no cover """Usage example for paginator.""" - log.info("Running the main script.") + log.bind(cat=LogCategory.FILESYSTEM).info("Running the main script.") file_paginator = Paginator[files.File]( Entry=files.File, gateway=GatewayClient( @@ -318,23 +321,27 @@ def main() -> None: # pragma: no cover dry_run=True, # in dry-run this should always generate 2.5 pages verbose=True, ) - log.info(f"Total files matched: {len(file_paginator)}") + log.bind(cat=LogCategory.FILESYSTEM).info( + f"Total files matched: {len(file_paginator)}" + ) processed_count: int = 0 for my_file in file_paginator: - log.info(f"Processing file: {my_file.name}") + log.bind(cat=LogCategory.FILESYSTEM).info(f"Processing file: {my_file.name}") _process_file_fake(my_file) processed_count += 1 # new pages are fetched automatically - log.info(f"Processed {processed_count} / {len(file_paginator)} files.") + log.bind(cat=LogCategory.FILESYSTEM).info( + f"Processed {processed_count} / {len(file_paginator)} files." + ) - log.info("Trying another loop:") + log.bind(cat=LogCategory.FILESYSTEM).info("Trying another loop:") for _my_file in file_paginator: msg = "This will not run, as the paginator was consumed." raise AssertionError(msg) - log.info("No more files to process.") + log.bind(cat=LogCategory.FILESYSTEM).info("No more files to process.") - log.info("Paginator demo finished.") + log.bind(cat=LogCategory.FILESYSTEM).info("Paginator demo finished.") if __name__ == "__main__": diff --git a/sdk/src/spectrumx/utils.py b/sdk/src/spectrumx/utils.py index a2d8faa05..85ed421cc 100644 --- a/sdk/src/spectrumx/utils.py +++ b/sdk/src/spectrumx/utils.py @@ -2,13 +2,23 @@ from __future__ import annotations +import contextlib +import contextvars +import importlib.metadata +import json import logging import os +import platform import random import re import string +import tempfile +from datetime import UTC +from datetime import datetime +from enum import StrEnum from pathlib import Path from typing import TYPE_CHECKING +from typing import Any from typing import NoReturn from typing import TypeVar from typing import cast @@ -18,6 +28,8 @@ from tqdm import auto as auto_tqdm from tqdm import tqdm +from spectrumx.vendor.xdg_base_dirs import xdg_state_home + if TYPE_CHECKING: from collections.abc import Iterable @@ -168,3 +180,193 @@ def get_prog_bar( "tqdm[T]", # pyrefly: ignore[bad-specialization] auto_tqdm.tqdm(iterable, *args, **kwargs), ) + + +def credit_unstreamed_file_bytes( + *, + file_size: int, + bytes_streamed: int, + prog_bar: tqdm[NoReturn] | None, + bytes_accounted: list[int] | None = None, +) -> int: + """Credit progress for file bytes that were not transferred over the wire. + + Byte-level progress tracks streamed chunks. When a file completes without + transferring its full contents (skip, metadata-only, local exists, etc.), + credit the remaining bytes so totals stay accurate. + + Returns: + The number of bytes credited. + """ + remaining = max(file_size - bytes_streamed, 0) + if remaining == 0: + return 0 + if bytes_accounted is not None: + bytes_accounted[0] += remaining + if prog_bar is not None: + prog_bar.update(remaining) + return remaining + + +# --- Structured Logging --- + + +class LogCategory(StrEnum): + """Categories for structured log messages.""" + + LOG = "log" + CONFIG = "config" + AUTH = "auth" + NETWORK = "network" + FILESYSTEM = "filesystem" + DOWNLOAD = "download" + UPLOAD = "upload" + + +LOG_CATEGORY_LOG = LogCategory.LOG + + +_log_context: contextvars.ContextVar[dict[str, Any] | None] = contextvars.ContextVar( + "_log_context", default=None +) + +_persistent_context: dict[str, Any] = {} +_structured_sink_id: int | None = None +_current_log_path: Path | None = None + + +class LogContext: + """Context manager for scoped structured logging context binding.""" + + def __init__(self, **kwargs: Any) -> None: + self._kwargs = kwargs + self._token: contextvars.Token[dict[str, Any] | None] | None = None + + def __enter__(self) -> None: + current = (_log_context.get() or {}).copy() + current.update(self._kwargs) + self._token = _log_context.set(current) + + def __exit__(self, *args: object) -> None: + if self._token is not None: + _log_context.reset(self._token) + + +# Alias for LogContext - used by uploads.py +log_context = LogContext + + +def set_persistent_log_context(**kwargs: Any) -> None: + """Set persistent context fields that appear on all log lines.""" + _persistent_context.update(kwargs) + + +def _get_default_log_path() -> Path: + """Resolve default structured log path: XDG state home > temp dir.""" + try: + base = xdg_state_home() / "spectrumx" / "logs" + base.mkdir(parents=True, exist_ok=True) + return base / f"{datetime.now(UTC).strftime('%Y-%m-%d')}.jsonl" + except (ImportError, OSError): + return Path(tempfile.gettempdir()) / "spectrumx_sdk.jsonl" + + +def _emit_boot_message(log_path: Path) -> None: + """Write system info boot message to the log file.""" + try: + sdk_version = importlib.metadata.version("spectrumx") + except importlib.metadata.PackageNotFoundError: + sdk_version = "unknown" + + boot_entry = { + "ts": datetime.now(UTC).isoformat(), + "pid": os.getpid(), + "lvl": "INFO", + "cat": LogCategory.LOG, + "msg": "system info", + "sdk_version": sdk_version, + "os": platform.platform(), + "python": platform.python_version(), + "log_file": str(log_path), + } + try: + with log_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(boot_entry, default=str) + "\n") + except OSError: + pass + + +def _structured_log_sink(message: Any) -> None: + """Loguru function sink: reads ContextVar, writes JSON line to file.""" + if _current_log_path is None: + return + + rec = message.record + msg_str = rec["message"].rstrip() + + entry: dict[str, Any] = { + "ts": rec["time"].isoformat(), + "pid": rec["process"].id, + "lvl": rec["level"].name, + "cat": rec.get("extra", {}).get("cat", LogCategory.LOG), + "msg": msg_str, + } + entry.update(rec.get("extra", {})) + + # Merge persistent context + entry.update({k: v for k, v in _persistent_context.items() if v is not None}) + + # Merge scoped context + ctx = _log_context.get() or {} + entry.update({k: v for k, v in ctx.items() if v is not None}) + + # Extract exc_info from loguru record (for error log lines) + exc_info = rec.get("exception") + if exc_info is not None and exc_info.type is not None: + entry["exc_info"] = f"{exc_info.type.__name__}: {exc_info.value}" + + try: + with _current_log_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, default=str) + "\n") + except OSError: + pass + + +def enable_structured_logging(log_path: Path | str | None = None) -> None: + """Enable or reconfigure the structured JSONL logging sink.""" + global _structured_sink_id, _current_log_path # noqa: PLW0603 + + if _structured_sink_id is not None: + with contextlib.suppress(Exception): + log.remove(_structured_sink_id) + + # Resolve path + resolved = Path(log_path) if log_path is not None else _get_default_log_path() + + _current_log_path = resolved + parent = resolved.parent + parent.mkdir(parents=True, exist_ok=True) + + # Emit system info boot message every time the logger is initialized + _emit_boot_message(resolved) + + try: + _structured_sink_id = log.add( + _structured_log_sink, + format="{message}", + filter=lambda record: True, + ) + except Exception: # noqa: BLE001 + _structured_sink_id = None + + +def reset_structured_logging() -> None: + """Reset structured logging state. Useful for tests.""" + global _structured_sink_id, _current_log_path # noqa: PLW0603 + if _structured_sink_id is not None: + with contextlib.suppress(Exception): + log.remove(_structured_sink_id) + _structured_sink_id = None + _current_log_path = None + _persistent_context.clear() + _log_context.set(None) diff --git a/sdk/tests/ops/test_datasets.py b/sdk/tests/ops/test_datasets.py index b3e23fc68..380a2d678 100644 --- a/sdk/tests/ops/test_datasets.py +++ b/sdk/tests/ops/test_datasets.py @@ -56,8 +56,8 @@ def test_get_files_artifacts_only_clears_capture_uuids_and_warns() -> None: ): paginator = api.get_files(ds, capture_uuids=(cap,), artifacts_only=True) assert len(paginator) == 0 - log_mock.warning.assert_called_once() - assert "capture" in log_mock.warning.call_args[0][0].lower() + log_mock.bind.return_value.warning.assert_called_once() + assert "capture" in log_mock.bind.return_value.warning.call_args[0][0].lower() kw = mock_get.call_args.kwargs assert kw.get("capture_uuids") in (None, ()) assert kw["artifacts_only"] is True @@ -78,7 +78,7 @@ def test_get_files_artifacts_only_with_top_level_dirs_logs_info() -> None: artifacts_only=True, ) assert len(paginator) == 0 - log_mock.info.assert_called_once() + log_mock.bind.return_value.info.assert_called_once() kw = mock_get.call_args.kwargs assert kw["top_level_dirs"] == ("/pytest/root",) assert kw["artifacts_only"] is True @@ -154,3 +154,105 @@ def test_revoke_dataset_share_permissions( assert len(responses.calls) == 1 assert responses.calls[0].request.method == "PUT" assert responses.calls[0].request.url == revoke_url + + +def test_get_dry_run(client: Client) -> None: + """Dry run for get() returns a shell Dataset without HTTP calls.""" + client.dry_run = True + dataset_uuid = uuidlib.uuid4() + result = client.datasets.get(dataset_uuid) + assert result.uuid == dataset_uuid + + +@responses.activate +def test_get_gateway(client: Client, responses: responses.RequestsMock) -> None: + """get() calls gateway and returns a parsed Dataset.""" + client.dry_run = False + dataset_uuid = uuidlib.uuid4() + mock_data = { + "uuid": dataset_uuid.hex, + "name": "test-dataset-from-gateway", + } + responses.add( + method=responses.GET, + url=get_datasets_endpoint(client, dataset_id=dataset_uuid.hex), + json=mock_data, + status=200, + ) + result = client.datasets.get(dataset_uuid) + assert result.uuid is not None + assert result.uuid.hex == dataset_uuid.hex + assert result.name == "test-dataset-from-gateway" + assert len(responses.calls) == 1 + + +def test_list_captures_dry_run(client: Client) -> None: + """Dry run for list_captures() returns an empty list.""" + client.dry_run = True + dataset_uuid = uuidlib.uuid4() + result = client.datasets.list_captures(dataset_uuid) + assert result == [] + + +@responses.activate +def test_list_captures_gateway( + client: Client, responses: responses.RequestsMock +) -> None: + """list_captures() calls gateway and returns parsed capture payloads.""" + client.dry_run = False + dataset_uuid = uuidlib.uuid4() + mock_data = { + "captures": [ + {"uuid": str(uuidlib.uuid4()), "name": "capture-1"}, + ], + } + responses.add( + method=responses.GET, + url=get_datasets_endpoint(client, dataset_id=dataset_uuid.hex), + json=mock_data, + status=200, + ) + result = client.datasets.list_captures(dataset_uuid) + assert len(result) == 1 + assert result[0]["name"] == "capture-1" + assert len(responses.calls) == 1 + + +def test_list_artifact_files_dry_run(client: Client) -> None: + """Dry run for list_artifact_files() returns an empty list.""" + client.dry_run = True + dataset_uuid = uuidlib.uuid4() + result = client.datasets.list_artifact_files(dataset_uuid) + assert result == [] + + +@responses.activate +def test_list_artifact_files_gateway( + client: Client, responses: responses.RequestsMock +) -> None: + """list_artifact_files() calls gateway and returns parsed file payloads.""" + client.dry_run = False + dataset_uuid = uuidlib.uuid4() + mock_data = { + "files": [ + {"uuid": str(uuidlib.uuid4()), "name": "file-1.txt"}, + ], + } + responses.add( + method=responses.GET, + url=get_datasets_endpoint(client, dataset_id=dataset_uuid.hex), + json=mock_data, + status=200, + ) + result = client.datasets.list_artifact_files(dataset_uuid) + assert len(result) == 1 + assert result[0]["name"] == "file-1.txt" + assert len(responses.calls) == 1 + + +def test_revoke_share_permissions_dry_run(client: Client) -> None: + """Dry run for revoke_share_permissions() returns True.""" + client.dry_run = True + dataset_uuid = uuidlib.uuid4() + result = client.datasets.revoke_share_permissions(dataset_uuid) + assert result is True diff --git a/sdk/tests/ops/test_files.py b/sdk/tests/ops/test_files.py index ed163629f..6c0ea832a 100644 --- a/sdk/tests/ops/test_files.py +++ b/sdk/tests/ops/test_files.py @@ -17,10 +17,14 @@ import responses from spectrumx import Client from spectrumx.api.sds_files import delete_file +from spectrumx.api.sds_files import download_file from spectrumx.api.sds_files import file_list_time_query_param from spectrumx.api.sds_files import list_files +from spectrumx.api.uploads import UploadPersistenceManager from spectrumx.errors import FileError +from spectrumx.errors import SDSError from spectrumx.gateway import API_TARGET_VERSION +from spectrumx.models.files import File from spectrumx.ops.files import ( _load_undesired_globs, # pyright: ignore[reportPrivateUsage] ) @@ -870,3 +874,466 @@ def test_large_valid_file(self, tmp_path: Path) -> None: f"Large valid file should pass validation. Reasons: {reasons}" ) assert reasons == [], "No reasons should be given for valid files" + + +# ============================================================================= +# sds_files.py coverage tests (phase 2) +# ============================================================================= + + +def test_upload_file_invalid_type(client: Client) -> None: + """upload_file raises TypeError for non-File/Path/str input.""" + with pytest.raises(TypeError, match="file_path must be a Path, str, or File"): + client.upload_file(local_file=123) # type: ignore[arg-type] + + +def test_upload_file_instance_directory_composition( + client: Client, tmp_path: Path +) -> None: + """Uploading a File instance with directory set prepends sds_path.""" + client.dry_run = True + sub_path = PurePosixPath("sub/dir") + file_path = tmp_path / "test_compose.txt" + file_path.write_text("content") + + file_instance = File( + uuid=uuidlib.uuid4(), + name=file_path.name, + media_type="text/plain", + size=file_path.stat().st_size, + directory=sub_path, + permissions="rw-r--r--", + created_at=datetime(2024, 12, 1, 12, 0, 0, tzinfo=UTC), + updated_at=datetime(2024, 12, 1, 12, 0, 0, tzinfo=UTC), + expiration_date=datetime(2026, 12, 1, 12, 0, 0, tzinfo=UTC), + local_path=file_path, + ) + + result = client.upload_file( + local_file=file_instance, + sds_path=PurePosixPath("/base"), + ) + + assert result.directory == PurePosixPath("/base/sub/dir"), ( + f"Expected '/base/sub/dir', got '{result.directory}'" + ) + + +def test_upload_file_skip_mode( + client: Client, + responses: responses.RequestsMock, + temp_file_with_text_contents: Path, +) -> None: + """When file already exists in tree, upload_file returns input without UUID.""" + client.dry_run = False + asset_id = uuidlib.uuid4() + + responses.add( + method=responses.POST, + url=get_content_check_endpoint(client), + status=201, + json={ + "file_contents_exist_for_user": True, + "file_exists_in_tree": True, + "user_mutable_attributes_differ": False, + "asset_id": asset_id.hex, + }, + ) + + result = client.upload_file( + local_file=temp_file_with_text_contents, + sds_path=PurePosixPath("/test/skip"), + ) + + assert result.uuid is None, "SKIP mode should return the local file without UUID" + assert result.local_path == temp_file_with_text_contents + assert len(responses.calls) == 1, "Only content-check request expected" + + +def test_upload_file_metadata_only_mode( + client: Client, + responses: responses.RequestsMock, + temp_file_with_text_contents: Path, +) -> None: + """When contents exist for user, upload metadata only.""" + client.dry_run = False + asset_id = uuidlib.uuid4() + file_id = uuidlib.uuid4() + file_size = temp_file_with_text_contents.stat().st_size + + # content-check response: contents exist for user but not in tree + responses.add( + method=responses.POST, + url=get_content_check_endpoint(client), + status=201, + json={ + "file_contents_exist_for_user": True, + "file_exists_in_tree": False, + "user_mutable_attributes_differ": True, + "asset_id": asset_id.hex, + }, + ) + + # metadata-only upload response + responses.add( + method=responses.POST, + url=get_files_endpoint(client), + status=201, + json={ + "uuid": file_id.hex, + "name": temp_file_with_text_contents.name, + "media_type": "text/plain", + "size": file_size, + "directory": "/test/metadata-only", + "permissions": "rw-r--r--", + "created_at": "2024-12-01T12:00:00Z", + "updated_at": "2024-12-01T12:00:00Z", + "expiration_date": "2026-12-01T12:00:00Z", + }, + ) + + result = client.upload_file( + local_file=temp_file_with_text_contents, + sds_path=PurePosixPath("/test/metadata-only"), + ) + + assert result.uuid == file_id, "Metadata-only upload should return a server File" + assert result.directory == PurePosixPath("/test/metadata-only") + assert len(responses.calls) == 2, ( # noqa: PLR2004 + "Expected content-check + metadata-upload calls" + ) + + +def test_download_file_with_file_instance( + client: Client, responses: responses.RequestsMock +) -> None: + """download_file accepts a File instance directly.""" + client.dry_run = False + file_id = uuidlib.uuid4() + file_contents = "downloaded via file-instance" + + now_ts = datetime(2024, 12, 1, 12, 0, 0, tzinfo=UTC) + + file_instance = File( + uuid=file_id, + name="test.txt", + media_type="text/plain", + size=len(file_contents), + directory=PurePosixPath("/test/"), + permissions="rw-r--r--", + created_at=now_ts, + updated_at=now_ts, + expiration_date=datetime(2026, 12, 1, 12, 0, 0, tzinfo=UTC), + ) + + # only the download endpoint is needed (file info is in the instance) + responses.add( + method=responses.GET, + url=_download_file_endpoint(client, file_id=file_id.hex), + status=200, + body=file_contents, + ) + + result = download_file(client=client, file_instance=file_instance) + + assert result.uuid == file_id + assert result.local_path is not None + assert result.local_path.exists() + assert result.local_path.read_text() == file_contents + + # cleanup + result.local_path.unlink(missing_ok=True) + + +def test_download_file_skip_contents( + client: Client, responses: responses.RequestsMock +) -> None: + """skip_contents=True prevents downloading file contents.""" + client.dry_run = False + file_id = uuidlib.uuid4() + now_ts = datetime(2024, 12, 1, 12, 0, 0, tzinfo=UTC) + + file_instance = File( + uuid=file_id, + name="test.txt", + media_type="text/plain", + size=100, + directory=PurePosixPath("/test/"), + permissions="rw-r--r--", + created_at=now_ts, + updated_at=now_ts, + expiration_date=datetime(2026, 12, 1, 12, 0, 0, tzinfo=UTC), + ) + + # Do NOT register a download endpoint — if skip_contents works, + # the download endpoint should NOT be called. + result = download_file( + client=client, + file_instance=file_instance, + skip_contents=True, + ) + + assert result.uuid == file_id + assert result.local_path is None, "No local path should be set when skipping" + assert len(responses.calls) == 0, "No HTTP calls should be made" + + +def test_download_file_missing_uuid(client: Client) -> None: + """Downloading a File instance without a UUID raises ValueError.""" + now_ts = datetime(2024, 12, 1, 12, 0, 0, tzinfo=UTC) + + file_instance = File( + name="test.txt", + media_type="text/plain", + size=100, + directory=PurePosixPath("/test/"), + permissions="rw-r--r--", + created_at=now_ts, + updated_at=now_ts, + expiration_date=datetime(2026, 12, 1, 12, 0, 0, tzinfo=UTC), + ) + + with pytest.raises(ValueError, match="local reference"): + download_file(client=client, file_instance=file_instance) + + +def test_download_file_contents_error_cleanup( + client: Client, tmp_path: Path, responses: responses.RequestsMock +) -> None: + """When download fails, temp files are cleaned up and exception propagates.""" + client.dry_run = False + file_id = uuidlib.uuid4() + target_path = tmp_path / "should-not-exist.txt" + now_ts = datetime(2024, 12, 1, 12, 0, 0, tzinfo=UTC) + + file_instance = File( + uuid=file_id, + name="test.txt", + media_type="text/plain", + size=100, + directory=PurePosixPath("/test/"), + permissions="rw-r--r--", + created_at=now_ts, + updated_at=now_ts, + expiration_date=datetime(2026, 12, 1, 12, 0, 0, tzinfo=UTC), + ) + + responses.add( + method=responses.GET, + url=_download_file_endpoint(client, file_id=file_id.hex), + status=500, + json={"detail": "Simulated download failure"}, + ) + + with pytest.raises(SDSError, match="Simulated download failure"): + download_file( + client=client, + file_instance=file_instance, + to_local_path=target_path, + ) + + assert not target_path.exists(), ( + "Target path should not exist after failed download" + ) + # verify no stray .downloading temp files remain + temp_files = list(tmp_path.glob("*.downloading")) + assert len(temp_files) == 0, f"Temp files not cleaned up: {temp_files}" + + +def test_delete_file_get_file_fails( + client: Client, responses: responses.RequestsMock +) -> None: + """When get_file fails before deletion, a warning is logged but delete proceeds.""" + test_uuid = uuidlib.uuid4() + test_uuid_hex = test_uuid.hex + client.dry_run = False + + # GET request for file info will receive a response that causes parse failure + responses.add( + method=responses.GET, + url=f"{get_files_endpoint(client)}{test_uuid_hex}/", + status=200, + body=b"not-valid-json", + ) + + # DELETE request succeeds + responses.add( + method=responses.DELETE, + url=f"{get_files_endpoint(client)}{test_uuid_hex}/", + status=204, + ) + + result = delete_file(client=client, file_uuid=test_uuid) + + assert result is True, "Delete should succeed even if get_file fails" + assert len(responses.calls) == 2, "Expected GET + DELETE calls" # noqa: PLR2004 + + +def test_delete_file_cleanup_called( + client: Client, responses: responses.RequestsMock +) -> None: + """When sum_blake3 is set and delete succeeds, cleanup is triggered.""" + test_uuid = uuidlib.uuid4() + test_uuid_hex = test_uuid.hex + test_checksum = "abc123def456" + client.dry_run = False + + # GET returns file info WITH sum_blake3 + responses.add( + method=responses.GET, + url=f"{get_files_endpoint(client)}{test_uuid_hex}/", + status=200, + json={ + "uuid": test_uuid_hex, + "name": "test.txt", + "media_type": "text/plain", + "size": 100, + "directory": "/test/", + "permissions": "rw-r--r--", + "sum_blake3": test_checksum, + "created_at": "2024-12-01T12:00:00Z", + "updated_at": "2024-12-01T12:00:00Z", + "expiration_date": "2026-12-01T12:00:00Z", + }, + ) + + # DELETE request succeeds + responses.add( + method=responses.DELETE, + url=f"{get_files_endpoint(client)}{test_uuid_hex}/", + status=204, + ) + + with patch.object( + UploadPersistenceManager, + "remove_persisted_uploads_by_checksum", + ) as mock_cleanup: + result = delete_file(client=client, file_uuid=test_uuid) + + assert result is True + mock_cleanup.assert_called_once_with(checksum=test_checksum) + + assert len(responses.calls) == 2, "Expected GET + DELETE calls" # noqa: PLR2004 + + +def test_delete_file_gateway_returns_false( + client: Client, responses: responses.RequestsMock +) -> None: + """When the gateway rejects the DELETE, the error propagates as FileError.""" + test_uuid = uuidlib.uuid4() + test_uuid_hex = test_uuid.hex + client.dry_run = False + + # GET returns file info + responses.add( + method=responses.GET, + url=f"{get_files_endpoint(client)}{test_uuid_hex}/", + status=200, + json={ + "uuid": test_uuid_hex, + "name": "test.txt", + "media_type": "text/plain", + "size": 100, + "directory": "/test/", + "permissions": "rw-r--r--", + "created_at": "2024-12-01T12:00:00Z", + "updated_at": "2024-12-01T12:00:00Z", + "expiration_date": "2026-12-01T12:00:00Z", + }, + ) + + responses.add( + method=responses.DELETE, + url=f"{get_files_endpoint(client)}{test_uuid_hex}/", + status=400, + json={"detail": "Cannot delete"}, + ) + + with pytest.raises(FileError, match="Cannot delete"): + delete_file(client=client, file_uuid=test_uuid) + + +def test_list_files_empty_result( + client: Client, responses: responses.RequestsMock +) -> None: + """list_files returns no files when the gateway returns an empty page.""" + client.dry_run = False + + responses.add( + method=responses.GET, + url=get_files_endpoint(client), + status=200, + json={"count": 0, "results": []}, + ) + + paginator = list_files( + client=client, + sds_path=PurePosixPath("/test/empty"), + ) + results = list(paginator) + + assert results == [], "Expected no files from empty listing" + + +def test_list_files_dry_run(client: Client) -> None: + """list_files in dry run returns a paginator without network calls.""" + client.dry_run = True + + paginator = list_files( + client=client, + sds_path=PurePosixPath("/test/dry"), + ) + + # In dry run, the paginator yields sample files + results = list(paginator) + assert len(results) > 0, "Expected sample files in dry run" + for f in results: + assert f.uuid is not None + assert isinstance(f.name, str) + assert len(f.name) > 0 + + +def test_cleanup_persisted_uploads_error( + client: Client, responses: responses.RequestsMock +) -> None: + """When UploadPersistenceManager raises, the error is logged but not re-raised.""" + test_uuid = uuidlib.uuid4() + test_uuid_hex = test_uuid.hex + client.dry_run = False + + # GET returns file info WITH sum_blake3 + responses.add( + method=responses.GET, + url=f"{get_files_endpoint(client)}{test_uuid_hex}/", + status=200, + json={ + "uuid": test_uuid_hex, + "name": "test.txt", + "media_type": "text/plain", + "size": 100, + "directory": "/test/", + "permissions": "rw-r--r--", + "sum_blake3": "somechecksum", + "created_at": "2024-12-01T12:00:00Z", + "updated_at": "2024-12-01T12:00:00Z", + "expiration_date": "2026-12-01T12:00:00Z", + }, + ) + + # DELETE succeeds + responses.add( + method=responses.DELETE, + url=f"{get_files_endpoint(client)}{test_uuid_hex}/", + status=204, + ) + + with patch.object( + UploadPersistenceManager, + "remove_persisted_uploads_by_checksum", + side_effect=OSError("Disk error"), + ): + # Should NOT raise — error is caught and logged + result = delete_file(client=client, file_uuid=test_uuid) + + assert result is True, "Delete should still succeed even if cleanup fails" + assert len(responses.calls) == 2, "Expected GET + DELETE calls" # noqa: PLR2004 diff --git a/sdk/tests/ops/test_network.py b/sdk/tests/ops/test_network.py index f09b84347..1f06b5f9f 100644 --- a/sdk/tests/ops/test_network.py +++ b/sdk/tests/ops/test_network.py @@ -6,9 +6,12 @@ # ruff: noqa: SLF001 # pyright: ignore[reportPrivateUsage] +from unittest.mock import patch + import pytest import requests from spectrumx import errors +from spectrumx.ops.network import extract_error_details_from_html from spectrumx.ops.network import success_or_raise @@ -74,3 +77,64 @@ class CustomException(errors.SDSError): with pytest.raises(CustomException, match="Bad Request"): success_or_raise(response, ContextException=CustomException) + + +def test_success_or_raise_no_status_code() -> None: + """No status code should raise SDSError.""" + response = requests.Response() + object.__setattr__(response, "status_code", None) + with pytest.raises(errors.SDSError, match="No status code"): + success_or_raise(response) + + +def test_success_or_raise_json_decode_error_no_bs4() -> None: + """Non-JSON error body falls back to reason when not in test env.""" + response = requests.Response() + response.status_code = 500 + response._content = b"Not JSON" + response.reason = "Internal Server Error" + with ( + patch("spectrumx.ops.network.is_test_env", return_value=False), + pytest.raises(errors.ServiceError, match="Internal Server Error"), + ): + success_or_raise(response) + + +def test_success_or_raise_json_decode_error_with_bs4() -> None: + """Non-JSON error body extracts error from HTML when in test env.""" + response = requests.Response() + response.status_code = 500 + response._content = b'