diff --git a/.gitignore b/.gitignore index 468c518..4dcd2c5 100644 --- a/.gitignore +++ b/.gitignore @@ -215,3 +215,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml internal/ + +# macOS +.DS_Store diff --git a/README.md b/README.md index 955a221..4c95621 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# WildEdge Python SDK +

+ WildEdge +

+ +
[![CI](https://github.com/wild-edge/wildedge-python/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wild-edge/wildedge-python/actions/workflows/ci.yml) [![Python Versions](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://pypi.org/project/wildedge-sdk/) @@ -40,6 +44,7 @@ Useful flags: | `--hubs` | Hub trackers to activate: `huggingface`, `torchhub` | | `--print-startup-report` | Print per-integration status at startup | | `--strict-integrations` | Fail if a requested integration can't be loaded | +| `--attachments` | Enable opt-in raw input/output attachment upload | | `--no-propagate` | Don't pass WildEdge env vars to child processes | ## SDK @@ -102,8 +107,9 @@ For unsupported frameworks, see [Manual tracking](https://github.com/wild-edge/w | `app_identity` | `` | Namespace for offline persistence. Set per-app in multi-process workloads (or `WILDEDGE_APP_IDENTITY`) | | `enable_offline_persistence` | `true` | Persist unsent events to disk and replay on restart | | `sampling_interval_s` | `30.0` | Seconds between background hardware snapshots. Set to `0` or `None` to disable (or `WILDEDGE_SAMPLING_INTERVAL_S`) | +| `attachments_enabled` | `false` | Opt-in upload of raw inference inputs/outputs (or `WILDEDGE_ATTACHMENTS_ENABLED`). See [Attachments](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md#attachments) | -For advanced options (batching, queue tuning, dead-letter storage), see [Configuration](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md). +For advanced options (batching, queue tuning, dead-letter storage, attachments), see [Configuration](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md). ## Projects using this SDK @@ -115,9 +121,13 @@ For advanced options (batching, queue tuning, dead-letter storage), see [Configu Using WildEdge in your project? Open a PR to add it to the list. -## Privacy +## Security & Privacy + +By default the SDK transmits only anonymized telemetry, never raw model inputs +or outputs. The one exception is opt-in [attachments](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md#attachments) +(`attachments_enabled`), which upload raw bytes you explicitly pass in. -Report security & priact issues to: wildedge@googlegroups.com. +Report security and privacy issues to: support@wildedge.dev ## Links diff --git a/docs/configuration.md b/docs/configuration.md index 566738b..e3cf982 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,3 +22,21 @@ Full reference for all `WildEdge` client parameters. | `max_event_age_sec` | `900` | - | Max age in seconds before an event is dropped | | `enable_dead_letter_persistence` | `false` | - | Persist dropped batches to disk for later inspection | | `debug` | `false` | `WILDEDGE_DEBUG` | Log SDK internals to console | + +## Attachments + +Opt-in upload of raw inference inputs and outputs alongside telemetry. Off by +default, and requires the attachments feature to be enabled on your project. +Bytes are buffered to disk and uploaded in the background via presigned URLs; +event flushes never block on an upload. + +| Parameter | Default | Env var | Description | +|---|---|---|---| +| `attachments_enabled` | `false` | `WILDEDGE_ATTACHMENTS_ENABLED` | Enable raw input/output upload | +| `max_attachments_per_inference` | `10` | - | Cap on attachments buffered per inference event | +| `max_attachment_size_bytes` | `10485760` | - | Per-attachment size limit (10 MB); larger ones are dropped | +| `attachment_storage_strategy` | `"file"` | - | `"file"` (bytes on disk) or `"inline"` (small blobs embedded in the record) | +| `attachment_filter` | `None` | - | `Callable[[list[Attachment]], list[Attachment]]` to redact/drop before buffering | +| `attachment_dir` | per `app_identity` | - | Override the local buffer directory | + +See [Track attachments](manual-tracking.md#track-attachments) for usage. diff --git a/docs/manual-tracking.md b/docs/manual-tracking.md index 0fa227e..8968c16 100644 --- a/docs/manual-tracking.md +++ b/docs/manual-tracking.md @@ -216,6 +216,42 @@ handle.feedback(FeedbackType.THUMBS_DOWN) `FeedbackType` values: `THUMBS_UP`, `THUMBS_DOWN`. +## Track attachments + +Opt-in upload of the raw bytes behind an inference, such as the source image, +audio clip, or generated text, so you can inspect or curate them later. This is +off by default and requires the attachment feature enabled on your project. +Enable it at init, then pass `attachments=` to `track_inference`: + +```python +import wildedge +from wildedge import Attachment + +client = wildedge.init(attachments_enabled=True) +handle = client.register_model(model, model_id="doc-classifier-v1") + +handle.track_inference( + duration_ms=120, + input_modality="image", + output_modality="text", + attachments=[ + Attachment(content_type="image/jpeg", role="input", data=image_bytes), + Attachment(content_type="text/plain", role="output", data=answer.encode()), + ], +) +``` + +An `Attachment` carries either in-memory `data` or a file `path`, plus a +`role` (`"input"` or `"output"`) and `content_type`. The SDK writes a reference +into the inference event immediately and uploads the bytes in the background via +a presigned URL. A failed or disabled upload never blocks telemetry. + +Bytes are buffered to disk and survive restarts. Capture is gated by +`max_attachments_per_inference`, `max_attachment_size_bytes`, and an optional +`attachment_filter` hook. See [Configuration](configuration.md#attachments) and +[`examples/attachments_example.py`](../examples/attachments_example.py) for the +full reference. + ## Track spans for agentic workflows Use span events to track non-inference steps like planning, tool calls, retrieval, or memory updates. diff --git a/docs/wildedge-logo-text.svg b/docs/wildedge-logo-text.svg new file mode 100644 index 0000000..7a61d3d --- /dev/null +++ b/docs/wildedge-logo-text.svg @@ -0,0 +1,99 @@ + + + + diff --git a/examples/attachments_example.py b/examples/attachments_example.py new file mode 100644 index 0000000..4eb0735 --- /dev/null +++ b/examples/attachments_example.py @@ -0,0 +1,62 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["wildedge-sdk"] +# +# [tool.uv.sources] +# wildedge-sdk = { path = "..", editable = true } +# /// +""" +Attachment upload example. Run with: uv run attachments_example.py + +Opt-in raw input/output capture. When `attachments_enabled=True` (and the +project has the paid feature turned on), the SDK buffers the raw bytes locally, +writes a reference into the inference event, and uploads the bytes independently +via a presigned URL, so the batch flush never waits on the upload. + +Attachments are off by default and must be explicitly enabled. Set WILDEDGE_DSN +to see real uploads; otherwise the client runs in no-op mode. +""" + +import wildedge +from wildedge import Attachment + + +# Optional: redact / drop attachments before they are buffered. +def redact(attachments: list[Attachment]) -> list[Attachment]: + return [a for a in attachments if a.content_type != "application/secret"] + + +client = wildedge.init( + app_version="1.0.0", + attachments_enabled=True, + max_attachments_per_inference=5, + max_attachment_size_bytes=5 * 1024 * 1024, + attachment_storage_strategy="file", # or "inline" for small blobs + attachment_filter=redact, +) + +handle = client.register_model( + object(), + model_id="doc-classifier-v1", + source="local", + family="custom", +) + +# Pretend these came from a real inference call. +image_bytes = b"\xff\xd8\xff\xe0fake-jpeg-bytes" +answer = "This document is an invoice." + +inference_id = handle.track_inference( + duration_ms=120, + input_modality="image", + output_modality="text", + attachments=[ + Attachment(content_type="image/jpeg", role="input", data=image_bytes), + Attachment(content_type="text/plain", role="output", data=answer.encode()), + ], +) + +print(f"tracked inference {inference_id[:8]}… with 2 attachments") + +# Bytes upload in the background; flush/close lets buffered events drain. +client.close() diff --git a/pyproject.toml b/pyproject.toml index a1257ad..197d73c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "wildedge-sdk" -version = "0.1.4" -description = "On-device ML inference monitoring for Python" +version = "0.1.5" +description = "ML inference monitoring for Python: on-device and remote models" readme = "README.md" requires-python = ">=3.10" dependencies = [] diff --git a/tests/test_attachments.py b/tests/test_attachments.py new file mode 100644 index 0000000..46ff061 --- /dev/null +++ b/tests/test_attachments.py @@ -0,0 +1,480 @@ +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone + +from wildedge.attachments import ( + Attachment, + AttachmentManager, + AttachmentStore, + AttachmentTransmitter, + AttachmentUploader, + PendingAttachment, + UploadOutcome, +) +from wildedge.events.inference import InferenceEvent +from wildedge.model import ModelHandle, ModelInfo + +# --------------------------------------------------------------------------- # +# AttachmentStore +# --------------------------------------------------------------------------- # + + +def _append(store: AttachmentStore, data: bytes, attachment_id: str, role="input"): + store.append( + attachment=Attachment(content_type="text/plain", role=role, data=data), + attachment_id=attachment_id, + inference_id="inf-1", + inference_timestamp="2026-01-17T10:30:00+00:00", + data=data, + ) + + +def test_store_file_strategy_roundtrip(tmp_path): + store = AttachmentStore(str(tmp_path), strategy="file") + _append(store, b"hello", "a1") + assert store.length() == 1 + pending = store.peek_many(10) + assert len(pending) == 1 + assert pending[0].attachment_id == "a1" + assert pending[0].read_bytes() == b"hello" + assert pending[0].bin_path is not None and pending[0].bin_path.exists() + + +def test_store_inline_strategy_roundtrip(tmp_path): + store = AttachmentStore(str(tmp_path), strategy="inline") + _append(store, b"\x00\x01\x02inline", "a1") + pending = store.peek_many(10) + assert pending[0].read_bytes() == b"\x00\x01\x02inline" + assert pending[0].bin_path is None + + +def test_store_survives_restart(tmp_path): + store = AttachmentStore(str(tmp_path), strategy="file") + _append(store, b"persisted", "a1") + # Fresh instance reads the same directory. + reopened = AttachmentStore(str(tmp_path), strategy="file") + pending = reopened.peek_many(10) + assert len(pending) == 1 + assert pending[0].read_bytes() == b"persisted" + + +def test_store_capacity_drops_oldest(tmp_path): + store = AttachmentStore(str(tmp_path), strategy="file", max_pending=2) + _append(store, b"1", "a1") + _append(store, b"2", "a2") + _append(store, b"3", "a3") + assert store.length() == 2 + ids = [p.attachment_id for p in store.peek_many(10)] + assert ids == ["a2", "a3"] + # Oldest bin file is gone. + assert not (tmp_path / "a1.bin").exists() + + +def test_store_remove_deletes_record_and_bin(tmp_path): + store = AttachmentStore(str(tmp_path), strategy="file") + _append(store, b"x", "a1") + pending = store.peek_many(10) + store.remove(pending) + assert store.length() == 0 + assert not (tmp_path / "a1.bin").exists() + + +def test_store_peek_oldest_first(tmp_path): + store = AttachmentStore(str(tmp_path), strategy="file") + for i in range(5): + _append(store, str(i).encode(), f"a{i}") + ids = [p.attachment_id for p in store.peek_many(3)] + assert ids == ["a0", "a1", "a2"] + + +# --------------------------------------------------------------------------- # +# AttachmentManager (capture) +# --------------------------------------------------------------------------- # + + +def _manager(tmp_path, **kwargs): + store = AttachmentStore(str(tmp_path), strategy="file") + return store, AttachmentManager(store, **kwargs) + + +def test_manager_capture_returns_refs_and_buffers(tmp_path): + store, mgr = _manager(tmp_path) + refs = mgr.capture( + [ + Attachment(content_type="image/jpeg", role="input", data=b"img"), + Attachment(content_type="text/plain", role="output", data=b"out"), + ], + "inf-1", + datetime.now(timezone.utc), + ) + assert len(refs) == 2 + assert refs[0] == { + "attachment_id": refs[0]["attachment_id"], + "role": "input", + "content_type": "image/jpeg", + } + assert store.length() == 2 + + +def test_manager_disabled_returns_nothing(tmp_path): + store, mgr = _manager(tmp_path) + mgr.disable() + refs = mgr.capture( + [Attachment(content_type="text/plain", data=b"x")], + "inf-1", + datetime.now(timezone.utc), + ) + assert refs == [] + assert store.length() == 0 + + +def test_manager_enforces_max_per_inference(tmp_path): + store, mgr = _manager(tmp_path, max_per_inference=2) + refs = mgr.capture( + [Attachment(content_type="text/plain", data=b"x") for _ in range(5)], + "inf-1", + datetime.now(timezone.utc), + ) + assert len(refs) == 2 + assert store.length() == 2 + + +def test_manager_drops_oversized(tmp_path): + store, mgr = _manager(tmp_path, max_size_bytes=4) + refs = mgr.capture( + [ + Attachment(content_type="text/plain", data=b"ok"), + Attachment(content_type="text/plain", data=b"way too big"), + ], + "inf-1", + datetime.now(timezone.utc), + ) + assert len(refs) == 1 + assert store.length() == 1 + + +def test_manager_filter_hook(tmp_path): + store, mgr = _manager( + tmp_path, + attachment_filter=lambda atts: [a for a in atts if a.role == "input"], + ) + refs = mgr.capture( + [ + Attachment(content_type="text/plain", role="input", data=b"keep"), + Attachment(content_type="text/plain", role="output", data=b"drop"), + ], + "inf-1", + datetime.now(timezone.utc), + ) + assert len(refs) == 1 + assert refs[0]["role"] == "input" + + +# --------------------------------------------------------------------------- # +# InferenceEvent attachment refs +# --------------------------------------------------------------------------- # + + +def test_inference_event_emits_attachments(): + event = InferenceEvent( + model_id="m1", + duration_ms=10, + attachments=[ + {"attachment_id": "a1", "role": "input", "content_type": "image/jpeg"} + ], + ) + data = event.to_dict() + assert data["attachments"] == [ + {"attachment_id": "a1", "role": "input", "content_type": "image/jpeg"} + ] + + +def test_inference_event_omits_attachments_when_absent(): + event = InferenceEvent(model_id="m1", duration_ms=10) + assert "attachments" not in event.to_dict() + + +def test_text_input_meta_no_has_attachments(): + from wildedge.events.inference import TextInputMeta + + assert not hasattr(TextInputMeta(char_count=1), "has_attachments") + + +# --------------------------------------------------------------------------- # +# ModelHandle wiring +# --------------------------------------------------------------------------- # + + +def _make_handle(tmp_path): + store = AttachmentStore(str(tmp_path), strategy="file") + mgr = AttachmentManager(store) + published: list[dict] = [] + info = ModelInfo( + model_name="m", model_version="1", model_source="local", model_format="onnx" + ) + handle = ModelHandle("m1", info, published.append, capture_attachments=mgr.capture) + return handle, store, published + + +def test_track_inference_wires_attachments(tmp_path): + handle, store, published = _make_handle(tmp_path) + handle.track_inference( + duration_ms=10, + input_modality="image", + attachments=[Attachment(content_type="image/jpeg", role="input", data=b"img")], + ) + assert store.length() == 1 + event = published[0] + assert len(event["attachments"]) == 1 + assert event["attachments"][0]["content_type"] == "image/jpeg" + # The buffered attachment is keyed to this inference event. + assert store.peek_many(1)[0].inference_id == event["inference"]["inference_id"] + + +def test_track_inference_without_capture_callback_omits_refs(tmp_path): + info = ModelInfo( + model_name="m", model_version="1", model_source="local", model_format="onnx" + ) + published: list[dict] = [] + handle = ModelHandle("m1", info, published.append) # no capture callback + handle.track_inference( + duration_ms=10, + attachments=[Attachment(content_type="image/jpeg", data=b"img")], + ) + assert "attachments" not in published[0] + + +# --------------------------------------------------------------------------- # +# AttachmentTransmitter (presign + PUT) with a fake urlopen +# --------------------------------------------------------------------------- # + + +class _Resp: + def __init__(self, status: int, body: bytes = b""): + self.status = status + self._body = body + + def read(self) -> bytes: + return self._body + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + +def _fake_urlopen(responses): + """responses: list of _Resp or Exception, consumed in call order.""" + calls = iter(responses) + + def opener(req, timeout=None): + item = next(calls) + if isinstance(item, Exception): + raise item + return item + + return opener + + +PRESIGN_OK = _Resp( + 200, + json.dumps([{"upload_url": "https://blob/put", "attachment_id": "a1"}]).encode(), +) + + +def _pending(tmp_path) -> PendingAttachment: + store = AttachmentStore(str(tmp_path), strategy="file") + _append(store, b"payload", "a1") + return store.peek_many(1)[0] + + +def _transmit(monkeypatch, tmp_path, responses) -> UploadOutcome: + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen(responses)) + tx = AttachmentTransmitter(api_key="secret", host="https://ingest.example") + return tx.transmit(_pending(tmp_path)) + + +def test_transmit_success(monkeypatch, tmp_path): + outcome = _transmit(monkeypatch, tmp_path, [PRESIGN_OK, _Resp(200)]) + assert outcome is UploadOutcome.UPLOADED + + +def test_transmit_put_204(monkeypatch, tmp_path): + outcome = _transmit(monkeypatch, tmp_path, [PRESIGN_OK, _Resp(204)]) + assert outcome is UploadOutcome.UPLOADED + + +def test_presign_403_feature_disabled(monkeypatch, tmp_path): + err = urllib.error.HTTPError("u", 403, "forbidden", {}, None) + outcome = _transmit(monkeypatch, tmp_path, [err]) + assert outcome is UploadOutcome.FEATURE_DISABLED + + +def test_presign_422_permanent(monkeypatch, tmp_path): + err = urllib.error.HTTPError("u", 422, "bad", {}, None) + outcome = _transmit(monkeypatch, tmp_path, [err]) + assert outcome is UploadOutcome.PERMANENT + + +def test_presign_429_transient(monkeypatch, tmp_path): + err = urllib.error.HTTPError("u", 429, "rate", {}, None) + outcome = _transmit(monkeypatch, tmp_path, [err]) + assert outcome is UploadOutcome.TRANSIENT + + +def test_presign_500_transient(monkeypatch, tmp_path): + err = urllib.error.HTTPError("u", 500, "boom", {}, None) + outcome = _transmit(monkeypatch, tmp_path, [err]) + assert outcome is UploadOutcome.TRANSIENT + + +def test_presign_network_error_transient(monkeypatch, tmp_path): + outcome = _transmit(monkeypatch, tmp_path, [urllib.error.URLError("offline")]) + assert outcome is UploadOutcome.TRANSIENT + + +def test_put_expired_signature_transient(monkeypatch, tmp_path): + # Blob store returns 403 on an expired URL -> retry (re-presign next tick). + err = urllib.error.HTTPError("u", 403, "expired", {}, None) + outcome = _transmit(monkeypatch, tmp_path, [PRESIGN_OK, err]) + assert outcome is UploadOutcome.TRANSIENT + + +def test_put_400_permanent(monkeypatch, tmp_path): + err = urllib.error.HTTPError("u", 400, "bad", {}, None) + outcome = _transmit(monkeypatch, tmp_path, [PRESIGN_OK, err]) + assert outcome is UploadOutcome.PERMANENT + + +# --------------------------------------------------------------------------- # +# AttachmentUploader +# --------------------------------------------------------------------------- # + + +class _StubTransmitter: + def __init__(self, outcomes): + self.outcomes = list(outcomes) + self.seen: list[str] = [] + + def transmit(self, pending): + self.seen.append(pending.attachment_id) + return self.outcomes.pop(0) + + +def _filled_store(tmp_path, n=3): + store = AttachmentStore(str(tmp_path), strategy="file") + for i in range(n): + _append(store, str(i).encode(), f"a{i}") + return store + + +def test_uploader_uploads_and_removes(tmp_path): + store = _filled_store(tmp_path, 3) + tx = _StubTransmitter([UploadOutcome.UPLOADED] * 3) + up = AttachmentUploader(store, tx) + up.process_pending() + assert store.length() == 0 + assert up.uploaded_count == 3 + + +def test_uploader_stops_at_transient(tmp_path): + store = _filled_store(tmp_path, 3) + tx = _StubTransmitter( + [UploadOutcome.UPLOADED, UploadOutcome.TRANSIENT, UploadOutcome.UPLOADED] + ) + up = AttachmentUploader(store, tx) + up.process_pending() + # First removed, second hit transient -> stop; a1 and a2 remain. + ids = [p.attachment_id for p in store.peek_many(10)] + assert ids == ["a1", "a2"] + assert up.uploaded_count == 1 + + +def test_uploader_drops_on_permanent(tmp_path): + store = _filled_store(tmp_path, 2) + tx = _StubTransmitter([UploadOutcome.PERMANENT, UploadOutcome.UPLOADED]) + up = AttachmentUploader(store, tx) + up.process_pending() + assert store.length() == 0 + assert up.uploaded_count == 1 + assert up.dropped_count == 1 + + +def test_uploader_feature_disabled_clears_and_callbacks(tmp_path): + store = _filled_store(tmp_path, 3) + tx = _StubTransmitter([UploadOutcome.FEATURE_DISABLED]) + disabled = [] + up = AttachmentUploader( + store, tx, on_feature_disabled=lambda: disabled.append(True) + ) + up.process_pending() + assert disabled == [True] + assert store.length() == 0 + + +def test_uploader_evicts_stale(tmp_path): + store = _filled_store(tmp_path, 2) + tx = _StubTransmitter([UploadOutcome.UPLOADED] * 2) + up = AttachmentUploader(store, tx, max_age_s=0.0) + time.sleep(0.01) + up.process_pending() + assert store.length() == 0 + assert up.dropped_count == 2 + assert up.uploaded_count == 0 # all evicted before upload + assert tx.seen == [] + + +# --------------------------------------------------------------------------- # +# Client wiring +# --------------------------------------------------------------------------- # + + +def test_client_attachments_disabled_by_default(monkeypatch): + from wildedge import constants + from wildedge.client import WildEdge + + monkeypatch.delenv(constants.ENV_ATTACHMENTS_ENABLED, raising=False) + client = WildEdge(dsn="https://test@test.com/key") + try: + assert client.attachment_manager is None + assert client.attachment_uploader is None + finally: + client.close() + + +def test_client_attachments_enabled_wires_capture(monkeypatch, tmp_path): + from wildedge.client import WildEdge + + client = WildEdge( + dsn="https://test@test.com/key", + attachments_enabled=True, + attachment_dir=str(tmp_path), + sampling_interval_s=0, + ) + try: + assert client.attachment_manager is not None + handle = client.register_model(object(), model_id="m1", source="local") + assert handle.capture_attachments is not None + finally: + client.close() + + +def test_client_attachments_enabled_via_env(monkeypatch, tmp_path): + from wildedge import constants + from wildedge.client import WildEdge + + monkeypatch.setenv(constants.ENV_ATTACHMENTS_ENABLED, "1") + client = WildEdge( + dsn="https://test@test.com/key", + attachment_dir=str(tmp_path), + sampling_interval_s=0, + ) + try: + assert client.attachment_manager is not None + finally: + client.close() diff --git a/tests/test_device.py b/tests/test_device.py index 70fa84d..fa1671b 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import patch +from wildedge import constants from wildedge.platforms import detect_device, get_device_id_path from wildedge.platforms.base import Platform, hmac_device_id from wildedge.platforms.device_info import DeviceInfo @@ -35,7 +36,7 @@ def test_returns_device_info(self, monkeypatch): info = detect_device(api_key="test-key", app_version="1.0.0") assert isinstance(info, DeviceInfo) assert info.app_version == "1.0.0" - assert info.sdk_version == "wildedge-python-0.1.0" + assert info.sdk_version == constants.SDK_VERSION def test_device_type_is_normalised(self, monkeypatch): monkeypatch.setattr( diff --git a/tests/test_hardware_context.py b/tests/test_hardware_context.py index d35f0a8..0240818 100644 --- a/tests/test_hardware_context.py +++ b/tests/test_hardware_context.py @@ -1,6 +1,7 @@ from __future__ import annotations from wildedge.events.inference import InferenceEvent +from wildedge.model import ModelHandle, ModelInfo from wildedge.platforms import capture_hardware from wildedge.platforms.hardware import HardwareContext, ThermalContext from wildedge.platforms.linux import LinuxPlatform @@ -81,3 +82,30 @@ def test_capture_preserves_existing_accelerator_when_not_passed(monkeypatch): lambda self: HardwareContext(accelerator_actual="mps"), ) assert capture_hardware().accelerator_actual == "mps" + + +def test_track_inference_passes_detected_accelerator_to_hardware(monkeypatch): + """detected_accelerator on the handle must appear as accelerator_actual in the event.""" + monkeypatch.setattr("wildedge.model.is_sampling", lambda: True) + monkeypatch.setattr( + "wildedge.model.capture_hardware", + lambda accelerator_actual=None: HardwareContext( + accelerator_actual=accelerator_actual + ), + ) + + published = [] + info = ModelInfo( + model_name="test", + model_format="pytorch", + model_version="1", + model_source="local", + ) + handle = ModelHandle(model_id="m1", info=info, publish=published.append) + handle.detected_accelerator = "cuda" + + handle.track_inference(duration_ms=50) + + assert len(published) == 1 + hardware = published[0]["inference"].get("hardware", {}) + assert hardware.get("accelerator_actual") == "cuda" diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 168e98d..05a1de4 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -2,11 +2,29 @@ from wildedge.platforms.base import cuda_device_count from wildedge.platforms.linux import LinuxPlatform -from wildedge.platforms.macos import MacOSPlatform +from wildedge.platforms.macos import MACOS_THERMAL_STATES, MacOSPlatform from wildedge.platforms.unknown import UnknownPlatform from wildedge.platforms.windows import WindowsPlatform +class FakeObjc: + """Minimal stand-in for the libobjc CDLL used in MacOSPlatform.thermal(). + + Absorbs restype/argtypes assignments silently and returns a no-op callable + for any attribute not explicitly defined. + """ + + objc_getClass = staticmethod(lambda name: 1) # noqa: N815 + sel_registerName = staticmethod(lambda name: 2) # noqa: N815 + objc_msgSend = staticmethod(lambda *a: 3) # noqa: N815 + + def __getattr__(self, name): + return lambda *a, **kw: 0 + + def __setattr__(self, name, val): + pass + + def test_linux_gpu_accelerators_cuda_and_rocm_counts(monkeypatch): platform = LinuxPlatform() monkeypatch.setattr("wildedge.platforms.linux.cuda_device_count", lambda _: 1) @@ -116,6 +134,93 @@ def test_unknown_os_version_returns_platform_version(monkeypatch): assert UnknownPlatform().os_version() == "some-kernel-version" +def test_macos_thermal_states_covers_all_levels(): + assert set(MACOS_THERMAL_STATES) == {0, 1, 2, 3} + states = {v[0] for v in MACOS_THERMAL_STATES.values()} + assert states == {"nominal", "fair", "serious", "critical"} + + +def test_macos_cpu_freq_intel(monkeypatch): + """On Intel, both hw.cpufrequency and hw.cpufrequency_max are available.""" + import wildedge.platforms.macos as macos_mod + + calls = [] + + def fake_sysctl(name): + calls.append(name) + return { + b"hw.cpufrequency": 2_400_000_000, + b"hw.cpufrequency_max": 3_200_000_000, + }.get(name) + + monkeypatch.setattr(macos_mod, "_sysctl_uint64", fake_sysctl) + cur, max_f = MacOSPlatform().cpu_freq() + assert cur == 2400 + assert max_f == 3200 + + +def test_macos_cpu_freq_apple_silicon(monkeypatch): + """On Apple Silicon, hw.cpufrequency is absent; max falls back to hw.perflevel0.cpufrequency_max.""" + import wildedge.platforms.macos as macos_mod + + def fake_sysctl(name): + return {b"hw.perflevel0.cpufrequency_max": 4_056_000_000}.get(name) + + monkeypatch.setattr(macos_mod, "_sysctl_uint64", fake_sysctl) + cur, max_f = MacOSPlatform().cpu_freq() + assert cur is None + assert max_f == 4056 + + +def test_macos_thermal_nominal(monkeypatch): + import wildedge.platforms.macos as macos_mod + + monkeypatch.setattr(macos_mod.ctypes.cdll, "LoadLibrary", lambda _: FakeObjc()) + monkeypatch.setattr( + macos_mod.ctypes, + "CFUNCTYPE", + lambda *types: lambda fn: lambda *a: 0, # always returns level 0 (nominal) + ) + monkeypatch.setattr( + macos_mod.ctypes, "cast", lambda obj, t: type("V", (), {"value": 0})() + ) + + ctx = MacOSPlatform().thermal() + assert ctx is not None + assert ctx.state == "nominal" + + +def test_macos_thermal_serious(monkeypatch): + import wildedge.platforms.macos as macos_mod + + monkeypatch.setattr(macos_mod.ctypes.cdll, "LoadLibrary", lambda _: FakeObjc()) + monkeypatch.setattr( + macos_mod.ctypes, + "CFUNCTYPE", + lambda *types: lambda fn: lambda *a: 2, # level 2 = serious + ) + monkeypatch.setattr( + macos_mod.ctypes, "cast", lambda obj, t: type("V", (), {"value": 0})() + ) + + ctx = MacOSPlatform().thermal() + assert ctx is not None + assert ctx.state == "serious" + assert ctx.state_raw == "serious" + + +def test_macos_thermal_returns_none_on_error(monkeypatch): + import wildedge.platforms.macos as macos_mod + + monkeypatch.setattr( + macos_mod.ctypes.cdll, + "LoadLibrary", + lambda _: (_ for _ in ()).throw(OSError("no objc")), + ) + + assert MacOSPlatform().thermal() is None + + def test_platform_adapters_expose_state_and_cache_paths(): for adapter in ( LinuxPlatform(), diff --git a/tests/test_platform_macos.py b/tests/test_platform_macos.py index 9506321..096030c 100644 --- a/tests/test_platform_macos.py +++ b/tests/test_platform_macos.py @@ -2,6 +2,7 @@ import pytest +from wildedge.platforms.hardware import ThermalContext from wildedge.platforms.macos import MacOSPlatform @@ -42,12 +43,20 @@ def test_ram_bytes_delegates_to_meminfo(monkeypatch): def test_hardware_context_fields(monkeypatch): monkeypatch.setattr(MacOSPlatform, "meminfo", lambda self: (None, 3_000_000_000)) monkeypatch.setattr(MacOSPlatform, "battery", lambda self: (0.8, False)) + monkeypatch.setattr(MacOSPlatform, "cpu_freq", lambda self: (None, 4056)) + monkeypatch.setattr( + MacOSPlatform, + "thermal", + lambda self: ThermalContext(state="fair", state_raw="fair"), + ) ctx = MacOSPlatform().hardware_context() assert ctx.memory_available_bytes == 3_000_000_000 assert ctx.battery_level == pytest.approx(0.8) assert ctx.battery_charging is False assert ctx.cpu_freq_mhz is None - assert ctx.thermal is None + assert ctx.cpu_freq_max_mhz == 4056 + assert ctx.thermal is not None + assert ctx.thermal.state == "fair" @pytest.mark.requires_macos diff --git a/uv.lock b/uv.lock index 58a4ccf..ca702ca 100644 --- a/uv.lock +++ b/uv.lock @@ -419,7 +419,7 @@ wheels = [ [[package]] name = "wildedge-sdk" -version = "0.1.3" +version = "0.1.5" source = { editable = "." } [package.dev-dependencies] diff --git a/wildedge/__init__.py b/wildedge/__init__.py index 2b7993f..a2d68b9 100644 --- a/wildedge/__init__.py +++ b/wildedge/__init__.py @@ -1,5 +1,6 @@ """WildEdge Python SDK.""" +from wildedge.attachments import Attachment from wildedge.client import SpanContextManager, WildEdge from wildedge.convenience import init from wildedge.decorators import track @@ -35,6 +36,7 @@ __all__ = [ "WildEdge", "init", + "Attachment", "capture_hardware", "HardwareContext", "ThermalContext", diff --git a/wildedge/attachments.py b/wildedge/attachments.py new file mode 100644 index 0000000..9636120 --- /dev/null +++ b/wildedge/attachments.py @@ -0,0 +1,539 @@ +"""Opt-in raw input/output attachment upload. + +Capture and upload are decoupled, mirroring the Swift SDK: + + track_inference(attachments=[...]) AttachmentUploader (own thread) + -> generate attachment_id -> POST /api/attachments/presign + -> AttachmentStore.append(bytes + meta) -> PUT bytes to presigned URL + -> event["attachments"] = [refs] -> delete local bytes on success + +The batch flush never waits on bytes: the reference is written into the +inference event immediately, while the bytes ride this module's independent +loop. Only collect attachments for events guaranteed to be transmitted - with +reservoir sampling inactive every inference event is transmitted, so every +attachment is eligible. +""" + +from __future__ import annotations + +import base64 +import json +import threading +import time +import urllib.error +import urllib.request +import uuid +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any + +from wildedge import constants +from wildedge.logging import logger + +AttachmentStorageStrategy = str # "file" | "inline" + + +@dataclass +class Attachment: + """A raw input or output blob to upload alongside an inference event. + + Provide exactly one of ``data`` (in-memory bytes) or ``path`` (a file on + disk). ``content_type`` is the MIME type baked into the presigned URL and + echoed in the inference event reference. + """ + + content_type: str + role: str = "input" # "input" | "output" + data: bytes | None = None + path: str | None = None + name: str | None = None + + def resolve_bytes(self) -> bytes: + if self.data is not None: + return self.data + if self.path is not None: + return Path(self.path).expanduser().read_bytes() + raise ValueError("Attachment requires either data or path") + + def size_bytes(self) -> int: + if self.data is not None: + return len(self.data) + if self.path is not None: + return Path(self.path).expanduser().stat().st_size + raise ValueError("Attachment requires either data or path") + + +@dataclass +class PendingAttachment: + """A buffered attachment awaiting upload, reconstructed from disk.""" + + attachment_id: str + inference_id: str + role: str + content_type: str + size_bytes: int + inference_timestamp: str + registered_at: float + record_path: Path + bin_path: Path | None = None + inline_data: bytes | None = None + name: str | None = None + + def read_bytes(self) -> bytes: + if self.inline_data is not None: + return self.inline_data + if self.bin_path is not None: + return self.bin_path.read_bytes() + raise ValueError("PendingAttachment has no payload") + + +class UploadOutcome(Enum): + UPLOADED = "uploaded" + TRANSIENT = "transient" + PERMANENT = "permanent" + FEATURE_DISABLED = "feature_disabled" + + +class AttachmentStore: + """Disk-backed, restart-safe buffer of pending attachments. + + Each attachment is one JSON record file (timestamp-ordered name for + oldest-first draining); ``file`` strategy also writes a sibling ``.bin`` + holding the raw bytes, while ``inline`` embeds base64 bytes in the record. + """ + + def __init__( + self, + directory: str, + *, + strategy: AttachmentStorageStrategy = constants.DEFAULT_ATTACHMENT_STORAGE_STRATEGY, + max_pending: int = constants.DEFAULT_MAX_PENDING_ATTACHMENTS, + ) -> None: + self.directory = Path(directory).expanduser() + self.strategy = strategy + self.max_pending = max_pending + self.lock = threading.Lock() + self._seq = 0 + self.directory.mkdir(parents=True, exist_ok=True) + + def append( + self, + *, + attachment: Attachment, + attachment_id: str, + inference_id: str, + inference_timestamp: str, + data: bytes, + ) -> None: + with self.lock: + self._evict_for_capacity() + record: dict[str, Any] = { + "attachment_id": attachment_id, + "inference_id": inference_id, + "role": attachment.role, + "content_type": attachment.content_type, + "size_bytes": len(data), + "inference_timestamp": inference_timestamp, + "registered_at": time.time(), + "name": attachment.name, + } + if self.strategy == "inline": + record["payload_kind"] = "inline" + record["data_b64"] = base64.b64encode(data).decode("ascii") + else: + bin_path = self.directory / f"{attachment_id}.bin" + bin_path.write_bytes(data) + record["payload_kind"] = "file" + record["bin_file"] = bin_path.name + + name = f"{time.time_ns():020d}-{self._seq:010d}-{attachment_id}.json" + self._seq += 1 + (self.directory / name).write_text( + json.dumps(record, separators=(",", ":"), ensure_ascii=True) + ) + + def _evict_for_capacity(self) -> None: + # Caller holds the lock. + records = sorted(self.directory.glob("*.json")) + overflow = len(records) - self.max_pending + 1 + if overflow <= 0: + return + for path in records[:overflow]: + pending = self._load(path) + if pending is not None: + self._delete(pending) + else: + path.unlink(missing_ok=True) + logger.warning( + "wildedge: attachment buffer full (%d); dropped %d oldest", + self.max_pending, + overflow, + ) + + def peek_many(self, n: int) -> list[PendingAttachment]: + with self.lock: + result: list[PendingAttachment] = [] + for path in sorted(self.directory.glob("*.json"))[:n]: + pending = self._load(path) + if pending is not None: + result.append(pending) + else: + path.unlink(missing_ok=True) + return result + + def remove(self, pendings: list[PendingAttachment]) -> None: + with self.lock: + for pending in pendings: + self._delete(pending) + + def _delete(self, pending: PendingAttachment) -> None: + # Caller holds the lock. + pending.record_path.unlink(missing_ok=True) + if pending.bin_path is not None: + pending.bin_path.unlink(missing_ok=True) + + def clear(self) -> None: + with self.lock: + for path in self.directory.glob("*.json"): + pending = self._load(path) + if pending is not None: + self._delete(pending) + else: + path.unlink(missing_ok=True) + for stray in self.directory.glob("*.bin"): + stray.unlink(missing_ok=True) + + def length(self) -> int: + with self.lock: + return sum(1 for _ in self.directory.glob("*.json")) + + def _load(self, path: Path) -> PendingAttachment | None: + try: + record = json.loads(path.read_text()) + except Exception: + return None + if not isinstance(record, dict): + return None + try: + kind = record["payload_kind"] + bin_path: Path | None = None + inline_data: bytes | None = None + if kind == "file": + bin_path = self.directory / record["bin_file"] + if not bin_path.exists(): + return None + elif kind == "inline": + inline_data = base64.b64decode(record["data_b64"]) + else: + return None + return PendingAttachment( + attachment_id=str(record["attachment_id"]), + inference_id=str(record["inference_id"]), + role=str(record["role"]), + content_type=str(record["content_type"]), + size_bytes=int(record["size_bytes"]), + inference_timestamp=str(record["inference_timestamp"]), + registered_at=float(record["registered_at"]), + record_path=path, + bin_path=bin_path, + inline_data=inline_data, + name=record.get("name"), + ) + except (KeyError, ValueError, TypeError): + return None + + +class AttachmentManager: + """Capture-side gate: applies the filter hook and per-inference caps, then + buffers eligible attachments and returns the event references.""" + + def __init__( + self, + store: AttachmentStore, + *, + max_per_inference: int = constants.DEFAULT_MAX_ATTACHMENTS_PER_INFERENCE, + max_size_bytes: int = constants.DEFAULT_MAX_ATTACHMENT_SIZE_BYTES, + attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None, + debug: bool = False, + ) -> None: + self.store = store + self.max_per_inference = max_per_inference + self.max_size_bytes = max_size_bytes + self.attachment_filter = attachment_filter + self.debug = debug + self.enabled = True + + def disable(self) -> None: + """Stop accepting attachments (e.g. the project has the feature off).""" + self.enabled = False + + def capture( + self, + attachments: list[Attachment], + inference_id: str, + inference_timestamp: datetime, + ) -> list[dict[str, str]]: + if not self.enabled or not attachments: + return [] + + selected = attachments + if self.attachment_filter is not None: + try: + selected = self.attachment_filter(list(attachments)) + except Exception as exc: # pragma: no cover - user callback failures + logger.warning("wildedge: attachment_filter raised: %s", exc) + return [] + + if len(selected) > self.max_per_inference: + if self.debug: + logger.debug( + "wildedge: %d attachments exceeds max_per_inference=%d; dropping excess", + len(selected), + self.max_per_inference, + ) + selected = selected[: self.max_per_inference] + + ts = inference_timestamp.isoformat() + refs: list[dict[str, str]] = [] + for attachment in selected: + try: + data = attachment.resolve_bytes() + except Exception as exc: + logger.warning("wildedge: could not read attachment bytes: %s", exc) + continue + if len(data) > self.max_size_bytes: + if self.debug: + logger.debug( + "wildedge: attachment (%d bytes) exceeds max_size_bytes=%d; dropping", + len(data), + self.max_size_bytes, + ) + continue + attachment_id = str(uuid.uuid4()) + try: + self.store.append( + attachment=attachment, + attachment_id=attachment_id, + inference_id=inference_id, + inference_timestamp=ts, + data=data, + ) + except Exception as exc: + logger.warning("wildedge: failed to buffer attachment: %s", exc) + continue + refs.append( + { + "attachment_id": attachment_id, + "role": attachment.role, + "content_type": attachment.content_type, + } + ) + return refs + + +class AttachmentTransmitter: + """Presigns and PUTs a single attachment. Presign hits the ingest host with + the project secret; the PUT goes straight to the returned blob-store URL + with no secret.""" + + def __init__( + self, + api_key: str, + host: str, + timeout: float = constants.DEFAULT_ATTACHMENT_HTTP_TIMEOUT, + ) -> None: + self.api_key = api_key + self.host = host.rstrip("/") + self.timeout = timeout + + def transmit(self, pending: PendingAttachment) -> UploadOutcome: + outcome, upload_url = self._presign(pending) + if upload_url is None: + return outcome + try: + data = pending.read_bytes() + except Exception as exc: + logger.warning("wildedge: attachment bytes unreadable: %s", exc) + return UploadOutcome.PERMANENT + return self._put(upload_url, data, pending.content_type) + + def _presign(self, pending: PendingAttachment) -> tuple[UploadOutcome, str | None]: + url = f"{self.host}/api/attachments/presign" + body = json.dumps( + [ + { + "attachment_id": pending.attachment_id, + "role": pending.role, + "content_type": pending.content_type, + "size_bytes": pending.size_bytes, + "inference_timestamp": pending.inference_timestamp, + } + ] + ).encode() + req = urllib.request.Request( + url, + data=body, + headers={ + "User-Agent": constants.SDK_VERSION, + "X-Project-Secret": self.api_key, + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + status = resp.status + raw = resp.read() + except urllib.error.HTTPError as exc: + status = exc.code + raw = exc.read() + except (urllib.error.URLError, OSError) as exc: + logger.debug("wildedge: presign network error: %s", exc) + return UploadOutcome.TRANSIENT, None + + if status in (200, 201): + try: + entries = json.loads(raw) + upload_url = entries[0]["upload_url"] + except (ValueError, KeyError, IndexError, TypeError): + return UploadOutcome.TRANSIENT, None + return UploadOutcome.UPLOADED, upload_url + if status == 403: + logger.warning( + "wildedge: attachments not enabled for this project (403); " + "disabling attachment upload" + ) + return UploadOutcome.FEATURE_DISABLED, None + if status == 422: + logger.warning( + "wildedge: attachment presign validation error (422): %s", + raw[: constants.ERROR_MSG_MAX_LEN], + ) + return UploadOutcome.PERMANENT, None + if status == 401 or status == 400: + logger.warning( + "wildedge: attachment presign rejected (%d) - dropping", status + ) + return UploadOutcome.PERMANENT, None + # 429 / 5xx / anything else: retry later. + return UploadOutcome.TRANSIENT, None + + def _put(self, upload_url: str, data: bytes, content_type: str) -> UploadOutcome: + req = urllib.request.Request( + upload_url, + data=data, + headers={"Content-Type": content_type}, + method="PUT", + ) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + status = resp.status + except urllib.error.HTTPError as exc: + status = exc.code + except (urllib.error.URLError, OSError) as exc: + logger.debug("wildedge: attachment upload network error: %s", exc) + return UploadOutcome.TRANSIENT + + if status in (200, 201, 202, 204): + return UploadOutcome.UPLOADED + if status == 400: + return UploadOutcome.PERMANENT + # Expired/invalid signature (403) and 5xx: retry, which re-presigns. + return UploadOutcome.TRANSIENT + + +class AttachmentUploader: + """Background daemon thread that drains the AttachmentStore oldest-first.""" + + def __init__( + self, + store: AttachmentStore, + transmitter: AttachmentTransmitter, + *, + flush_interval_s: float = constants.DEFAULT_ATTACHMENT_FLUSH_INTERVAL_SEC, + max_age_s: float = constants.DEFAULT_MAX_ATTACHMENT_AGE_SEC, + on_feature_disabled: Callable[[], None] | None = None, + debug: bool = False, + ) -> None: + self.store = store + self.transmitter = transmitter + self.flush_interval_s = flush_interval_s + self.max_age_s = max_age_s + self.on_feature_disabled = on_feature_disabled + self.debug = debug + self.uploaded_count = 0 + self.dropped_count = 0 + self.stop_event = threading.Event() + self.thread = self._make_thread() + + def _make_thread(self) -> threading.Thread: + return threading.Thread( + target=self._run, daemon=True, name="wildedge-attachment-uploader" + ) + + def start(self) -> None: + self.thread.start() + + def stop(self) -> None: + self.stop_event.set() + if self.thread.is_alive(): + self.thread.join(timeout=2.0) + + def _pause(self) -> None: + """Stop the thread before a fork() so no wildedge threads survive.""" + self.stop_event.set() + if self.thread.is_alive(): + self.thread.join(timeout=1.0) + + def _resume(self) -> None: + """Start a fresh thread after a fork() in parent and child.""" + self.stop_event = threading.Event() + self.thread = self._make_thread() + self.thread.start() + + def _run(self) -> None: + while not self.stop_event.wait(self.flush_interval_s): + try: + self.process_pending() + except Exception as exc: # pragma: no cover - never kill the thread + logger.warning("wildedge: attachment uploader error: %s", exc) + + def process_pending(self) -> None: + # Evict stale buffered bytes from the front of the queue. + cutoff = time.time() - self.max_age_s + batch = self.store.peek_many(constants.ATTACHMENT_PRESIGN_BATCH_SIZE) + stale = [p for p in batch if p.registered_at < cutoff] + if stale: + self.store.remove(stale) + self.dropped_count += len(stale) + logger.warning( + "wildedge: evicted %d stale attachment(s) (age > %.0fs)", + len(stale), + self.max_age_s, + ) + batch = [p for p in batch if p.registered_at >= cutoff] + + done: list[PendingAttachment] = [] + for pending in batch: + outcome = self.transmitter.transmit(pending) + if outcome is UploadOutcome.UPLOADED: + done.append(pending) + self.uploaded_count += 1 + elif outcome is UploadOutcome.PERMANENT: + done.append(pending) + self.dropped_count += 1 + elif outcome is UploadOutcome.FEATURE_DISABLED: + if self.on_feature_disabled is not None: + self.on_feature_disabled() + self.store.remove(done) + self.store.clear() + self.stop_event.set() + return + else: # TRANSIENT - stop, preserve order, retry next tick + break + + if done: + self.store.remove(done) diff --git a/wildedge/cli.py b/wildedge/cli.py index 3d44b75..967b23b 100644 --- a/wildedge/cli.py +++ b/wildedge/cli.py @@ -58,6 +58,11 @@ def build_parser() -> argparse.ArgumentParser: default=constants.DEFAULT_SHUTDOWN_FLUSH_TIMEOUT_SEC, help="Flush timeout (seconds) for shutdown.", ) + run.add_argument( + "--attachments", + action="store_true", + help="Enable opt-in raw input/output attachment upload (paid feature).", + ) run.add_argument( "--debug", action="store_true", @@ -242,6 +247,8 @@ def run_command(parsed: argparse.Namespace) -> int: env[constants.ENV_HUBS] = parsed.hubs env[constants.ENV_SAMPLING_INTERVAL] = str(parsed.sampling_interval) env[constants.ENV_FLUSH_TIMEOUT] = str(parsed.flush_timeout) + if parsed.attachments: + env[constants.ENV_ATTACHMENTS_ENABLED] = "1" env[constants.ENV_PROPAGATE] = "1" if parsed.propagate else "0" env[constants.ENV_STRICT_INTEGRATIONS] = "1" if parsed.strict_integrations else "0" env[constants.ENV_PRINT_STARTUP_REPORT] = ( diff --git a/wildedge/client.py b/wildedge/client.py index a6ac911..48fbfe2 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -10,6 +10,13 @@ from urllib.parse import urlparse from wildedge import constants +from wildedge.attachments import ( + Attachment, + AttachmentManager, + AttachmentStore, + AttachmentTransmitter, + AttachmentUploader, +) from wildedge.consumer import Consumer from wildedge.dead_letters import DeadLetterStore from wildedge.events import SpanEvent @@ -33,6 +40,7 @@ from wildedge.logging import enable_debug, logger from wildedge.model import ModelHandle, ModelInfo, ModelRegistry from wildedge.paths import ( + default_attachment_dir, default_dead_letter_dir, default_model_registry_path, default_pending_queue_dir, @@ -265,6 +273,16 @@ def __init__( max_dead_letter_batches: int = constants.DEFAULT_MAX_DEAD_LETTER_BATCHES, on_delivery_failure: Callable[[str, int, int], None] | None = None, sampling_interval_s: float | None = constants.DEFAULT_SAMPLING_INTERVAL_S, + attachments_enabled: bool | None = None, + max_attachments_per_inference: int = ( + constants.DEFAULT_MAX_ATTACHMENTS_PER_INFERENCE + ), + max_attachment_size_bytes: int = constants.DEFAULT_MAX_ATTACHMENT_SIZE_BYTES, + attachment_storage_strategy: str = ( + constants.DEFAULT_ATTACHMENT_STORAGE_STRATEGY + ), + attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None, + attachment_dir: str | None = None, ): env = read_client_env(dsn=dsn, debug=debug, app_identity=app_identity) dsn = env.dsn @@ -356,6 +374,37 @@ def __init__( if sampling_interval_s: start_sampler(interval_s=sampling_interval_s) + if attachments_enabled is None: + attachments_enabled = ( + env.attachments_enabled + if env.attachments_enabled is not None + else constants.DEFAULT_ATTACHMENTS_ENABLED + ) + self.attachment_manager: AttachmentManager | None = None + self.attachment_uploader: AttachmentUploader | None = None + if attachments_enabled: + resolved_attachment_dir = attachment_dir or str( + default_attachment_dir(app_identity) + ) + attachment_store = AttachmentStore( + directory=resolved_attachment_dir, + strategy=attachment_storage_strategy, + ) + self.attachment_manager = AttachmentManager( + attachment_store, + max_per_inference=max_attachments_per_inference, + max_size_bytes=max_attachment_size_bytes, + attachment_filter=attachment_filter, + debug=debug, + ) + self.attachment_uploader = AttachmentUploader( + attachment_store, + AttachmentTransmitter(api_key=api_key, host=host), + on_feature_disabled=self.attachment_manager.disable, + debug=debug, + ) + self.attachment_uploader.start() + self.auto_loaded: set[str] = set() # Stores the active weakref.finalize for each auto-loaded model so that # when a wrapping object (e.g. a Pipeline) supersedes the inner model, the @@ -371,6 +420,12 @@ def __init__( after_in_child=self.consumer._resume, after_in_parent=self.consumer._resume, ) + if self.attachment_uploader is not None: + os.register_at_fork( + before=self.attachment_uploader._pause, + after_in_child=self.attachment_uploader._resume, + after_in_parent=self.attachment_uploader._resume, + ) if debug: logger.debug("wildedge: client initialized (session=%s)", self.session_id) @@ -398,6 +453,8 @@ def _init_noop(self, *, debug: bool, device: DeviceInfo | None) -> None: self.auto_loaded = set() self._auto_load_finalizers = {} self.hub_trackers = {} + self.attachment_manager = None + self.attachment_uploader = None def publish(self, event_dict: dict) -> None: if self.closed or self.noop: @@ -467,7 +524,14 @@ def register_model( "Pass id='your-model-id' to register_model()." ) - handle, is_new = self.registry.register(model_id, info, self.publish) + capture_attachments = ( + self.attachment_manager.capture + if self.attachment_manager is not None + else None + ) + handle, is_new = self.registry.register( + model_id, info, self.publish, capture_attachments + ) if not is_new: logger.debug( @@ -909,6 +973,8 @@ def close(self, timeout: float | None = None) -> None: self.closed = True if not self.noop: stop_sampler() + if self.attachment_uploader is not None: + self.attachment_uploader.stop() if timeout is None: self.consumer.close() else: diff --git a/wildedge/constants.py b/wildedge/constants.py index 8c3ebd0..87b5afd 100644 --- a/wildedge/constants.py +++ b/wildedge/constants.py @@ -1,5 +1,10 @@ # SDK identity -SDK_VERSION = "wildedge-python-0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + SDK_VERSION = f"wildedge-python-{version('wildedge-sdk')}" +except PackageNotFoundError: # running from a source tree without install metadata + SDK_VERSION = "wildedge-python-0.0.0+unknown" PROTOCOL_VERSION = "1.0" # Env vars @@ -14,6 +19,7 @@ ENV_PROPAGATE = "WILDEDGE_PROPAGATE" ENV_PRINT_STARTUP_REPORT = "WILDEDGE_PRINT_STARTUP_REPORT" ENV_SAMPLING_INTERVAL = "WILDEDGE_SAMPLING_INTERVAL_S" +ENV_ATTACHMENTS_ENABLED = "WILDEDGE_ATTACHMENTS_ENABLED" # Defaults DEFAULT_SAMPLING_INTERVAL_S = 30.0 @@ -27,6 +33,17 @@ DEFAULT_MAX_DEAD_LETTER_BATCHES = 10 DEFAULT_SHUTDOWN_FLUSH_TIMEOUT_SEC = 0.0 +# Attachments (opt-in raw input/output upload) +DEFAULT_ATTACHMENTS_ENABLED = False +DEFAULT_ATTACHMENT_STORAGE_STRATEGY = "file" # "file" | "inline" +DEFAULT_MAX_ATTACHMENTS_PER_INFERENCE = 10 +DEFAULT_MAX_ATTACHMENT_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB +DEFAULT_MAX_PENDING_ATTACHMENTS = 100 +DEFAULT_MAX_ATTACHMENT_AGE_SEC = 5 * 60 +DEFAULT_ATTACHMENT_FLUSH_INTERVAL_SEC = 5.0 +DEFAULT_ATTACHMENT_HTTP_TIMEOUT = 10.0 +ATTACHMENT_PRESIGN_BATCH_SIZE = 5 + # Consumer backoff BACKOFF_MIN = 1.0 BACKOFF_MAX = 60.0 diff --git a/wildedge/events/inference.py b/wildedge/events/inference.py index ef76ae5..ecbb1b4 100644 --- a/wildedge/events/inference.py +++ b/wildedge/events/inference.py @@ -106,7 +106,6 @@ class TextInputMeta: contains_code: bool | None = None prompt_type: str | None = None turn_index: int | None = None - has_attachments: bool | None = None def to_dict(self) -> dict: return { @@ -121,7 +120,6 @@ def to_dict(self) -> dict: "contains_code": self.contains_code, "prompt_type": self.prompt_type, "turn_index": self.turn_index, - "has_attachments": self.has_attachments, }.items() if v is not None } @@ -304,6 +302,7 @@ class InferenceEvent: generation_config: GenerationConfig | None = None hardware: HardwareContext | None = None api_meta: ApiMeta | None = None + attachments: list[dict[str, Any]] | None = None trace_id: str | None = None span_id: str | None = None parent_span_id: str | None = None @@ -348,6 +347,8 @@ def to_dict(self) -> dict: "model_id": self.model_id, "inference": inference_data, } + if self.attachments: + event["attachments"] = self.attachments from wildedge.events.common import add_optional_fields add_optional_fields( diff --git a/wildedge/model.py b/wildedge/model.py index 2502b14..e133172 100644 --- a/wildedge/model.py +++ b/wildedge/model.py @@ -3,9 +3,11 @@ import json from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from pathlib import Path from typing import Any +from wildedge.attachments import Attachment from wildedge.events import ( ApiMeta, AudioInputMeta, @@ -54,10 +56,18 @@ def to_dict(self) -> dict: class ModelHandle: """Handle returned by register_model(). All events for a model go through here.""" - def __init__(self, model_id: str, info: ModelInfo, publish: Callable[[dict], None]): + def __init__( + self, + model_id: str, + info: ModelInfo, + publish: Callable[[dict], None], + capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]] + | None = None, + ): self.model_id = model_id self.info = info self.publish = publish + self.capture_attachments = capture_attachments self.detected_accelerator: str = "cpu" self.last_inference_id: str | None = None @@ -204,6 +214,7 @@ def track_inference( generation_config: GenerationConfig | None = None, hardware: HardwareContext | None = None, api_meta: ApiMeta | None = None, + attachments: list[Attachment] | None = None, trace_id: str | None = None, span_id: str | None = None, parent_span_id: str | None = None, @@ -214,7 +225,7 @@ def track_inference( context: dict[str, Any] | None = None, ) -> str: if hardware is None and is_sampling(): - hardware = capture_hardware() + hardware = capture_hardware(accelerator_actual=self.detected_accelerator) correlation = _merge_correlation_fields( trace_id=trace_id, span_id=span_id, @@ -240,6 +251,12 @@ def track_inference( api_meta=api_meta, **correlation, ) + if attachments and self.capture_attachments is not None: + refs = self.capture_attachments( + attachments, event.inference_id, event.timestamp + ) + if refs: + event.attachments = refs self.last_inference_id = event.inference_id self.publish(event.to_dict()) return event.inference_id @@ -375,7 +392,12 @@ def save_to_disk(self) -> None: ) def register( - self, model_id: str, info: ModelInfo, publish: Callable[[dict], None] + self, + model_id: str, + info: ModelInfo, + publish: Callable[[dict], None], + capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]] + | None = None, ) -> tuple[ModelHandle, bool]: """Return (handle, is_new). is_new=False means already registered; skip install_hooks.""" if model_id in self.handles: @@ -386,7 +408,10 @@ def register( self.save_to_disk() handle = ModelHandle( - model_id=model_id, info=self.models[model_id], publish=publish + model_id=model_id, + info=self.models[model_id], + publish=publish, + capture_attachments=capture_attachments, ) self.handles[model_id] = handle return handle, True diff --git a/wildedge/paths.py b/wildedge/paths.py index de272e8..8226311 100644 --- a/wildedge/paths.py +++ b/wildedge/paths.py @@ -32,3 +32,7 @@ def default_model_registry_path(namespace: str = "default") -> Path: def default_dead_letter_dir(namespace: str = "default") -> Path: return default_sdk_cache_dir() / normalize_namespace(namespace) / "dead_letters" + + +def default_attachment_dir(namespace: str = "default") -> Path: + return default_sdk_state_dir() / normalize_namespace(namespace) / "attachments" diff --git a/wildedge/platforms/macos.py b/wildedge/platforms/macos.py index bcbb71b..dc86852 100644 --- a/wildedge/platforms/macos.py +++ b/wildedge/platforms/macos.py @@ -6,6 +6,7 @@ from pathlib import Path from wildedge.platforms.base import Platform, debug_detection_failure +from wildedge.platforms.hardware import ThermalContext _libc: ctypes.CDLL | None = None @@ -17,8 +18,16 @@ def _get_libc() -> ctypes.CDLL: return _libc -_CF_STRING_ENCODING_UTF8 = 0x08000100 -_CF_NUMBER_SINT32_TYPE = 3 +CF_STRING_ENCODING_UTF8 = 0x08000100 +CF_NUMBER_SINT32_TYPE = 3 + +# NSProcessInfoThermalState values (0–3) +MACOS_THERMAL_STATES: dict[int, tuple[str, str]] = { + 0: ("nominal", "nominal"), + 1: ("fair", "fair"), + 2: ("serious", "serious"), + 3: ("critical", "critical"), +} def _sysctl_uint32(name: bytes) -> int | None: @@ -108,6 +117,52 @@ def gpu_accelerators(self) -> tuple[list[str], str | None]: def gpu_accelerator_for_offload(self) -> str: return "mps" if platform.machine() == "arm64" else "cpu" + def cpu_freq(self) -> tuple[int | None, int | None]: + """Read CPU frequency via sysctl. + + Current freq (hw.cpufrequency) is Intel-only — Apple Silicon does not expose + per-core clock speed through public sysctl, so cur_mhz is None on M-series. + + Max freq falls back to hw.perflevel0.cpufrequency_max (P-core cluster) when + hw.cpufrequency_max is absent, so max_mhz is populated on both architectures. + """ + cur = _sysctl_uint64(b"hw.cpufrequency") + cur_mhz = (cur // 1_000_000) if cur else None + + max_f = _sysctl_uint64(b"hw.cpufrequency_max") or _sysctl_uint64( + b"hw.perflevel0.cpufrequency_max" + ) + max_mhz = (max_f // 1_000_000) if max_f else None + + return cur_mhz, max_mhz + + def thermal(self) -> ThermalContext | None: + """Read thermal pressure via NSProcessInfo.thermalState (Objective-C runtime).""" + try: + objc = ctypes.cdll.LoadLibrary("/usr/lib/libobjc.A.dylib") + objc.objc_getClass.restype = ctypes.c_void_p + objc.sel_registerName.restype = ctypes.c_void_p + objc.objc_msgSend.restype = ctypes.c_void_p + objc.objc_msgSend.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + + ns_process_info_cls = objc.objc_getClass(b"NSProcessInfo") + info = objc.objc_msgSend( + ns_process_info_cls, objc.sel_registerName(b"processInfo") + ) + + # thermalState returns NSInteger — cast objc_msgSend to the right signature + addr = ctypes.cast(objc.objc_msgSend, ctypes.c_void_p).value + msg_int = ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_void_p)( + addr + ) + level = int(msg_int(info, objc.sel_registerName(b"thermalState"))) + + state, state_raw = MACOS_THERMAL_STATES.get(level, ("nominal", "nominal")) + return ThermalContext(state=state, state_raw=state_raw) + except Exception as exc: + debug_detection_failure("macos thermal", exc) + return None + def battery(self) -> tuple[float | None, bool | None]: """Read battery level and charging state via IOKit AppleSmartBattery.""" try: @@ -163,7 +218,7 @@ def battery(self) -> tuple[float | None, bool | None]: def cfstr(s: bytes) -> ctypes.c_void_p: return ctypes.c_void_p( - cf.CFStringCreateWithCString(None, s, _CF_STRING_ENCODING_UTF8) + cf.CFStringCreateWithCString(None, s, CF_STRING_ENCODING_UTF8) ) cap_key = cfstr(b"CurrentCapacity") @@ -179,10 +234,10 @@ def cfstr(s: bytes) -> ctypes.c_void_p: cur = ctypes.c_int32(0) mx = ctypes.c_int32(0) cf.CFNumberGetValue( - ctypes.c_void_p(cap_ref), _CF_NUMBER_SINT32_TYPE, ctypes.byref(cur) + ctypes.c_void_p(cap_ref), CF_NUMBER_SINT32_TYPE, ctypes.byref(cur) ) cf.CFNumberGetValue( - ctypes.c_void_p(max_ref), _CF_NUMBER_SINT32_TYPE, ctypes.byref(mx) + ctypes.c_void_p(max_ref), CF_NUMBER_SINT32_TYPE, ctypes.byref(mx) ) if mx.value > 0: level = cur.value / mx.value diff --git a/wildedge/settings.py b/wildedge/settings.py index a01b88c..138832d 100644 --- a/wildedge/settings.py +++ b/wildedge/settings.py @@ -14,6 +14,7 @@ class ClientEnv: dsn: str | None debug: bool app_identity: str | None + attachments_enabled: bool | None @dataclass(frozen=True) @@ -79,10 +80,13 @@ def read_client_env( debug if debug is not None else parse_bool(env.get(constants.ENV_DEBUG)) ) resolved_identity = app_identity or env.get(constants.ENV_APP_IDENTITY) + raw_attachments = env.get(constants.ENV_ATTACHMENTS_ENABLED) + attachments_enabled = parse_bool(raw_attachments) if raw_attachments else None return ClientEnv( dsn=resolved_dsn, debug=resolved_debug, app_identity=resolved_identity, + attachments_enabled=attachments_enabled, )