From 92c4b6323d16588b4d10f58a1fa765f031be5536 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 21:06:16 +0000 Subject: [PATCH 1/3] chore: bump bundled creatorsapi-python-sdk from 1.2.0 to 1.3.0 Vendors the upstream 1.3.0 SDK archive. Changes relevant to the wrapper: - `FeedType` and `ReportType` enums, with `feedType` on `Feed` / `GetFeedRequestContent` and `reportType` on `ReportMetadata` / `GetReportRequestContent`, so callers can tell Creator Central from Creator Connections reports and route `GetFeed` / `GetReport` explicitly. - `partnerTag` is now required in `SearchItemsRequestContent` and the request body is now required in `DefaultApi.search_items`. Both wrapper layers already pass a partner tag and a request body on every call, so the public API is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uE6m9RVQtTiRDLxAyJpE5 --- CHANGELOG.md | 13 ++++++ creatorsapi_python_sdk/__init__.py | 2 + creatorsapi_python_sdk/api/default_api.py | 13 +++--- creatorsapi_python_sdk/api_client.py | 2 +- creatorsapi_python_sdk/models/__init__.py | 2 + creatorsapi_python_sdk/models/feed.py | 9 ++-- creatorsapi_python_sdk/models/feed_type.py | 44 +++++++++++++++++++ .../models/get_feed_request_content.py | 9 ++-- .../models/get_report_request_content.py | 9 ++-- .../models/report_metadata.py | 9 ++-- creatorsapi_python_sdk/models/report_type.py | 44 +++++++++++++++++++ .../models/search_items_request_content.py | 5 +-- docs/conf.py | 2 +- pyproject.toml | 2 +- 14 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 creatorsapi_python_sdk/models/feed_type.py create mode 100644 creatorsapi_python_sdk/models/report_type.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c7005..c1df582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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 + +- `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` + ## [6.3.0] - 2026-05-15 ### Added 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/pyproject.toml b/pyproject.toml index 4b8f0e0..88d747c 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" From 127eca9372daae8877425155247da4a518d7ebde Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 21:22:33 +0000 Subject: [PATCH 2/3] fix: relax search integration assertion and pin ruff to the pre-commit version `test_search_items_returns_expected_count` asserted an exact page of 10 items; SearchItems treats 10 as a maximum, so a live search returning 9 failed CI in both the sync and async suites. Assert the upper bound instead, the invariant the API actually guarantees. The dev group allowed any ruff `>=0.14.11` while pre-commit pins `v0.14.11`. With ruff 0.16 resolved locally, `make lint` reported 61 errors from newly stabilised rules (CPY001, PLR0917) that CI never sees. Pin the exact version so both run the same linter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uE6m9RVQtTiRDLxAyJpE5 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- tests/amazon_creatorsapi/aio/integration_test.py | 5 +++-- tests/amazon_creatorsapi/integration_test.py | 6 +++--- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1df582..0c7a1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/pyproject.toml b/pyproject.toml index 88d747c..7ecaf0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/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/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.""" From 5d9d3dc9724ae86542201f209d6a2cff7b4f6495 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 21:47:32 +0000 Subject: [PATCH 3/3] feat: add feeds and reports support to the Creators API wrapper The bundled SDK exposes listFeeds, getFeed, listReports and getReport, and 1.3.0 added the FeedType and ReportType enums that disambiguate a name present in more than one program (a report in both Creator Central and Creator Connections, for instance). None of it was reachable from the wrapper, so the new types had no caller. Both layers now expose `list_feeds`, `get_feed`, `list_reports` and `get_report`, with the type as an optional filter. The get operations return the download URL directly, the only field in their responses. listFeeds and listReports take no request payload, so the async `_make_request` and the HTTP client now accept an optional body and send the request without one, matching what the SDK does over urllib3. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uE6m9RVQtTiRDLxAyJpE5 --- CHANGELOG.md | 2 + README.md | 26 +++ amazon_creatorsapi/aio/api.py | 113 +++++++++- amazon_creatorsapi/aio/client.py | 5 +- amazon_creatorsapi/api.py | 110 ++++++++++ amazon_creatorsapi/models.py | 9 + docs/pages/usage-guide.md | 25 +++ tests/amazon_creatorsapi/aio/api_test.py | 259 +++++++++++++++++++++++ tests/amazon_creatorsapi/api_test.py | 198 +++++++++++++++++ 9 files changed, 743 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7a1ca..c241184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 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/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/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/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")