diff --git a/README.md b/README.md index 6cb6da5..61d5d88 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ This workspace contains a Python-first eBay REST client foundation with: Currently wired API domains include Notification, Inventory, Fulfillment, Account, Feed, and Media. +## Media Helpers + +The Media wrapper includes workflow helpers on top of the raw endpoints: + +- `extract_resource_id()` to pull a media resource ID from a `Location` header +- `wait_for_video()` to poll until a video reaches `LIVE` or a terminal failure state +- `wait_for_document()` to poll until a document reaches `ACCEPTED` or a terminal failure state + ## Generate Low-Level Clients The project uses a dedicated code generation environment because the main runtime is currently on Python 3.14 while the model generator still targets earlier Python versions. diff --git a/ebay_client/media/__init__.py b/ebay_client/media/__init__.py index 4ac5518..1288b1e 100644 --- a/ebay_client/media/__init__.py +++ b/ebay_client/media/__init__.py @@ -1,3 +1,3 @@ -from ebay_client.media.client import CreatedMediaResource, MediaClient +from ebay_client.media.client import CreatedMediaResource, MediaClient, extract_resource_id -__all__ = ["CreatedMediaResource", "MediaClient"] \ No newline at end of file +__all__ = ["CreatedMediaResource", "MediaClient", "extract_resource_id"] \ No newline at end of file diff --git a/ebay_client/media/client.py b/ebay_client/media/client.py index 4ed70cb..636df99 100644 --- a/ebay_client/media/client.py +++ b/ebay_client/media/client.py @@ -1,5 +1,8 @@ from __future__ import annotations +from time import monotonic, sleep +from urllib.parse import urlparse + from pydantic import BaseModel from ebay_client.core.http.transport import ApiTransport @@ -19,6 +22,19 @@ MEDIA_SCOPE = "https://api.ebay.com/oauth/api_scope/sell.inventory" class CreatedMediaResource(BaseModel): location: str | None = None + resource_id: str | None = None + + +def extract_resource_id(location: str | None) -> str | None: + if not location: + return None + + path = urlparse(location).path.rstrip("/") + if not path: + return None + + _, _, resource_id = path.rpartition("/") + return resource_id or None class MediaClient: @@ -66,7 +82,8 @@ class MediaClient: headers={"Content-Type": "application/json"}, json_body=payload.model_dump(by_alias=True, exclude_none=True), ) - return CreatedMediaResource(location=response.headers.get("Location")) + location = response.headers.get("Location") + return CreatedMediaResource(location=location, resource_id=extract_resource_id(location)) def get_video(self, video_id: str) -> Video: return self.transport.request_model( @@ -140,4 +157,69 @@ class MediaClient: f"/commerce/media/v1_beta/document/{document_id}/upload", scopes=[MEDIA_SCOPE], files={"file": (file_name, content, content_type)}, - ) \ No newline at end of file + ) + + def wait_for_video( + self, + video_id: str, + *, + success_statuses: set[str] | None = None, + failure_statuses: set[str] | None = None, + timeout_seconds: float = 30.0, + poll_interval_seconds: float = 1.0, + ) -> Video: + desired_statuses = success_statuses or {"LIVE"} + terminal_failures = failure_statuses or {"BLOCKED", "PROCESSING_FAILED"} + return self._wait_for_media_state( + fetch=lambda: self.get_video(video_id), + get_status=lambda payload: payload.status, + desired_statuses=desired_statuses, + terminal_failures=terminal_failures, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + resource_label=f"video {video_id}", + ) + + def wait_for_document( + self, + document_id: str, + *, + success_statuses: set[str] | None = None, + failure_statuses: set[str] | None = None, + timeout_seconds: float = 30.0, + poll_interval_seconds: float = 1.0, + ) -> DocumentResponse: + desired_statuses = success_statuses or {"ACCEPTED"} + terminal_failures = failure_statuses or {"REJECTED"} + return self._wait_for_media_state( + fetch=lambda: self.get_document(document_id), + get_status=lambda payload: payload.documentStatus, + desired_statuses=desired_statuses, + terminal_failures=terminal_failures, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + resource_label=f"document {document_id}", + ) + + def _wait_for_media_state( + self, + *, + fetch, + get_status, + desired_statuses: set[str], + terminal_failures: set[str], + timeout_seconds: float, + poll_interval_seconds: float, + resource_label: str, + ): + deadline = monotonic() + timeout_seconds + while True: + payload = fetch() + status = get_status(payload) + if status in desired_statuses: + return payload + if status in terminal_failures: + raise ValueError(f"{resource_label} reached terminal failure status: {status}") + if monotonic() >= deadline: + raise TimeoutError(f"Timed out while waiting for {resource_label}; last status was {status!r}") + sleep(poll_interval_seconds) \ No newline at end of file diff --git a/tests/test_public_wrappers.py b/tests/test_public_wrappers.py index 9e4b0f7..da9a5df 100644 --- a/tests/test_public_wrappers.py +++ b/tests/test_public_wrappers.py @@ -35,7 +35,7 @@ from ebay_client.generated.notification.models import ( UpdateSubscriptionRequest, ) from ebay_client.inventory.client import InventoryClient -from ebay_client.media.client import CreatedMediaResource, MediaClient +from ebay_client.media.client import CreatedMediaResource, MediaClient, extract_resource_id from ebay_client.notification.client import NotificationClient @@ -430,6 +430,13 @@ def test_media_wrapper_returns_created_resource_location_for_video(httpx_mock: H assert isinstance(result, CreatedMediaResource) assert result.location == "https://api.ebay.com/commerce/media/v1_beta/video/VIDEO-1" + assert result.resource_id == "VIDEO-1" + + +def test_extract_media_resource_id_handles_location_header() -> None: + assert extract_resource_id("https://api.ebay.com/commerce/media/v1_beta/video/VIDEO-1") == "VIDEO-1" + assert extract_resource_id("https://api.ebay.com/commerce/media/v1_beta/document/DOC-1/") == "DOC-1" + assert extract_resource_id(None) is None def test_media_wrapper_returns_video_model(httpx_mock: HTTPXMock) -> None: @@ -520,4 +527,39 @@ def test_media_wrapper_returns_document_models(httpx_mock: HTTPXMock) -> None: upload_request = httpx_mock.get_requests()[3] assert upload_request.headers["Content-Type"].startswith("multipart/form-data;") assert b"filename=\"guide.pdf\"" in upload_request.content - assert b"%PDF-1.7" in upload_request.content \ No newline at end of file + assert b"%PDF-1.7" in upload_request.content + + +def test_media_wait_for_video_returns_live_payload(monkeypatch) -> None: + client = MediaClient(build_transport()) + states = iter( + [ + Video(videoId="VIDEO-1", status="PENDING_UPLOAD"), + Video(videoId="VIDEO-1", status="PROCESSING"), + Video(videoId="VIDEO-1", status="LIVE"), + ] + ) + + monkeypatch.setattr(client, "get_video", lambda _video_id: next(states)) + monkeypatch.setattr("ebay_client.media.client.sleep", lambda _seconds: None) + + result = client.wait_for_video("VIDEO-1", poll_interval_seconds=0.0) + + assert result.status == "LIVE" + + +def test_media_wait_for_document_raises_on_terminal_failure(monkeypatch) -> None: + client = MediaClient(build_transport()) + + monkeypatch.setattr( + client, + "get_document", + lambda _document_id: DocumentResponse(documentId="DOC-1", documentStatus="REJECTED"), + ) + + try: + client.wait_for_document("DOC-1", poll_interval_seconds=0.0) + except ValueError as exc: + assert "REJECTED" in str(exc) + else: + raise AssertionError("Expected wait_for_document to raise on terminal failure status") \ No newline at end of file