From 937cd86c8b8e7d9e4117fe6165ab52e8f8e6329e Mon Sep 17 00:00:00 2001 From: claudi Date: Tue, 7 Apr 2026 10:29:42 +0200 Subject: [PATCH] Add media upload convenience methods and example workflows for images, documents, and videos --- README.md | 10 +++ ebay_client/media/client.py | 71 ++++++++++++++- examples/media_workflows.py | 86 ++++++++++++++++++ tests/test_public_wrappers.py | 160 +++++++++++++++++++++++++++++++++- 4 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 examples/media_workflows.py diff --git a/README.md b/README.md index 61d5d88..30a6d75 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ 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 +- `create_upload_and_wait_video()` to stage, upload, and poll a video in one call +- `create_upload_and_wait_document()` to stage, upload, and poll a document in one call +- `create_document_from_url_and_wait()` to create a document from a URL and poll until it is accepted + +A concrete workflow example is available in `examples/media_workflows.py` for: + +- uploading an image from a file +- creating an image from a URL +- staging, uploading, and polling a document +- staging, uploading, and polling a video ## Generate Low-Level Clients diff --git a/ebay_client/media/client.py b/ebay_client/media/client.py index 636df99..1d82810 100644 --- a/ebay_client/media/client.py +++ b/ebay_client/media/client.py @@ -85,6 +85,30 @@ class MediaClient: location = response.headers.get("Location") return CreatedMediaResource(location=location, resource_id=extract_resource_id(location)) + def create_upload_and_wait_video( + self, + payload: CreateVideoRequest, + *, + content: bytes, + content_length: int | None = None, + content_range: str | None = None, + timeout_seconds: float = 30.0, + poll_interval_seconds: float = 1.0, + ) -> Video: + created = self.create_video(payload) + video_id = self._require_resource_id(created.resource_id, "video resource ID") + self.upload_video( + video_id, + content=content, + content_length=content_length if content_length is not None else len(content), + content_range=content_range, + ) + return self.wait_for_video( + video_id, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + def get_video(self, video_id: str) -> Video: return self.transport.request_model( Video, @@ -125,6 +149,30 @@ class MediaClient: json_body=payload.model_dump(by_alias=True, exclude_none=True), ) + def create_upload_and_wait_document( + self, + payload: CreateDocumentRequest, + *, + file_name: str, + content: bytes, + content_type: str = "application/octet-stream", + timeout_seconds: float = 30.0, + poll_interval_seconds: float = 1.0, + ) -> DocumentResponse: + created = self.create_document(payload) + document_id = self._require_resource_id(created.documentId, "documentId") + self.upload_document( + document_id, + file_name=file_name, + content=content, + content_type=content_type, + ) + return self.wait_for_document( + document_id, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + def create_document_from_url(self, payload: CreateDocumentFromUrlRequest) -> CreateDocumentResponse: return self.transport.request_model( CreateDocumentResponse, @@ -135,6 +183,21 @@ class MediaClient: json_body=payload.model_dump(by_alias=True, exclude_none=True), ) + def create_document_from_url_and_wait( + self, + payload: CreateDocumentFromUrlRequest, + *, + timeout_seconds: float = 30.0, + poll_interval_seconds: float = 1.0, + ) -> DocumentResponse: + created = self.create_document_from_url(payload) + document_id = self._require_resource_id(created.documentId, "documentId") + return self.wait_for_document( + document_id, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + def get_document(self, document_id: str) -> DocumentResponse: return self.transport.request_model( DocumentResponse, @@ -222,4 +285,10 @@ class MediaClient: 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 + sleep(poll_interval_seconds) + + @staticmethod + def _require_resource_id(value: str | None, field_name: str) -> str: + if not value: + raise RuntimeError(f"eBay did not return a required {field_name}") + return value \ No newline at end of file diff --git a/examples/media_workflows.py b/examples/media_workflows.py new file mode 100644 index 0000000..777b4c2 --- /dev/null +++ b/examples/media_workflows.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from ebay_client.client import EbayClient +from ebay_client.core.auth.models import EbayOAuthConfig +from ebay_client.generated.media.models import ( + CreateDocumentRequest, + CreateImageFromUrlRequest, + CreateVideoRequest, +) + + +def build_client() -> EbayClient: + oauth_config = EbayOAuthConfig( + client_id=os.environ["EBAY_CLIENT_ID"], + client_secret=os.environ["EBAY_CLIENT_SECRET"], + default_scopes=["https://api.ebay.com/oauth/api_scope/sell.inventory"], + ) + return EbayClient(oauth_config) + + +def upload_image_from_file(client: EbayClient, image_path: Path) -> None: + image = client.media.create_image_from_file( + file_name=image_path.name, + content=image_path.read_bytes(), + content_type="image/jpeg", + ) + print("image_url:", image.imageUrl) + + +def upload_image_from_url(client: EbayClient, image_url: str) -> None: + image = client.media.create_image_from_url(CreateImageFromUrlRequest(imageUrl=image_url)) + print("image_url:", image.imageUrl) + + +def upload_document_and_wait(client: EbayClient, document_path: Path) -> None: + accepted = client.media.create_upload_and_wait_document( + CreateDocumentRequest( + documentType="USER_GUIDE_OR_MANUAL", + languages=["en-US"], + ), + file_name=document_path.name, + content=document_path.read_bytes(), + content_type="application/pdf", + timeout_seconds=60.0, + ) + print("document_final_status:", accepted.documentStatus) + + +def upload_video_and_wait(client: EbayClient, video_path: Path) -> None: + live_video = client.media.create_upload_and_wait_video( + CreateVideoRequest( + title=video_path.stem, + size=video_path.stat().st_size, + classification=["ITEM"], + description="Example upload from the ebay-rest-client workspace.", + ), + content=video_path.read_bytes(), + timeout_seconds=120.0, + ) + print("video_status:", live_video.status) + print("video_id:", live_video.videoId) + + +def main() -> None: + client = build_client() + + image_file = os.environ.get("EBAY_MEDIA_IMAGE_FILE") + image_url = os.environ.get("EBAY_MEDIA_IMAGE_URL") + document_file = os.environ.get("EBAY_MEDIA_DOCUMENT_FILE") + video_file = os.environ.get("EBAY_MEDIA_VIDEO_FILE") + + if image_file: + upload_image_from_file(client, Path(image_file)) + if image_url: + upload_image_from_url(client, image_url) + if document_file: + upload_document_and_wait(client, Path(document_file)) + if video_file: + upload_video_and_wait(client, Path(video_file)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_public_wrappers.py b/tests/test_public_wrappers.py index da9a5df..9dcaf85 100644 --- a/tests/test_public_wrappers.py +++ b/tests/test_public_wrappers.py @@ -16,6 +16,7 @@ from ebay_client.generated.inventory.models import InventoryItemWithSkuLocaleGro from ebay_client.generated.media.models import ( CreateDocumentFromUrlRequest, CreateDocumentRequest, + CreateDocumentResponse, CreateImageFromUrlRequest, CreateVideoRequest, DocumentResponse, @@ -562,4 +563,161 @@ def test_media_wait_for_document_raises_on_terminal_failure(monkeypatch) -> None 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 + raise AssertionError("Expected wait_for_document to raise on terminal failure status") + + +def test_media_create_upload_and_wait_video_orchestrates_flow(monkeypatch) -> None: + client = MediaClient(build_transport()) + calls: list[tuple[str, object]] = [] + + monkeypatch.setattr( + client, + "create_video", + lambda payload: calls.append(("create_video", payload)) or CreatedMediaResource(resource_id="VIDEO-9"), + ) + monkeypatch.setattr( + client, + "upload_video", + lambda video_id, **kwargs: calls.append(("upload_video", {"video_id": video_id, **kwargs})), + ) + monkeypatch.setattr( + client, + "wait_for_video", + lambda video_id, **kwargs: calls.append(("wait_for_video", {"video_id": video_id, **kwargs})) + or Video(videoId=video_id, status="LIVE"), + ) + + result = client.create_upload_and_wait_video( + CreateVideoRequest(title="Demo", size=4, classification=["ITEM"]), + content=b"demo", + poll_interval_seconds=0.0, + ) + + assert result.videoId == "VIDEO-9" + assert calls[0][0] == "create_video" + assert calls[1] == ( + "upload_video", + {"video_id": "VIDEO-9", "content": b"demo", "content_length": 4, "content_range": None}, + ) + assert calls[2] == ( + "wait_for_video", + {"video_id": "VIDEO-9", "timeout_seconds": 30.0, "poll_interval_seconds": 0.0}, + ) + + +def test_media_create_upload_and_wait_document_orchestrates_flow(monkeypatch) -> None: + client = MediaClient(build_transport()) + calls: list[tuple[str, object]] = [] + + monkeypatch.setattr( + client, + "create_document", + lambda payload: calls.append(("create_document", payload)) + or CreateDocumentResponse(documentId="DOC-9", documentStatus="PENDING_UPLOAD"), + ) + monkeypatch.setattr( + client, + "upload_document", + lambda document_id, **kwargs: calls.append(("upload_document", {"document_id": document_id, **kwargs})) + or DocumentResponse(documentId=document_id, documentStatus="SUBMITTED"), + ) + monkeypatch.setattr( + client, + "wait_for_document", + lambda document_id, **kwargs: calls.append(("wait_for_document", {"document_id": document_id, **kwargs})) + or DocumentResponse(documentId=document_id, documentStatus="ACCEPTED"), + ) + + result = client.create_upload_and_wait_document( + CreateDocumentRequest(documentType="USER_GUIDE_OR_MANUAL", languages=["en-US"]), + file_name="guide.pdf", + content=b"%PDF-1.7", + content_type="application/pdf", + poll_interval_seconds=0.0, + ) + + assert result.documentStatus == "ACCEPTED" + assert calls[0][0] == "create_document" + assert calls[1] == ( + "upload_document", + { + "document_id": "DOC-9", + "file_name": "guide.pdf", + "content": b"%PDF-1.7", + "content_type": "application/pdf", + }, + ) + assert calls[2] == ( + "wait_for_document", + {"document_id": "DOC-9", "timeout_seconds": 30.0, "poll_interval_seconds": 0.0}, + ) + + +def test_media_create_document_from_url_and_wait_orchestrates_flow(monkeypatch) -> None: + client = MediaClient(build_transport()) + calls: list[tuple[str, object]] = [] + + monkeypatch.setattr( + client, + "create_document_from_url", + lambda payload: calls.append(("create_document_from_url", payload)) + or CreateDocumentResponse(documentId="DOC-10", documentStatus="SUBMITTED"), + ) + monkeypatch.setattr( + client, + "wait_for_document", + lambda document_id, **kwargs: calls.append(("wait_for_document", {"document_id": document_id, **kwargs})) + or DocumentResponse(documentId=document_id, documentStatus="ACCEPTED"), + ) + + result = client.create_document_from_url_and_wait( + CreateDocumentFromUrlRequest( + documentType="USER_GUIDE_OR_MANUAL", + documentUrl="https://example.test/guide.pdf", + languages=["en-US"], + ), + poll_interval_seconds=0.0, + ) + + assert result.documentStatus == "ACCEPTED" + assert calls[0][0] == "create_document_from_url" + assert calls[1] == ( + "wait_for_document", + {"document_id": "DOC-10", "timeout_seconds": 30.0, "poll_interval_seconds": 0.0}, + ) + + +def test_media_convenience_methods_raise_when_required_ids_are_missing(monkeypatch) -> None: + client = MediaClient(build_transport()) + + monkeypatch.setattr(client, "create_video", lambda payload: CreatedMediaResource(resource_id=None)) + monkeypatch.setattr(client, "create_document", lambda payload: CreateDocumentResponse(documentId=None)) + monkeypatch.setattr(client, "create_document_from_url", lambda payload: CreateDocumentResponse(documentId=None)) + + for action in ( + lambda: client.create_upload_and_wait_video( + CreateVideoRequest(title="Demo", size=4, classification=["ITEM"]), + content=b"demo", + poll_interval_seconds=0.0, + ), + lambda: client.create_upload_and_wait_document( + CreateDocumentRequest(documentType="USER_GUIDE_OR_MANUAL", languages=["en-US"]), + file_name="guide.pdf", + content=b"%PDF-1.7", + poll_interval_seconds=0.0, + ), + lambda: client.create_document_from_url_and_wait( + CreateDocumentFromUrlRequest( + documentType="USER_GUIDE_OR_MANUAL", + documentUrl="https://example.test/guide.pdf", + languages=["en-US"], + ), + poll_interval_seconds=0.0, + ), + ): + try: + action() + except RuntimeError: + pass + else: + raise AssertionError("Expected convenience method to raise when eBay omits the required identifier") \ No newline at end of file