diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 3fa0482..7d2a9fa 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -115,6 +115,20 @@ Use the generated model packages for request and response types: This section maps typical seller workflows to wrapper methods. +Quick source jumps: + +- facade: [ebay_client/client.py](../ebay_client/client.py) +- notification wrapper: [ebay_client/notification/client.py](../ebay_client/notification/client.py) +- inventory wrapper: [ebay_client/inventory/client.py](../ebay_client/inventory/client.py) +- fulfillment wrapper: [ebay_client/fulfillment/client.py](../ebay_client/fulfillment/client.py) +- account wrapper: [ebay_client/account/client.py](../ebay_client/account/client.py) +- feed wrapper: [ebay_client/feed/client.py](../ebay_client/feed/client.py) +- media wrapper: [ebay_client/media/client.py](../ebay_client/media/client.py) +- FastAPI webhook example: [examples/fastapi_notification_webhook.py](../examples/fastapi_notification_webhook.py) +- media workflows example: [examples/media_workflows.py](../examples/media_workflows.py) +- inventory publish example: [examples/inventory_publish_flow.py](../examples/inventory_publish_flow.py) +- feed task example: [examples/feed_task_flow.py](../examples/feed_task_flow.py) + ### Notification: manage webhook subscriptions Typical flow: @@ -143,7 +157,7 @@ Recommended server flow: 3. verify the POST body with `WebhookSignatureValidator` 4. dispatch the parsed `WebhookEventEnvelope` -Reference implementation: `examples/fastapi_notification_webhook.py`. +Reference implementation: [examples/fastapi_notification_webhook.py](../examples/fastapi_notification_webhook.py). ### Inventory: read listings and inventory records @@ -305,9 +319,157 @@ Helpers: - `VideoWorkflowResult` - `DocumentWorkflowResult` -Reference implementation: `examples/media_workflows.py`. +Reference implementation: [examples/media_workflows.py](../examples/media_workflows.py). -## 5. Working With Generated Models +## 5. End-to-End Recipes + +These are the shortest practical paths through the client for the most common seller tasks. + +### Recipe: read existing listed inventory data + +Use this when you mainly want to inspect what is already listed or staged in eBay. + +```python +from ebay_client import EbayClient +from ebay_client.core.auth.models import EbayOAuthConfig + +client = EbayClient( + EbayOAuthConfig( + client_id="your-client-id", + client_secret="your-client-secret", + default_scopes=["https://api.ebay.com/oauth/api_scope/sell.inventory.readonly"], + ) +) + +items = client.inventory.get_inventory_items(limit=50) +offers = client.inventory.get_offers(limit=50) +``` + +Methods involved: + +- `get_inventory_items()` +- `get_offers()` +- optionally `get_inventory_item()` and `get_offer()` for detail reads + +### Recipe: create an inventory item, create an offer, and publish it + +Use this when you want the standard listing flow through the Inventory API. + +```python +from ebay_client import EbayClient +from ebay_client.core.auth.models import EbayOAuthConfig +from ebay_client.generated.inventory.models import InventoryItem, OfferDetailsWithKeys + +client = EbayClient( + EbayOAuthConfig( + client_id="your-client-id", + client_secret="your-client-secret", + default_scopes=["https://api.ebay.com/oauth/api_scope/sell.inventory"], + ) +) + +client.inventory.create_or_replace_inventory_item( + "SKU-123", + InventoryItem(), + content_language="en-US", +) + +offer = client.inventory.create_offer(OfferDetailsWithKeys()) +client.inventory.publish_offer(offer.offerId) +``` + +Operational notes: + +- you typically need business policy IDs and location data in place before the offer is valid +- the wrapper requires `content_language` for the item write call because eBay expects it on several write endpoints +- the exact request model fields depend on your marketplace and listing format + +### Recipe: receive Notification webhook events in FastAPI + +Use the built-in webhook helpers instead of hand-parsing the signature format. + +Flow: + +1. create a normal `EbayClient` +2. build `WebhookPublicKeyResolver` from `client.notification.get_public_key` +3. build `WebhookSignatureValidator` +4. use `WebhookRequestHandler` for both GET challenge and POST notification handling + +Working example: [examples/fastapi_notification_webhook.py](../examples/fastapi_notification_webhook.py). + +### Recipe: upload a document or video asset for listings + +Use the Media wrapper helpers when you want the client to handle the create, upload, and polling loop for you. + +```python +from pathlib import Path + +from ebay_client import EbayClient +from ebay_client.core.auth.models import EbayOAuthConfig +from ebay_client.generated.media.models import CreateDocumentRequest + +client = EbayClient( + EbayOAuthConfig( + client_id="your-client-id", + client_secret="your-client-secret", + default_scopes=["https://api.ebay.com/oauth/api_scope/sell.inventory"], + ) +) + +result = client.media.create_upload_and_wait_document_from_path( + CreateDocumentRequest( + documentType="USER_GUIDE_OR_MANUAL", + languages=["en-US"], + ), + Path("./manual.pdf"), + timeout_seconds=60.0, +) + +print(result.document_id) +print(result.document.documentStatus) +``` + +Working example: [examples/media_workflows.py](../examples/media_workflows.py). + +### Recipe: create a Feed task, upload its file, and download the result + +Use this when the operation is file-based and asynchronous. + +Flow: + +1. create the task with `create_task()` +2. extract the returned `resource_id` +3. upload the task input file with `upload_file()` +4. poll `get_task()` until the task reaches the state you expect +5. download the result with `get_result_file()` + +This wrapper already provides typed helpers for the file downloads through `FeedFileDownload`. + +Working example: [examples/feed_task_flow.py](../examples/feed_task_flow.py). + +### Recipe: dispute handling and evidence upload + +Use the Fulfillment dispute methods only when you have the dispute-specific scope. + +Flow: + +1. read the dispute with `get_payment_dispute()` +2. upload the binary evidence file with `upload_evidence_file()` +3. attach or update the evidence metadata with `add_evidence()` or `update_evidence()` +4. contest or accept with `contest_payment_dispute()` or `accept_payment_dispute()` + +Caveat: these calls go through the alternate `apiz.ebay.com` fulfillment base URL inside the wrapper. + +### Recipe: use the included examples directly + +The repository now includes these runnable example scripts: + +- [examples/inventory_publish_flow.py](../examples/inventory_publish_flow.py): create or replace an inventory item, create an offer, and publish it +- [examples/feed_task_flow.py](../examples/feed_task_flow.py): create a feed task, upload an input file, poll its status, and optionally save the result file +- [examples/media_workflows.py](../examples/media_workflows.py): image, document, and video asset workflows +- [examples/fastapi_notification_webhook.py](../examples/fastapi_notification_webhook.py): FastAPI-based challenge and notification handling + +## 6. Working With Generated Models The wrappers are intentionally thin. Request payloads and response types come from the generated Pydantic model packages. @@ -337,14 +499,14 @@ result = client.media.create_upload_and_wait_document_from_path( For any wrapper method that takes a payload, look up the corresponding generated request model in the matching `ebay_client.generated..models` package. -## 6. Operational Notes +## 7. Operational Notes - The default token store is process-local and in-memory only. - Empty `202` and `204` responses are normalized by the shared transport so wrapper methods can return `None` cleanly for no-body success cases. - Multipart uploads are already handled by the transport and wrappers for Media, Feed, and dispute evidence uploads. - Absolute URL handling is already built into the transport so wrappers can call alternate eBay hosts when required. -## 7. Validation and Regeneration +## 8. Validation and Regeneration Run tests: @@ -364,4 +526,4 @@ Generate just one API package: & .\.venv\Scripts\python.exe .\scripts\generate_clients.py --api media ``` -The generated models are written to `ebay_client/generated//models.py`. \ No newline at end of file +The generated models are written to `ebay_client/generated//models.py`. diff --git a/examples/feed_task_flow.py b/examples/feed_task_flow.py new file mode 100644 index 0000000..63d3335 --- /dev/null +++ b/examples/feed_task_flow.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import os +from pathlib import Path +from time import monotonic, sleep + +from ebay_client import EbayClient +from ebay_client.core.auth.models import EbayOAuthConfig +from ebay_client.generated.feed.models import CreateTaskRequest + + +TERMINAL_TASK_STATUSES = { + "COMPLETED", + "COMPLETED_WITH_ERROR", + "FAILED", + "CANCELLED", +} + + +def require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"Environment variable {name} is required") + return value + + +def build_client() -> EbayClient: + oauth_config = EbayOAuthConfig( + client_id=require_env("EBAY_CLIENT_ID"), + client_secret=require_env("EBAY_CLIENT_SECRET"), + default_scopes=[os.environ.get("EBAY_FEED_SCOPE", "https://api.ebay.com/oauth/api_scope/sell.fulfillment")], + ) + return EbayClient(oauth_config) + + +def wait_for_task(client: EbayClient, task_id: str, *, timeout_seconds: float = 120.0, poll_interval_seconds: float = 3.0): + deadline = monotonic() + timeout_seconds + last_status: str | None = None + + while monotonic() < deadline: + task = client.feed.get_task(task_id) + last_status = task.status + print("task_status:", last_status) + if last_status in TERMINAL_TASK_STATUSES: + return task + sleep(poll_interval_seconds) + + raise TimeoutError(f"Feed task {task_id} did not complete within {timeout_seconds} seconds; last status was {last_status}") + + +def main() -> None: + client = build_client() + marketplace_id = os.environ.get("EBAY_MARKETPLACE_ID", "EBAY_US") + feed_type = os.environ.get("EBAY_FEED_TYPE", "LMS_ORDER_REPORT") + schema_version = os.environ.get("EBAY_FEED_SCHEMA_VERSION", "1.0") + upload_path = Path(require_env("EBAY_FEED_UPLOAD_FILE")) + result_output_path = os.environ.get("EBAY_FEED_RESULT_OUTPUT") + + created = client.feed.create_task( + CreateTaskRequest(feedType=feed_type, schemaVersion=schema_version), + marketplace_id=marketplace_id, + ) + print("task_id:", created.resource_id) + + task_id = created.resource_id + if not task_id: + raise RuntimeError("create_task did not return a task id") + + client.feed.upload_file( + task_id, + file_name=upload_path.name, + content=upload_path.read_bytes(), + content_type=os.environ.get("EBAY_FEED_UPLOAD_CONTENT_TYPE", "application/xml"), + ) + print("uploaded_file:", upload_path.name) + + task = wait_for_task( + client, + task_id, + timeout_seconds=float(os.environ.get("EBAY_FEED_TIMEOUT_SECONDS", "120")), + poll_interval_seconds=float(os.environ.get("EBAY_FEED_POLL_INTERVAL_SECONDS", "3")), + ) + print("final_task_status:", task.status) + + if task.status in {"COMPLETED", "COMPLETED_WITH_ERROR"}: + result_file = client.feed.get_result_file(task_id) + print("result_file_name:", result_file.file_name) + print("result_file_bytes:", len(result_file.content)) + if result_output_path: + Path(result_output_path).write_bytes(result_file.content) + print("result_saved_to:", result_output_path) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/inventory_publish_flow.py b/examples/inventory_publish_flow.py new file mode 100644 index 0000000..6aaab27 --- /dev/null +++ b/examples/inventory_publish_flow.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import os + +from ebay_client import EbayClient +from ebay_client.core.auth.models import EbayOAuthConfig +from ebay_client.generated.inventory.models import ( + Amount, + Availability, + EbayOfferDetailsWithKeys, + InventoryItem, + ListingPolicies, + PricingSummary, + Product, + ShipToLocationAvailability, +) + + +def require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"Environment variable {name} is required") + return value + + +def build_client() -> EbayClient: + oauth_config = EbayOAuthConfig( + client_id=require_env("EBAY_CLIENT_ID"), + client_secret=require_env("EBAY_CLIENT_SECRET"), + default_scopes=["https://api.ebay.com/oauth/api_scope/sell.inventory"], + ) + return EbayClient(oauth_config) + + +def build_inventory_item() -> InventoryItem: + return InventoryItem( + condition="NEW", + availability=Availability( + shipToLocationAvailability=ShipToLocationAvailability(quantity=int(os.environ.get("EBAY_AVAILABLE_QUANTITY", "1"))) + ), + product=Product( + title=require_env("EBAY_ITEM_TITLE"), + description=require_env("EBAY_ITEM_DESCRIPTION"), + imageUrls=[require_env("EBAY_ITEM_IMAGE_URL")], + aspects='{"Brand":["Demo Brand"],"Type":["Demo Item"]}', + ), + ) + + +def build_offer_payload(sku: str) -> EbayOfferDetailsWithKeys: + return EbayOfferDetailsWithKeys( + sku=sku, + marketplaceId=os.environ.get("EBAY_MARKETPLACE_ID", "EBAY_US"), + format="FIXED_PRICE", + availableQuantity=int(os.environ.get("EBAY_AVAILABLE_QUANTITY", "1")), + categoryId=require_env("EBAY_CATEGORY_ID"), + merchantLocationKey=require_env("EBAY_MERCHANT_LOCATION_KEY"), + listingDescription=os.environ.get("EBAY_LISTING_DESCRIPTION") or require_env("EBAY_ITEM_DESCRIPTION"), + listingDuration="GTC", + listingPolicies=ListingPolicies( + fulfillmentPolicyId=require_env("EBAY_FULFILLMENT_POLICY_ID"), + paymentPolicyId=require_env("EBAY_PAYMENT_POLICY_ID"), + returnPolicyId=require_env("EBAY_RETURN_POLICY_ID"), + ), + pricingSummary=PricingSummary( + price=Amount( + currency=os.environ.get("EBAY_CURRENCY", "USD"), + value=require_env("EBAY_PRICE"), + ) + ), + ) + + +def main() -> None: + client = build_client() + sku = require_env("EBAY_SKU") + content_language = os.environ.get("EBAY_CONTENT_LANGUAGE", "en-US") + + item_result = client.inventory.create_or_replace_inventory_item( + sku, + build_inventory_item(), + content_language=content_language, + ) + print("inventory_item_warnings:", item_result.warnings) + + offer = client.inventory.create_offer( + build_offer_payload(sku), + content_language=content_language, + ) + print("offer_id:", offer.offerId) + + if not offer.offerId: + raise RuntimeError("create_offer did not return an offerId") + + published = client.inventory.publish_offer(offer.offerId) + print("listing_id:", published.listingId) + + +if __name__ == "__main__": + main() \ No newline at end of file