From 9592f389023779a648ce3f108e2e024185bffd03 Mon Sep 17 00:00:00 2001 From: claudi Date: Tue, 7 Apr 2026 10:53:30 +0200 Subject: [PATCH] Add CRUD operations for schedules and tasks in FeedClient; enhance tests for schedule and task management --- ebay_client/feed/client.py | 184 +++++++++++++++++++++++++++++++++- tests/test_public_wrappers.py | 161 ++++++++++++++++++++++++++++- 2 files changed, 340 insertions(+), 5 deletions(-) diff --git a/ebay_client/feed/client.py b/ebay_client/feed/client.py index 2ed0db6..7667ac0 100644 --- a/ebay_client/feed/client.py +++ b/ebay_client/feed/client.py @@ -1,11 +1,20 @@ from __future__ import annotations +from urllib.parse import urlparse + +from pydantic import BaseModel + from ebay_client.core.http.transport import ApiTransport from ebay_client.generated.feed.models import ( + CreateTaskRequest, + CreateUserScheduleRequest, + ScheduleTemplateResponse, ScheduleTemplateCollection, Task, TaskCollection, + UserScheduleResponse, UserScheduleCollection, + UpdateUserScheduleRequest, ) FEED_INVENTORY_SCOPE = "https://api.ebay.com/oauth/api_scope/sell.inventory" @@ -22,19 +31,96 @@ FEED_READ_SCOPE_OPTIONS = [ ] +class CreatedFeedResource(BaseModel): + location: str | None = None + resource_id: str | None = None + + +class FeedFileDownload(BaseModel): + content: bytes + content_disposition: str | None = None + file_name: str | None = None + + +def extract_feed_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 + + +def _extract_file_name(content_disposition: str | None) -> str | None: + if not content_disposition: + return None + + marker = "filename=" + if marker not in content_disposition: + return None + + value = content_disposition.split(marker, 1)[1].strip() + if value.startswith('"') and value.endswith('"'): + return value[1:-1] + return value + + class FeedClient: def __init__(self, transport: ApiTransport) -> None: self.transport = transport - def get_tasks(self, *, feed_type: str | None = None) -> TaskCollection: + def get_tasks( + self, + *, + feed_type: str | None = None, + schedule_id: str | None = None, + date_range: str | None = None, + look_back_days: int | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> TaskCollection: return self.transport.request_model( TaskCollection, "GET", "/sell/feed/v1/task", scope_options=FEED_READ_SCOPE_OPTIONS, - params={"feed_type": feed_type}, + params={ + "feed_type": feed_type, + "schedule_id": schedule_id, + "date_range": date_range, + "look_back_days": look_back_days, + "limit": limit, + "offset": offset, + }, ) + def create_task( + self, + payload: CreateTaskRequest, + *, + marketplace_id: str, + accept_language: str | None = None, + ) -> CreatedFeedResource: + headers = { + "Content-Type": "application/json", + "X-EBAY-C-MARKETPLACE-ID": marketplace_id, + } + if accept_language is not None: + headers["Accept-Language"] = accept_language + + response = self.transport.request( + "POST", + "/sell/feed/v1/task", + scope_options=FEED_READ_SCOPE_OPTIONS, + headers=headers, + json_body=payload.model_dump(by_alias=True, exclude_none=True), + ) + location = response.headers.get("Location") + return CreatedFeedResource(location=location, resource_id=extract_feed_resource_id(location)) + def get_task(self, task_id: str) -> Task: return self.transport.request_model( Task, @@ -43,18 +129,108 @@ class FeedClient: scope_options=FEED_READ_SCOPE_OPTIONS, ) - def get_schedule_templates(self) -> ScheduleTemplateCollection: + def get_input_file(self, task_id: str) -> FeedFileDownload: + return self._download_file(f"/sell/feed/v1/task/{task_id}/download_input_file") + + def get_result_file(self, task_id: str) -> FeedFileDownload: + return self._download_file(f"/sell/feed/v1/task/{task_id}/download_result_file") + + def upload_file( + self, + task_id: str, + *, + file_name: str, + content: bytes, + content_type: str = "application/octet-stream", + ) -> None: + self.transport.request_json( + "POST", + f"/sell/feed/v1/task/{task_id}/upload_file", + scope_options=FEED_READ_SCOPE_OPTIONS, + files={"file": (file_name, content, content_type)}, + ) + + def get_schedule_templates( + self, + *, + feed_type: str, + limit: int | None = None, + offset: int | None = None, + ) -> ScheduleTemplateCollection: return self.transport.request_model( ScheduleTemplateCollection, "GET", "/sell/feed/v1/schedule_template", scope_options=FEED_READ_SCOPE_OPTIONS, + params={"feed_type": feed_type, "limit": limit, "offset": offset}, ) - def get_schedules(self) -> UserScheduleCollection: + def get_schedule_template(self, schedule_template_id: str) -> ScheduleTemplateResponse: + return self.transport.request_model( + ScheduleTemplateResponse, + "GET", + f"/sell/feed/v1/schedule_template/{schedule_template_id}", + scope_options=FEED_READ_SCOPE_OPTIONS, + ) + + def get_schedules( + self, + *, + feed_type: str, + limit: int | None = None, + offset: int | None = None, + ) -> UserScheduleCollection: return self.transport.request_model( UserScheduleCollection, "GET", "/sell/feed/v1/schedule", scope_options=FEED_READ_SCOPE_OPTIONS, + params={"feed_type": feed_type, "limit": limit, "offset": offset}, + ) + + def create_schedule(self, payload: CreateUserScheduleRequest) -> CreatedFeedResource: + response = self.transport.request( + "POST", + "/sell/feed/v1/schedule", + scope_options=FEED_READ_SCOPE_OPTIONS, + headers={"Content-Type": "application/json"}, + json_body=payload.model_dump(by_alias=True, exclude_none=True), + ) + location = response.headers.get("Location") + return CreatedFeedResource(location=location, resource_id=extract_feed_resource_id(location)) + + def get_schedule(self, schedule_id: str) -> UserScheduleResponse: + return self.transport.request_model( + UserScheduleResponse, + "GET", + f"/sell/feed/v1/schedule/{schedule_id}", + scope_options=FEED_READ_SCOPE_OPTIONS, + ) + + def update_schedule(self, schedule_id: str, payload: UpdateUserScheduleRequest) -> None: + self.transport.request_json( + "PUT", + f"/sell/feed/v1/schedule/{schedule_id}", + scope_options=FEED_READ_SCOPE_OPTIONS, + headers={"Content-Type": "application/json"}, + json_body=payload.model_dump(by_alias=True, exclude_none=True), + ) + + def delete_schedule(self, schedule_id: str) -> None: + self.transport.request_json( + "DELETE", + f"/sell/feed/v1/schedule/{schedule_id}", + scope_options=FEED_READ_SCOPE_OPTIONS, + ) + + def get_latest_result_file(self, schedule_id: str) -> FeedFileDownload: + return self._download_file(f"/sell/feed/v1/schedule/{schedule_id}/download_result_file") + + def _download_file(self, path: str) -> FeedFileDownload: + response = self.transport.request("GET", path, scope_options=FEED_READ_SCOPE_OPTIONS) + content_disposition = response.headers.get("content-disposition") + return FeedFileDownload( + content=response.content, + content_disposition=content_disposition, + file_name=_extract_file_name(content_disposition), ) diff --git a/tests/test_public_wrappers.py b/tests/test_public_wrappers.py index e256366..6aec0a0 100644 --- a/tests/test_public_wrappers.py +++ b/tests/test_public_wrappers.py @@ -7,7 +7,7 @@ from pytest_httpx import HTTPXMock from ebay_client.account.client import AccountClient from ebay_client.core.auth.models import OAuthToken from ebay_client.core.http.transport import ApiTransport -from ebay_client.feed.client import FeedClient +from ebay_client.feed.client import CreatedFeedResource, FeedClient, FeedFileDownload from ebay_client.fulfillment.client import FulfillmentClient from ebay_client.generated.account.models import ( FulfillmentPolicy, @@ -19,6 +19,13 @@ from ebay_client.generated.account.models import ( ReturnPolicyRequest, ) from ebay_client.generated.feed.models import TaskCollection +from ebay_client.generated.feed.models import ( + CreateTaskRequest, + CreateUserScheduleRequest, + ScheduleTemplateResponse, + UpdateUserScheduleRequest, + UserScheduleResponse, +) from ebay_client.generated.fulfillment.models import Order from ebay_client.generated.inventory.models import InventoryItemWithSkuLocaleGroupid from ebay_client.generated.media.models import ( @@ -406,6 +413,158 @@ def test_feed_wrapper_returns_task_collection_model(httpx_mock: HTTPXMock) -> No assert result.tasks and result.tasks[0].taskId == "TASK-1" +def test_feed_wrapper_supports_schedule_crud_and_template_lookup(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.ebay.com/sell/feed/v1/schedule", + status_code=201, + headers={"Location": "https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-1"}, + ) + httpx_mock.add_response( + method="GET", + url="https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-1", + json={"scheduleId": "SCHEDULE-1", "feedType": "LMS_ORDER_REPORT"}, + ) + httpx_mock.add_response( + method="PUT", + url="https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-1", + status_code=204, + ) + httpx_mock.add_response( + method="DELETE", + url="https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-1", + status_code=204, + ) + httpx_mock.add_response( + method="GET", + url="https://api.ebay.com/sell/feed/v1/schedule_template/TEMPLATE-1", + json={"scheduleTemplateId": "TEMPLATE-1", "feedType": "LMS_ORDER_REPORT"}, + ) + + client = FeedClient(build_transport()) + created = client.create_schedule( + CreateUserScheduleRequest(feedType="LMS_ORDER_REPORT", scheduleTemplateId="TEMPLATE-1") + ) + schedule = client.get_schedule("SCHEDULE-1") + client.update_schedule("SCHEDULE-1", UpdateUserScheduleRequest(scheduleName="nightly")) + client.delete_schedule("SCHEDULE-1") + template = client.get_schedule_template("TEMPLATE-1") + + assert isinstance(created, CreatedFeedResource) + assert created.resource_id == "SCHEDULE-1" + assert isinstance(schedule, UserScheduleResponse) + assert schedule.scheduleId == "SCHEDULE-1" + assert isinstance(template, ScheduleTemplateResponse) + assert template.scheduleTemplateId == "TEMPLATE-1" + + create_body = json.loads(httpx_mock.get_requests()[0].content.decode("utf-8")) + assert create_body["feedType"] == "LMS_ORDER_REPORT" + update_body = json.loads(httpx_mock.get_requests()[2].content.decode("utf-8")) + assert update_body["scheduleName"] == "nightly" + + +def test_feed_wrapper_supports_task_create_upload_and_file_downloads(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.ebay.com/sell/feed/v1/task", + status_code=202, + headers={"Location": "https://api.ebay.com/sell/feed/v1/task/TASK-2"}, + ) + httpx_mock.add_response( + method="POST", + url="https://api.ebay.com/sell/feed/v1/task/TASK-2/upload_file", + status_code=200, + json={}, + ) + httpx_mock.add_response( + method="GET", + url="https://api.ebay.com/sell/feed/v1/task/TASK-2/download_input_file", + status_code=200, + headers={"content-disposition": 'attachment; filename="input.xml"'}, + content=b"", + ) + httpx_mock.add_response( + method="GET", + url="https://api.ebay.com/sell/feed/v1/task/TASK-2/download_result_file", + status_code=200, + headers={"content-disposition": 'attachment; filename="result.csv"'}, + content=b"id,name\n1,demo\n", + ) + httpx_mock.add_response( + method="GET", + url="https://api.ebay.com/sell/feed/v1/schedule/SCHEDULE-9/download_result_file", + status_code=200, + headers={"content-disposition": 'attachment; filename="latest.csv"'}, + content=b"id,name\n2,latest\n", + ) + + client = FeedClient(build_transport()) + created = client.create_task( + CreateTaskRequest(feedType="LMS_ORDER_REPORT", schemaVersion="1.0"), + marketplace_id="EBAY_US", + ) + client.upload_file("TASK-2", file_name="input.xml", content=b"", content_type="application/xml") + input_file = client.get_input_file("TASK-2") + result_file = client.get_result_file("TASK-2") + latest_file = client.get_latest_result_file("SCHEDULE-9") + + assert isinstance(created, CreatedFeedResource) + assert created.resource_id == "TASK-2" + assert isinstance(input_file, FeedFileDownload) + assert input_file.file_name == "input.xml" + assert input_file.content == b"" + assert result_file.file_name == "result.csv" + assert result_file.content.startswith(b"id,name") + assert latest_file.file_name == "latest.csv" + + create_request = httpx_mock.get_requests()[0] + assert create_request.headers["X-EBAY-C-MARKETPLACE-ID"] == "EBAY_US" + create_body = json.loads(create_request.content.decode("utf-8")) + assert create_body["feedType"] == "LMS_ORDER_REPORT" + + upload_request = httpx_mock.get_requests()[1] + assert upload_request.headers["Content-Type"].startswith("multipart/form-data;") + assert b"filename=\"input.xml\"" in upload_request.content + + +def test_feed_wrapper_passes_task_and_schedule_filters(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url=( + "https://api.ebay.com/sell/feed/v1/task?feed_type=LMS_ORDER_REPORT" + "&schedule_id=SCHEDULE-1&date_range=2026-01-01T00:00:00.000Z..2026-01-02T00:00:00.000Z" + "&look_back_days=7&limit=50&offset=0" + ), + json={"tasks": [], "total": 0}, + ) + httpx_mock.add_response( + method="GET", + url="https://api.ebay.com/sell/feed/v1/schedule?feed_type=LMS_ORDER_REPORT&limit=25&offset=0", + json={"schedules": [], "total": 0}, + ) + httpx_mock.add_response( + method="GET", + url="https://api.ebay.com/sell/feed/v1/schedule_template?feed_type=LMS_ORDER_REPORT&limit=25&offset=0", + json={"scheduleTemplates": [], "total": 0}, + ) + + client = FeedClient(build_transport()) + tasks = client.get_tasks( + feed_type="LMS_ORDER_REPORT", + schedule_id="SCHEDULE-1", + date_range="2026-01-01T00:00:00.000Z..2026-01-02T00:00:00.000Z", + look_back_days=7, + limit=50, + offset=0, + ) + schedules = client.get_schedules(feed_type="LMS_ORDER_REPORT", limit=25, offset=0) + templates = client.get_schedule_templates(feed_type="LMS_ORDER_REPORT", limit=25, offset=0) + + assert tasks.total == 0 + assert schedules.total == 0 + assert templates.total == 0 + + def test_inventory_wrapper_accepts_readonly_or_full_scope_options(httpx_mock: HTTPXMock) -> None: oauth_client = RecordingOAuthClient() transport = ApiTransport(base_url="https://api.ebay.com", oauth_client=oauth_client)