diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c7005..c241184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.4.0] - 2026-09-03 + +### Added + +- `list_feeds`, `get_feed`, `list_reports` and `get_report` methods in `amazon_creatorsapi` and `amazon_creatorsapi.aio` +- `Feed`, `FeedType`, `ReportMetadata` and `ReportType` in `amazon_creatorsapi.models` +- `FeedType` and `ReportType` models in the bundled SDK +- `feedType` field in `Feed` and `GetFeedRequestContent` models +- `reportType` field in `ReportMetadata` and `GetReportRequestContent` models + +### Changed + +- Bumped `creatorsapi-python-sdk` from `1.2.0` to `1.3.0` +- `partnerTag` is now a required field in `SearchItemsRequestContent` +- Pinned `ruff` to the version run by pre-commit, so `make lint` matches CI + +### Fixed + +- Search integration tests no longer assume Amazon always returns a full page of items + ## [6.3.0] - 2026-05-15 ### Added diff --git a/README.md b/README.md index 6887537..952bdbf 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A Python wrapper for Amazon's product APIs. This package supports both the legac - �🔍 **Product search** by keywords, categories, or browse nodes - 📦 **Product details** via ASIN or Amazon URL - 🔄 **Item variations** support (size, color, etc.) +- 📊 **Feeds and reports** listing and download URLs - 💰 **OffersV2 support** for enhanced pricing and offer details - 🌍 **20+ countries** supported - 🛡️ **Built-in throttling** to avoid API rate limits @@ -91,6 +92,29 @@ for node in nodes: print(node.display_name) ``` +### Feeds and Reports + +Feeds and reports are listed per marketplace, and downloaded through the +temporary URL returned by the API: + +```python +from amazon_creatorsapi.models import FeedType, ReportType + +for feed in api.list_feeds(): + print(feed.feed_name, feed.feed_type, feed.size) + +url = api.get_feed("product-feed", feed_type=FeedType.PRODUCT_FEEDS) + +for report in api.list_reports(): + print(report.filename, report.report_type, report.last_modified) + +url = api.get_report("earnings.csv", report_type=ReportType.CREATOR_CONNECTIONS) +``` + +The type is only needed to disambiguate a name available in more than one +program, such as a report present in both Creator Central and Creator +Connections. + ### Get the ASIN from URL ```python @@ -145,6 +169,8 @@ async with AsyncAmazonCreatorsApi( results = await api.search_items(keywords="laptop") variations = await api.get_variations("B01N5IB20Q") nodes = await api.get_browse_nodes(["667049031"]) + feeds = await api.list_feeds() + reports = await api.list_reports() # Or use without context manager (creates new connection per request) api = AsyncAmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY) diff --git a/amazon_creatorsapi/aio/api.py b/amazon_creatorsapi/aio/api.py index 19e15bf..bc9b023 100644 --- a/amazon_creatorsapi/aio/api.py +++ b/amazon_creatorsapi/aio/api.py @@ -42,10 +42,20 @@ from amazon_creatorsapi.core.marketplaces import CountryCode from creatorsapi_python_sdk.models.condition import Condition from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag + from creatorsapi_python_sdk.models.feed_type import FeedType + from creatorsapi_python_sdk.models.report_type import ReportType from creatorsapi_python_sdk.models.sort_by import SortBy from creatorsapi_python_sdk.models.browse_node import BrowseNode +from creatorsapi_python_sdk.models.feed import Feed +from creatorsapi_python_sdk.models.get_feed_response_content import ( + GetFeedResponseContent, +) +from creatorsapi_python_sdk.models.get_report_response_content import ( + GetReportResponseContent, +) from creatorsapi_python_sdk.models.item import Item +from creatorsapi_python_sdk.models.report_metadata import ReportMetadata from creatorsapi_python_sdk.models.search_result import SearchResult from creatorsapi_python_sdk.models.variations_result import VariationsResult @@ -55,6 +65,10 @@ ENDPOINT_SEARCH_ITEMS = "/catalog/v1/searchItems" ENDPOINT_GET_VARIATIONS = "/catalog/v1/getVariations" ENDPOINT_GET_BROWSE_NODES = "/catalog/v1/getBrowseNodes" +ENDPOINT_LIST_FEEDS = "/catalog/v1/listFeeds" +ENDPOINT_GET_FEED = "/catalog/v1/getFeed" +ENDPOINT_LIST_REPORTS = "/reports/v1/listReports" +ENDPOINT_GET_REPORT = "/reports/v1/getReport" # TypeVar for generic resource handling ResourceT = TypeVar("ResourceT", bound=Enum) @@ -449,6 +463,90 @@ async def get_browse_nodes( return self._deserialize_browse_nodes(browse_nodes_result["browseNodes"]) + async def list_feeds(self) -> list[Feed]: + """Return the feeds available for your account. + + Returns: + List of Feed objects, empty if no feeds are available. Each feed + carries its type, so the same name can exist in more than one + feed program. + + Raises: + RequestError: If the API request fails. + + """ + response = await self._make_request(ENDPOINT_LIST_FEEDS) + + return self._deserialize_feeds(response.get("feeds") or []) + + async def get_feed(self, feed_name: str, feed_type: FeedType | None = None) -> str: + """Return a temporary download URL for a feed. + + Args: + feed_name: Name of the feed, as returned by list_feeds. + feed_type: Feed program the name belongs to. Needed to disambiguate + a name available in more than one program. + + Returns: + URL to download the feed contents from. + + Raises: + RequestError: If the API request fails. + + """ + request_body: dict[str, Any] = {"feedName": feed_name} + + if feed_type is not None: + request_body["feedType"] = feed_type.value + + response = await self._make_request(ENDPOINT_GET_FEED, request_body) + + return GetFeedResponseContent.model_validate(response).url + + async def list_reports(self) -> list[ReportMetadata]: + """Return the reports available for your account. + + Returns: + List of ReportMetadata objects, empty if no reports are available. + Each report carries its type, telling Creator Central reports apart + from Creator Connections ones. + + Raises: + RequestError: If the API request fails. + + """ + response = await self._make_request(ENDPOINT_LIST_REPORTS) + + return self._deserialize_reports(response.get("reports") or []) + + async def get_report( + self, + filename: str, + report_type: ReportType | None = None, + ) -> str: + """Return a temporary download URL for a report. + + Args: + filename: Name of the report, as returned by list_reports. + report_type: Program the report belongs to. Needed to disambiguate + a filename available in more than one program. + + Returns: + URL to download the report contents from. + + Raises: + RequestError: If the API request fails. + + """ + request_body: dict[str, Any] = {"filename": filename} + + if report_type is not None: + request_body["reportType"] = report_type.value + + response = await self._make_request(ENDPOINT_GET_REPORT, request_body) + + return GetReportResponseContent.model_validate(response).url + async def _throttle(self) -> None: """Wait for the throttling interval to elapse since the last API call. @@ -468,13 +566,13 @@ async def _throttle(self) -> None: async def _make_request( self, endpoint: str, - body: dict[str, Any], + body: dict[str, Any] | None = None, ) -> dict[str, Any]: """Make an API request with authentication and throttling. Args: endpoint: API endpoint path. - body: Request body. + body: Request body, omitted for operations that take no payload. Returns: Parsed JSON response. @@ -554,3 +652,14 @@ def _deserialize_browse_nodes( ) -> list[BrowseNode]: """Deserialize browse nodes data from API response to BrowseNode models.""" return [BrowseNode.model_validate(node) for node in browse_nodes_data] + + def _deserialize_feeds(self, feeds_data: list[dict[str, Any]]) -> list[Feed]: + """Deserialize feed data from API response to Feed models.""" + return [Feed.model_validate(feed) for feed in feeds_data] + + def _deserialize_reports( + self, + reports_data: list[dict[str, Any]], + ) -> list[ReportMetadata]: + """Deserialize report data from API response to ReportMetadata models.""" + return [ReportMetadata.model_validate(report) for report in reports_data] diff --git a/amazon_creatorsapi/aio/client.py b/amazon_creatorsapi/aio/client.py index 8ec143b..ef19251 100644 --- a/amazon_creatorsapi/aio/client.py +++ b/amazon_creatorsapi/aio/client.py @@ -105,14 +105,15 @@ async def post( self, path: str, headers: dict[str, str], - body: dict[str, Any], + body: dict[str, Any] | None = None, ) -> AsyncHttpResponse: """Make a POST request to the API. Args: path: API endpoint path (e.g., "/catalog/v1/getItems"). headers: Request headers. - body: Request body as a dictionary. + body: Request body as a dictionary. When omitted, the request is + sent without a payload, as operations like listFeeds expect. Returns: AsyncHttpResponse with status, headers, and body. diff --git a/amazon_creatorsapi/api.py b/amazon_creatorsapi/api.py index 70dc472..16dbf15 100644 --- a/amazon_creatorsapi/api.py +++ b/amazon_creatorsapi/api.py @@ -23,10 +23,16 @@ from creatorsapi_python_sdk.models.get_browse_nodes_resource import ( GetBrowseNodesResource, ) +from creatorsapi_python_sdk.models.get_feed_request_content import ( + GetFeedRequestContent, +) from creatorsapi_python_sdk.models.get_items_request_content import ( GetItemsRequestContent, ) from creatorsapi_python_sdk.models.get_items_resource import GetItemsResource +from creatorsapi_python_sdk.models.get_report_request_content import ( + GetReportRequestContent, +) from creatorsapi_python_sdk.models.get_variations_request_content import ( GetVariationsRequestContent, ) @@ -41,7 +47,11 @@ from creatorsapi_python_sdk.models.browse_node import BrowseNode from creatorsapi_python_sdk.models.condition import Condition from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag + from creatorsapi_python_sdk.models.feed import Feed + from creatorsapi_python_sdk.models.feed_type import FeedType from creatorsapi_python_sdk.models.item import Item + from creatorsapi_python_sdk.models.report_metadata import ReportMetadata + from creatorsapi_python_sdk.models.report_type import ReportType from creatorsapi_python_sdk.models.search_result import SearchResult from creatorsapi_python_sdk.models.sort_by import SortBy from creatorsapi_python_sdk.models.variations_result import VariationsResult @@ -367,6 +377,106 @@ def get_browse_nodes( return response.browse_nodes_result.browse_nodes + def list_feeds(self) -> list[Feed]: + """Return the feeds available for your account. + + Returns: + List of Feed objects, empty if no feeds are available. Each feed + carries its type, so the same name can exist in more than one + feed program. + + Raises: + RequestError: If the API request fails. + + """ + self._throttle() + + try: + response = self._api.list_feeds(x_marketplace=self.marketplace) + except ApiException as exc: + self._handle_api_exception(exc) + + return response.feeds or [] + + def get_feed(self, feed_name: str, feed_type: FeedType | None = None) -> str: + """Return a temporary download URL for a feed. + + Args: + feed_name: Name of the feed, as returned by list_feeds. + feed_type: Feed program the name belongs to. Needed to disambiguate + a name available in more than one program. + + Returns: + URL to download the feed contents from. + + Raises: + RequestError: If the API request fails. + + """ + request = GetFeedRequestContent(feedName=feed_name, feedType=feed_type) + + self._throttle() + + try: + response = self._api.get_feed( + x_marketplace=self.marketplace, + get_feed_request_content=request, + ) + except ApiException as exc: + self._handle_api_exception(exc) + + return response.url + + def list_reports(self) -> list[ReportMetadata]: + """Return the reports available for your account. + + Returns: + List of ReportMetadata objects, empty if no reports are available. + Each report carries its type, telling Creator Central reports apart + from Creator Connections ones. + + Raises: + RequestError: If the API request fails. + + """ + self._throttle() + + try: + response = self._api.list_reports(x_marketplace=self.marketplace) + except ApiException as exc: + self._handle_api_exception(exc) + + return response.reports + + def get_report(self, filename: str, report_type: ReportType | None = None) -> str: + """Return a temporary download URL for a report. + + Args: + filename: Name of the report, as returned by list_reports. + report_type: Program the report belongs to. Needed to disambiguate + a filename available in more than one program. + + Returns: + URL to download the report contents from. + + Raises: + RequestError: If the API request fails. + + """ + request = GetReportRequestContent(filename=filename, reportType=report_type) + + self._throttle() + + try: + response = self._api.get_report( + x_marketplace=self.marketplace, + get_report_request_content=request, + ) + except ApiException as exc: + self._handle_api_exception(exc) + + return response.url + def _throttle(self) -> None: """Wait for the throttling interval to elapse since the last API call.""" wait_time = self.throttling - (time.time() - self._last_query_time) diff --git a/amazon_creatorsapi/models.py b/amazon_creatorsapi/models.py index c6d2599..9276567 100644 --- a/amazon_creatorsapi/models.py +++ b/amazon_creatorsapi/models.py @@ -7,6 +7,7 @@ Example: >>> from amazon_creatorsapi.models import Item, Condition, SortBy >>> from amazon_creatorsapi.models import GetItemsResource, SearchItemsResource + >>> from amazon_creatorsapi.models import Feed, FeedType, ReportMetadata, ReportType """ @@ -25,6 +26,8 @@ from creatorsapi_python_sdk.models.deal_details import DealDetails from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag from creatorsapi_python_sdk.models.external_ids import ExternalIds +from creatorsapi_python_sdk.models.feed import Feed +from creatorsapi_python_sdk.models.feed_type import FeedType from creatorsapi_python_sdk.models.get_browse_nodes_resource import ( GetBrowseNodesResource, ) @@ -52,6 +55,8 @@ from creatorsapi_python_sdk.models.product_info import ProductInfo from creatorsapi_python_sdk.models.refinement import Refinement from creatorsapi_python_sdk.models.refinement_bin import RefinementBin +from creatorsapi_python_sdk.models.report_metadata import ReportMetadata +from creatorsapi_python_sdk.models.report_type import ReportType from creatorsapi_python_sdk.models.saving_basis_type import SavingBasisType from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource from creatorsapi_python_sdk.models.search_refinements import SearchRefinements @@ -82,6 +87,8 @@ "DealDetails", "DeliveryFlag", "ExternalIds", + "Feed", + "FeedType", "GetBrowseNodesResource", "GetItemsResource", "GetVariationsResource", @@ -107,6 +114,8 @@ "ProductInfo", "Refinement", "RefinementBin", + "ReportMetadata", + "ReportType", "SavingBasisType", "SearchItemsResource", "SearchRefinements", diff --git a/creatorsapi_python_sdk/__init__.py b/creatorsapi_python_sdk/__init__.py index 23da967..ab87dd9 100644 --- a/creatorsapi_python_sdk/__init__.py +++ b/creatorsapi_python_sdk/__init__.py @@ -57,6 +57,7 @@ from creatorsapi_python_sdk.models.error_data import ErrorData from creatorsapi_python_sdk.models.external_ids import ExternalIds from creatorsapi_python_sdk.models.feed import Feed +from creatorsapi_python_sdk.models.feed_type import FeedType from creatorsapi_python_sdk.models.get_browse_nodes_request_content import GetBrowseNodesRequestContent from creatorsapi_python_sdk.models.get_browse_nodes_resource import GetBrowseNodesResource from creatorsapi_python_sdk.models.get_browse_nodes_response_content import GetBrowseNodesResponseContent @@ -99,6 +100,7 @@ from creatorsapi_python_sdk.models.refinement import Refinement from creatorsapi_python_sdk.models.refinement_bin import RefinementBin from creatorsapi_python_sdk.models.report_metadata import ReportMetadata +from creatorsapi_python_sdk.models.report_type import ReportType from creatorsapi_python_sdk.models.resource_not_found_exception_response_content import ResourceNotFoundExceptionResponseContent from creatorsapi_python_sdk.models.saving_basis_type import SavingBasisType from creatorsapi_python_sdk.models.search_items_request_content import SearchItemsRequestContent diff --git a/creatorsapi_python_sdk/api/default_api.py b/creatorsapi_python_sdk/api/default_api.py index 5816d42..b1e7860 100644 --- a/creatorsapi_python_sdk/api/default_api.py +++ b/creatorsapi_python_sdk/api/default_api.py @@ -22,7 +22,6 @@ from typing_extensions import Annotated from pydantic import Field, field_validator -from typing import Optional from typing_extensions import Annotated from creatorsapi_python_sdk.models.get_browse_nodes_request_content import GetBrowseNodesRequestContent from creatorsapi_python_sdk.models.get_browse_nodes_response_content import GetBrowseNodesResponseContent @@ -2130,7 +2129,7 @@ def _list_reports_serialize( def search_items( self, x_marketplace: Annotated[str, Field(strict=True, max_length=1000, description="Target Amazon Locale.")], - search_items_request_content: Optional[SearchItemsRequestContent] = None, + search_items_request_content: SearchItemsRequestContent, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -2149,7 +2148,7 @@ def search_items( :param x_marketplace: Target Amazon Locale. (required) :type x_marketplace: str - :param search_items_request_content: + :param search_items_request_content: (required) :type search_items_request_content: SearchItemsRequestContent :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -2206,7 +2205,7 @@ def search_items( def search_items_with_http_info( self, x_marketplace: Annotated[str, Field(strict=True, max_length=1000, description="Target Amazon Locale.")], - search_items_request_content: Optional[SearchItemsRequestContent] = None, + search_items_request_content: SearchItemsRequestContent, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -2225,7 +2224,7 @@ def search_items_with_http_info( :param x_marketplace: Target Amazon Locale. (required) :type x_marketplace: str - :param search_items_request_content: + :param search_items_request_content: (required) :type search_items_request_content: SearchItemsRequestContent :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -2282,7 +2281,7 @@ def search_items_with_http_info( def search_items_without_preload_content( self, x_marketplace: Annotated[str, Field(strict=True, max_length=1000, description="Target Amazon Locale.")], - search_items_request_content: Optional[SearchItemsRequestContent] = None, + search_items_request_content: SearchItemsRequestContent, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -2301,7 +2300,7 @@ def search_items_without_preload_content( :param x_marketplace: Target Amazon Locale. (required) :type x_marketplace: str - :param search_items_request_content: + :param search_items_request_content: (required) :type search_items_request_content: SearchItemsRequestContent :param _request_timeout: timeout setting for this request. If one number provided, it will be total request diff --git a/creatorsapi_python_sdk/api_client.py b/creatorsapi_python_sdk/api_client.py index 41f2b17..63f203d 100644 --- a/creatorsapi_python_sdk/api_client.py +++ b/creatorsapi_python_sdk/api_client.py @@ -107,7 +107,7 @@ def __init__( self.default_headers[header_name] = header_value self.cookie = cookie # Set default User-Agent. - self.user_agent = 'creatorsapi-python-sdk/1.2.0' + self.user_agent = 'creatorsapi-python-sdk/1.3.0' self.client_side_validation = configuration.client_side_validation # OAuth2 properties diff --git a/creatorsapi_python_sdk/models/__init__.py b/creatorsapi_python_sdk/models/__init__.py index 095b64e..8114b9b 100644 --- a/creatorsapi_python_sdk/models/__init__.py +++ b/creatorsapi_python_sdk/models/__init__.py @@ -40,6 +40,7 @@ from creatorsapi_python_sdk.models.error_data import ErrorData from creatorsapi_python_sdk.models.external_ids import ExternalIds from creatorsapi_python_sdk.models.feed import Feed +from creatorsapi_python_sdk.models.feed_type import FeedType from creatorsapi_python_sdk.models.get_browse_nodes_request_content import GetBrowseNodesRequestContent from creatorsapi_python_sdk.models.get_browse_nodes_resource import GetBrowseNodesResource from creatorsapi_python_sdk.models.get_browse_nodes_response_content import GetBrowseNodesResponseContent @@ -82,6 +83,7 @@ from creatorsapi_python_sdk.models.refinement import Refinement from creatorsapi_python_sdk.models.refinement_bin import RefinementBin from creatorsapi_python_sdk.models.report_metadata import ReportMetadata +from creatorsapi_python_sdk.models.report_type import ReportType from creatorsapi_python_sdk.models.resource_not_found_exception_response_content import ResourceNotFoundExceptionResponseContent from creatorsapi_python_sdk.models.saving_basis_type import SavingBasisType from creatorsapi_python_sdk.models.search_items_request_content import SearchItemsRequestContent diff --git a/creatorsapi_python_sdk/models/feed.py b/creatorsapi_python_sdk/models/feed.py index ab2f452..96e623d 100644 --- a/creatorsapi_python_sdk/models/feed.py +++ b/creatorsapi_python_sdk/models/feed.py @@ -24,7 +24,8 @@ import json from pydantic import BaseModel, ConfigDict, Field, StrictFloat, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union +from creatorsapi_python_sdk.models.feed_type import FeedType from typing import Optional, Set from typing_extensions import Self @@ -36,7 +37,8 @@ class Feed(BaseModel): size: Union[StrictFloat, StrictInt] md5: StrictStr last_updated: StrictStr = Field(alias="lastUpdated") - __properties: ClassVar[List[str]] = ["feedName", "size", "md5", "lastUpdated"] + feed_type: Optional[FeedType] = Field(default=None, alias="feedType") + __properties: ClassVar[List[str]] = ["feedName", "size", "md5", "lastUpdated", "feedType"] model_config = ConfigDict( populate_by_name=True, @@ -91,7 +93,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "feedName": obj.get("feedName"), "size": obj.get("size"), "md5": obj.get("md5"), - "lastUpdated": obj.get("lastUpdated") + "lastUpdated": obj.get("lastUpdated"), + "feedType": obj.get("feedType") }) return _obj diff --git a/creatorsapi_python_sdk/models/feed_type.py b/creatorsapi_python_sdk/models/feed_type.py new file mode 100644 index 0000000..5eee81a --- /dev/null +++ b/creatorsapi_python_sdk/models/feed_type.py @@ -0,0 +1,44 @@ +# coding: utf-8 + +""" +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + +or in the "license" file accompanying this file. This file is distributed +on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +express or implied. See the License for the specific language governing +permissions and limitations under the License. + +""" # noqa: E501 + + + +from __future__ import annotations +import json +from enum import Enum +from typing_extensions import Self + + +class FeedType(str, Enum): + """ + FeedType + """ + + """ + allowed enum values + """ + PRODUCT_FEEDS = 'PRODUCT_FEEDS' + DEALS_FEEDS = 'DEALS_FEEDS' + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of FeedType from a JSON string""" + return cls(json.loads(json_str)) + + + diff --git a/creatorsapi_python_sdk/models/get_feed_request_content.py b/creatorsapi_python_sdk/models/get_feed_request_content.py index db15550..539f935 100644 --- a/creatorsapi_python_sdk/models/get_feed_request_content.py +++ b/creatorsapi_python_sdk/models/get_feed_request_content.py @@ -24,8 +24,9 @@ import json from pydantic import BaseModel, ConfigDict, Field -from typing import Any, ClassVar, Dict, List +from typing import Any, ClassVar, Dict, List, Optional from typing_extensions import Annotated +from creatorsapi_python_sdk.models.feed_type import FeedType from typing import Optional, Set from typing_extensions import Self @@ -34,7 +35,8 @@ class GetFeedRequestContent(BaseModel): GetFeedRequestContent """ # noqa: E501 feed_name: Annotated[str, Field(min_length=1, strict=True)] = Field(alias="feedName") - __properties: ClassVar[List[str]] = ["feedName"] + feed_type: Optional[FeedType] = Field(default=None, alias="feedType") + __properties: ClassVar[List[str]] = ["feedName", "feedType"] model_config = ConfigDict( populate_by_name=True, @@ -86,7 +88,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "feedName": obj.get("feedName") + "feedName": obj.get("feedName"), + "feedType": obj.get("feedType") }) return _obj diff --git a/creatorsapi_python_sdk/models/get_report_request_content.py b/creatorsapi_python_sdk/models/get_report_request_content.py index b50e1ba..f0e2953 100644 --- a/creatorsapi_python_sdk/models/get_report_request_content.py +++ b/creatorsapi_python_sdk/models/get_report_request_content.py @@ -24,8 +24,9 @@ import json from pydantic import BaseModel, ConfigDict, Field -from typing import Any, ClassVar, Dict, List +from typing import Any, ClassVar, Dict, List, Optional from typing_extensions import Annotated +from creatorsapi_python_sdk.models.report_type import ReportType from typing import Optional, Set from typing_extensions import Self @@ -34,7 +35,8 @@ class GetReportRequestContent(BaseModel): GetReportRequestContent """ # noqa: E501 filename: Annotated[str, Field(min_length=1, strict=True)] - __properties: ClassVar[List[str]] = ["filename"] + report_type: Optional[ReportType] = Field(default=None, alias="reportType") + __properties: ClassVar[List[str]] = ["filename", "reportType"] model_config = ConfigDict( populate_by_name=True, @@ -86,7 +88,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "filename": obj.get("filename") + "filename": obj.get("filename"), + "reportType": obj.get("reportType") }) return _obj diff --git a/creatorsapi_python_sdk/models/report_metadata.py b/creatorsapi_python_sdk/models/report_metadata.py index e9575c3..d6dd57a 100644 --- a/creatorsapi_python_sdk/models/report_metadata.py +++ b/creatorsapi_python_sdk/models/report_metadata.py @@ -24,7 +24,8 @@ import json from pydantic import BaseModel, ConfigDict, Field, StrictFloat, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union +from creatorsapi_python_sdk.models.report_type import ReportType from typing import Optional, Set from typing_extensions import Self @@ -36,7 +37,8 @@ class ReportMetadata(BaseModel): md5: StrictStr size: Union[StrictFloat, StrictInt] last_modified: StrictStr = Field(alias="lastModified") - __properties: ClassVar[List[str]] = ["filename", "md5", "size", "lastModified"] + report_type: Optional[ReportType] = Field(default=None, alias="reportType") + __properties: ClassVar[List[str]] = ["filename", "md5", "size", "lastModified", "reportType"] model_config = ConfigDict( populate_by_name=True, @@ -91,7 +93,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "filename": obj.get("filename"), "md5": obj.get("md5"), "size": obj.get("size"), - "lastModified": obj.get("lastModified") + "lastModified": obj.get("lastModified"), + "reportType": obj.get("reportType") }) return _obj diff --git a/creatorsapi_python_sdk/models/report_type.py b/creatorsapi_python_sdk/models/report_type.py new file mode 100644 index 0000000..fc4447b --- /dev/null +++ b/creatorsapi_python_sdk/models/report_type.py @@ -0,0 +1,44 @@ +# coding: utf-8 + +""" +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +A copy of the License is located at + + http://www.apache.org/licenses/LICENSE-2.0 + +or in the "license" file accompanying this file. This file is distributed +on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +express or implied. See the License for the specific language governing +permissions and limitations under the License. + +""" # noqa: E501 + + + +from __future__ import annotations +import json +from enum import Enum +from typing_extensions import Self + + +class ReportType(str, Enum): + """ + The source program a report belongs to. Stamped on ListReports response entries under the enforced treatment so callers can distinguish Creator Central from Creator Connections reports and route GetReport explicitly. + """ + + """ + allowed enum values + """ + CREATOR_CENTRAL = 'CREATOR_CENTRAL' + CREATOR_CONNECTIONS = 'CREATOR_CONNECTIONS' + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of ReportType from a JSON string""" + return cls(json.loads(json_str)) + + + diff --git a/creatorsapi_python_sdk/models/search_items_request_content.py b/creatorsapi_python_sdk/models/search_items_request_content.py index 656f3d9..bbe24af 100644 --- a/creatorsapi_python_sdk/models/search_items_request_content.py +++ b/creatorsapi_python_sdk/models/search_items_request_content.py @@ -55,7 +55,7 @@ class SearchItemsRequestContent(BaseModel): min_price: Optional[Union[Annotated[float, Field(strict=True, ge=1)], Annotated[int, Field(strict=True, ge=1)]]] = Field(default=None, description="The MinPrice parameter filters search results to items with at least one offer price above the specified value.", alias="minPrice") min_reviews_rating: Optional[Union[Annotated[float, Field(le=4, strict=True, ge=1)], Annotated[int, Field(le=4, strict=True, ge=1)]]] = Field(default=None, description="The MinReviewsRating parameter filters search results to items with customer review ratings above specified value.", alias="minReviewsRating") min_saving_percent: Optional[Union[Annotated[float, Field(le=99, strict=True, ge=1)], Annotated[int, Field(le=99, strict=True, ge=1)]]] = Field(default=None, description="The MinSavingPercent parameter filters search results to items with at least one offer having saving percentage above the specified value.", alias="minSavingPercent") - partner_tag: Optional[Annotated[str, Field(strict=True, max_length=64)]] = Field(default=None, description="An alphanumeric token that uniquely identifies a partner. If the value of PartnerType is Associates, enter your Store Id or tracking ID.", alias="partnerTag") + partner_tag: Annotated[str, Field(strict=True, max_length=64)] = Field(description="Unique Id for a partner. This is used to identify the associate tag for tracking affiliate commissions. Example: 'xyz-20'", alias="partnerTag") properties: Optional[Dict[str, Annotated[str, Field(strict=True)]]] = Field(default=None, description="Reserved parameter for specifying key-value pairs. This is a flexible mechanism for passing additional context or metadata to the API.") resources: Optional[Annotated[List[SearchItemsResource], Field(max_length=100)]] = Field(default=None, description="List of resources for SearchItems operation which specify the values to return.") search_index: Optional[Annotated[str, Field(strict=True, max_length=1000)]] = Field(default=None, description="Indicates the product category to search. SearchIndex values differ by marketplace.", alias="searchIndex") @@ -136,9 +136,6 @@ def keywords_validate_regular_expression(cls, value): @field_validator('partner_tag') def partner_tag_validate_regular_expression(cls, value): """Validates the regular expression""" - if value is None: - return value - if not re.match(r".*\S.*", value): raise ValueError(r"must validate the regular expression /.*\S.*/") return value diff --git a/docs/conf.py b/docs/conf.py index 91bf73b..db06254 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ author = "Sergio Abad" # The full version, including alpha/beta/rc tags -release = "6.3.0" +release = "6.4.0" # -- General configuration --------------------------------------------------- diff --git a/docs/pages/usage-guide.md b/docs/pages/usage-guide.md index 0a2ba08..fc95a09 100644 --- a/docs/pages/usage-guide.md +++ b/docs/pages/usage-guide.md @@ -70,6 +70,29 @@ for node in nodes: print(node.display_name) ``` +## Feeds and Reports + +Feeds and reports are listed per marketplace, and downloaded through the +temporary URL returned by the API: + +```python +from amazon_creatorsapi.models import FeedType, ReportType + +for feed in api.list_feeds(): + print(feed.feed_name, feed.feed_type, feed.size) + +url = api.get_feed("product-feed", feed_type=FeedType.PRODUCT_FEEDS) + +for report in api.list_reports(): + print(report.filename, report.report_type, report.last_modified) + +url = api.get_report("earnings.csv", report_type=ReportType.CREATOR_CONNECTIONS) +``` + +The type is only needed to disambiguate a name available in more than one +program, such as a report present in both Creator Central and Creator +Connections. + ## Get the ASIN from URL ```python @@ -127,6 +150,8 @@ async with AsyncAmazonCreatorsApi( results = await api.search_items(keywords="laptop") variations = await api.get_variations("B01N5IB20Q") nodes = await api.get_browse_nodes(["667049031"]) + feeds = await api.list_feeds() + reports = await api.list_reports() # Or use without context manager (creates new connection per request) api = AsyncAmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY) diff --git a/pyproject.toml b/pyproject.toml index 4b8f0e0..7ecaf0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-amazon-paapi" -version = "6.3.0" +version = "6.4.0" description = "Amazon Product Advertising API 5.0 wrapper for Python" readme = "README.md" requires-python = ">=3.9" @@ -52,7 +52,7 @@ dev = [ "pytest>=7.4.4", "pytest-cov>=4.1.0", "python-dotenv>=1.2.1", - "ruff>=0.14.11", + "ruff==0.14.11", # Keep in sync with the ruff rev in .pre-commit-config.yaml "types-requests>=2.32.4.20260107", ] diff --git a/tests/amazon_creatorsapi/aio/api_test.py b/tests/amazon_creatorsapi/aio/api_test.py index 0427d55..fc58c9b 100644 --- a/tests/amazon_creatorsapi/aio/api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -15,11 +15,13 @@ ) from creatorsapi_python_sdk.models.condition import Condition from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag +from creatorsapi_python_sdk.models.feed_type import FeedType from creatorsapi_python_sdk.models.get_browse_nodes_resource import ( GetBrowseNodesResource, ) from creatorsapi_python_sdk.models.get_items_resource import GetItemsResource from creatorsapi_python_sdk.models.get_variations_resource import GetVariationsResource +from creatorsapi_python_sdk.models.report_type import ReportType from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource from creatorsapi_python_sdk.models.sort_by import SortBy @@ -1340,5 +1342,262 @@ async def test_request_uses_lwa_authorization_header( self.assertEqual(headers["Authorization"], "Bearer test_token") +class TestAsyncAmazonCreatorsApiFeeds(unittest.IsolatedAsyncioTestCase): + """Tests for AsyncAmazonCreatorsApi feed operations.""" + + def _mock_transport( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + payload: dict, + ) -> AsyncMock: + """Wire the HTTP client and token manager mocks to return a payload.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = payload + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + return mock_client + + def _build_api(self) -> AsyncAmazonCreatorsApi: + """Build an API instance with throttling disabled.""" + return AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_list_feeds_success( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test list_feeds returns deserialized feeds.""" + mock_client = self._mock_transport( + mock_http_client_class, + mock_token_manager_class, + { + "feeds": [ + { + "feedName": "product-feed", + "size": 1024, + "md5": "abc123", + "lastUpdated": "2026-09-01T00:00:00Z", + "feedType": "PRODUCT_FEEDS", + } + ] + }, + ) + + async with self._build_api() as api: + feeds = await api.list_feeds() + + self.assertEqual(1, len(feeds)) + self.assertEqual("product-feed", feeds[0].feed_name) + self.assertEqual(FeedType.PRODUCT_FEEDS, feeds[0].feed_type) + self.assertEqual("/catalog/v1/listFeeds", mock_client.post.call_args.args[0]) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_list_feeds_without_feeds( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test list_feeds returns an empty list when no feeds are available.""" + self._mock_transport(mock_http_client_class, mock_token_manager_class, {}) + + async with self._build_api() as api: + self.assertEqual([], await api.list_feeds()) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_feed_success( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_feed returns the download URL without a feed type.""" + mock_client = self._mock_transport( + mock_http_client_class, + mock_token_manager_class, + {"url": "https://feed.example/file"}, + ) + + async with self._build_api() as api: + url = await api.get_feed("product-feed") + + self.assertEqual("https://feed.example/file", url) + self.assertEqual( + {"feedName": "product-feed"}, + mock_client.post.call_args.args[2], + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_feed_with_feed_type( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_feed sends the feed type in the request body.""" + mock_client = self._mock_transport( + mock_http_client_class, + mock_token_manager_class, + {"url": "https://feed.example/file"}, + ) + + async with self._build_api() as api: + await api.get_feed("deals-feed", feed_type=FeedType.DEALS_FEEDS) + + self.assertEqual( + {"feedName": "deals-feed", "feedType": "DEALS_FEEDS"}, + mock_client.post.call_args.args[2], + ) + + +class TestAsyncAmazonCreatorsApiReports(unittest.IsolatedAsyncioTestCase): + """Tests for AsyncAmazonCreatorsApi report operations.""" + + def _mock_transport( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + payload: dict, + ) -> AsyncMock: + """Wire the HTTP client and token manager mocks to return a payload.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = payload + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + return mock_client + + def _build_api(self) -> AsyncAmazonCreatorsApi: + """Build an API instance with throttling disabled.""" + return AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_list_reports_success( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test list_reports returns deserialized reports.""" + mock_client = self._mock_transport( + mock_http_client_class, + mock_token_manager_class, + { + "reports": [ + { + "filename": "earnings.csv", + "md5": "abc123", + "size": 2048, + "lastModified": "2026-09-01T00:00:00Z", + "reportType": "CREATOR_CONNECTIONS", + } + ] + }, + ) + + async with self._build_api() as api: + reports = await api.list_reports() + + self.assertEqual(1, len(reports)) + self.assertEqual("earnings.csv", reports[0].filename) + self.assertEqual(ReportType.CREATOR_CONNECTIONS, reports[0].report_type) + self.assertEqual("/reports/v1/listReports", mock_client.post.call_args.args[0]) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_list_reports_without_reports( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test list_reports returns an empty list when no reports exist.""" + self._mock_transport(mock_http_client_class, mock_token_manager_class, {}) + + async with self._build_api() as api: + self.assertEqual([], await api.list_reports()) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_report_success( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_report returns the download URL without a report type.""" + mock_client = self._mock_transport( + mock_http_client_class, + mock_token_manager_class, + {"url": "https://report.example/file"}, + ) + + async with self._build_api() as api: + url = await api.get_report("earnings.csv") + + self.assertEqual("https://report.example/file", url) + self.assertEqual( + {"filename": "earnings.csv"}, + mock_client.post.call_args.args[2], + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_report_with_report_type( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test get_report sends the report type in the request body.""" + mock_client = self._mock_transport( + mock_http_client_class, + mock_token_manager_class, + {"url": "https://report.example/file"}, + ) + + async with self._build_api() as api: + await api.get_report( + "earnings.csv", + report_type=ReportType.CREATOR_CENTRAL, + ) + + self.assertEqual( + {"filename": "earnings.csv", "reportType": "CREATOR_CENTRAL"}, + mock_client.post.call_args.args[2], + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/amazon_creatorsapi/aio/integration_test.py b/tests/amazon_creatorsapi/aio/integration_test.py index 9c9cf2a..10cf019 100644 --- a/tests/amazon_creatorsapi/aio/integration_test.py +++ b/tests/amazon_creatorsapi/aio/integration_test.py @@ -206,10 +206,11 @@ def setUp(self) -> None: self.browse_nodes_result: list[BrowseNode] = _cached_data["browse_nodes_result"] # type: ignore[assignment] async def test_search_items_returns_expected_count(self) -> None: - """Test that search returns the default number of items.""" + """Test that search returns no more items than the default page size.""" + # API defaults to a page size of 10, but may return fewer items items = self.search_result.items if items: - self.assertEqual(10, len(items)) + self.assertLessEqual(len(items), 10) async def test_search_items_includes_affiliate_tag(self) -> None: """Test that search results include the affiliate tag in URLs.""" diff --git a/tests/amazon_creatorsapi/api_test.py b/tests/amazon_creatorsapi/api_test.py index c471f51..b2a4acf 100644 --- a/tests/amazon_creatorsapi/api_test.py +++ b/tests/amazon_creatorsapi/api_test.py @@ -18,11 +18,13 @@ ) from creatorsapi_python_sdk.exceptions import ApiException from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag +from creatorsapi_python_sdk.models.feed_type import FeedType from creatorsapi_python_sdk.models.get_browse_nodes_resource import ( GetBrowseNodesResource, ) from creatorsapi_python_sdk.models.get_items_resource import GetItemsResource from creatorsapi_python_sdk.models.get_variations_resource import GetVariationsResource +from creatorsapi_python_sdk.models.report_type import ReportType from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource if TYPE_CHECKING: @@ -829,3 +831,199 @@ def test_get_browse_nodes_with_explicit_resources( resources=[GetBrowseNodesResource.BROWSE_NODES_DOT_ANCESTOR], ) self.assertIsInstance(result, list) + + def _build_api(self) -> AmazonCreatorsApi: + """Build an API instance with throttling disabled.""" + return AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + ) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_list_feeds( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test list_feeds method returns the available feeds.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + feed = MagicMock() + mock_api.list_feeds.return_value = MagicMock(feeds=[feed]) + + result = self._build_api().list_feeds() + + self.assertEqual([feed], result) + mock_api.list_feeds.assert_called_once_with(x_marketplace="www.amazon.es") + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_list_feeds_without_feeds( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test list_feeds returns an empty list when no feeds are available.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.list_feeds.return_value = MagicMock(feeds=None) + + self.assertEqual([], self._build_api().list_feeds()) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_list_feeds_api_exception( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test list_feeds raises a wrapped error on API failure.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.list_feeds.side_effect = ApiException(status=500) + + with self.assertRaises(RequestError): + self._build_api().list_feeds() + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_feed( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_feed method returns the download URL.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_feed.return_value = MagicMock(url="https://feed.example/file") + + result = self._build_api().get_feed("feed-name") + + self.assertEqual("https://feed.example/file", result) + request = mock_api.get_feed.call_args.kwargs["get_feed_request_content"] + self.assertEqual("feed-name", request.feed_name) + self.assertIsNone(request.feed_type) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_feed_with_feed_type( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_feed forwards the feed type to the SDK request.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_feed.return_value = MagicMock(url="https://feed.example/file") + + self._build_api().get_feed("feed-name", feed_type=FeedType.DEALS_FEEDS) + + request = mock_api.get_feed.call_args.kwargs["get_feed_request_content"] + self.assertEqual(FeedType.DEALS_FEEDS, request.feed_type) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_feed_api_exception( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_feed raises a wrapped error on API failure.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_feed.side_effect = ApiException(status=404) + + with self.assertRaises(ItemsNotFoundError): + self._build_api().get_feed("missing-feed") + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_list_reports( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test list_reports method returns the available reports.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + report = MagicMock() + mock_api.list_reports.return_value = MagicMock(reports=[report]) + + result = self._build_api().list_reports() + + self.assertEqual([report], result) + mock_api.list_reports.assert_called_once_with(x_marketplace="www.amazon.es") + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_list_reports_api_exception( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test list_reports raises a wrapped error on API failure.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.list_reports.side_effect = ApiException(status=429) + + with self.assertRaises(TooManyRequestsError): + self._build_api().list_reports() + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_report( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_report method returns the download URL.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_report.return_value = MagicMock(url="https://report.example/file") + + result = self._build_api().get_report("report.csv") + + self.assertEqual("https://report.example/file", result) + request = mock_api.get_report.call_args.kwargs["get_report_request_content"] + self.assertEqual("report.csv", request.filename) + self.assertIsNone(request.report_type) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_report_with_report_type( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_report forwards the report type to the SDK request.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_report.return_value = MagicMock(url="https://report.example/file") + + self._build_api().get_report( + "report.csv", + report_type=ReportType.CREATOR_CONNECTIONS, + ) + + request = mock_api.get_report.call_args.kwargs["get_report_request_content"] + self.assertEqual(ReportType.CREATOR_CONNECTIONS, request.report_type) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_report_api_exception( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_report raises a wrapped error on API failure.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_report.side_effect = ApiException(status=500) + + with self.assertRaises(RequestError): + self._build_api().get_report("report.csv") diff --git a/tests/amazon_creatorsapi/integration_test.py b/tests/amazon_creatorsapi/integration_test.py index 12a7d47..266d9b6 100644 --- a/tests/amazon_creatorsapi/integration_test.py +++ b/tests/amazon_creatorsapi/integration_test.py @@ -223,11 +223,11 @@ def setUpClass(cls) -> None: cls._setup_browse_nodes_result(items) def test_search_items_returns_expected_count(self) -> None: - """Test that search returns the default number of items.""" - # API defaults to 10 + """Test that search returns no more items than the default page size.""" + # API defaults to a page size of 10, but may return fewer items items = self.search_result.items if items: - self.assertEqual(10, len(items)) + self.assertLessEqual(len(items), 10) def test_search_items_includes_affiliate_tag(self) -> None: """Test that search results include the affiliate tag in URLs."""