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)