From 9843b43f4140b2af816f3282fcc805afefcf3bbc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:04:02 +0000 Subject: [PATCH] fix: keep the code of version 6 working and document what still breaks The clients gained arguments and errors gained types since version 6, and some of that broke code that had no reason to break. Three changes give it back without giving up what the new behaviour is for: - availability sat between item_page and condition in search_items, so a caller not using keywords bound every following value to the wrong argument, silently. It moves to the end and becomes keyword only, which also keeps it from displacing anything in the future. - A request that fails to authenticate or is denied raised RequestError before it got its own type, so AuthenticationError and AccessDeniedError become subclasses of it. - An invalid value raised the ValidationError of pydantic or a plain ValueError, both ValueError subclasses, so InvalidArgumentError becomes one too. An unsupported version now raises it as well, instead of the plain ValueError it kept for no reason. The signatures of both clients and the hierarchy of the errors are pinned by tests, as they are the promise this commit is making. What is left breaking is what cannot be given back without restoring a bug, and is now covered by a migration guide: the errors mapped from the response, the values the async client no longer sends unvalidated, the items not found, the timeout and the retries of the requests, the ASIN of a malformed URL and the configuration each client keeps for itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017bWGetbCpBr2upxna7R5QV --- CHANGELOG.md | 6 + README.md | 1 + amazon_creatorsapi/aio/api.py | 21 +- amazon_creatorsapi/aio/auth.py | 3 +- amazon_creatorsapi/api.py | 21 +- amazon_creatorsapi/core/oauth.py | 10 +- amazon_creatorsapi/errors.py | 24 +- docs/index.rst | 10 +- docs/pages/migration-guide-7.md | 249 ++++++++++++++++++++ tests/amazon_creatorsapi/errors_test.py | 70 ++++++ tests/amazon_creatorsapi/signatures_test.py | 128 ++++++++++ 11 files changed, 508 insertions(+), 35 deletions(-) create mode 100644 docs/pages/migration-guide-7.md create mode 100644 tests/amazon_creatorsapi/errors_test.py create mode 100644 tests/amazon_creatorsapi/signatures_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d999d..e8a0022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Migration guide from version 6, listing what can break in code written for `amazon_creatorsapi` and how to fix it - The values accepted by `version`, the countries and the marketplace each one maps to, and the `marketplace` argument are documented, instead of having to read the code to find them - `CONTRIBUTING.md`, with the setup of the project, the commands of the `Makefile`, the conventions of the code and the tests, and what a pull request is expected to carry +- Tests pinning the signatures of both clients and the hierarchy of the errors, so a change that breaks the code of the users is noticed ### Changed +- `availability` is a keyword only argument of `search_items`, placed after the rest, instead of sitting between `item_page` and `condition`, where it displaced every following argument of a caller not using keywords +- `AuthenticationError` and `AccessDeniedError` are subclasses of `RequestError`, which is what a failed request raised before they got their own type +- `InvalidArgumentError` is also a `ValueError`, as the `pydantic.ValidationError` and the plain `ValueError` it replaced were +- An unsupported `version` raises `InvalidArgumentError` instead of a plain `ValueError`, like the rest of the arguments of the clients - A `version` of a family that the library cannot authenticate is rejected even when `auth_endpoint` is given, instead of being sent with the Cognito flow and rejected by Amazon without an explanation - The error of an unsupported version tells that a newer version of a known family can be used by providing its `auth_endpoint` - The auth flow of a version and the `Authorization` header it expects are decided in a single place, and the copies bundled in the SDK are pinned to them by tests, so a bump of the SDK cannot leave both halves disagreeing diff --git a/README.md b/README.md index 8e81556..952c1e5 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,7 @@ back as `None`. - 📖 [Full documentation](https://python-amazon-paapi.readthedocs.io/) - 📘 [Usage guide](https://python-amazon-paapi.readthedocs.io/en/latest/pages/usage-guide.html) +- 🔀 [Migration guide from version 6](https://python-amazon-paapi.readthedocs.io/en/latest/pages/migration-guide-7.html) - 🔀 [Migration guide from `amazon_paapi`](https://python-amazon-paapi.readthedocs.io/en/latest/pages/migration-guide-6.html) - 📋 [Changelog](https://github.com/sergioteula/python-amazon-paapi/blob/master/CHANGELOG.md) - 💬 [Telegram support group](https://t.me/PythonAmazonPAAPI) diff --git a/amazon_creatorsapi/aio/api.py b/amazon_creatorsapi/aio/api.py index a5afdc5..f3f67db 100644 --- a/amazon_creatorsapi/aio/api.py +++ b/amazon_creatorsapi/aio/api.py @@ -178,13 +178,12 @@ class AsyncAmazonCreatorsApi: Raises: InvalidArgumentError: If neither country nor marketplace is provided, - if timeout is not greater than zero, if throttling is negative or - if retries is negative. - ValueError: If the version is not one of the supported ones, which - the message of the error lists, and no auth_endpoint is given, or - if it belongs to a family that the library cannot authenticate - (2.x and 3.x are the supported ones), which no auth_endpoint - makes valid. + if timeout is not greater than zero, if throttling is negative, + if retries is negative, if the version is not one of the supported + ones, which the message of the error lists, and no auth_endpoint + is given, or if it belongs to a family that the library cannot + authenticate (2.x and 3.x are the supported ones), which no + auth_endpoint makes valid. """ @@ -346,7 +345,6 @@ async def search_items( search_index: str | None = None, item_count: int | None = None, item_page: int | None = None, - availability: Availability | None = None, condition: Condition | None = None, currency_of_preference: str | None = None, delivery_flags: list[DeliveryFlag] | None = None, @@ -357,6 +355,8 @@ async def search_items( min_reviews_rating: int | None = None, sort_by: SortBy | None = None, resources: list[SearchItemsResource] | None = None, + *, + availability: Availability | None = None, ) -> SearchResult: """Search for items on Amazon based on a search query. @@ -374,8 +374,6 @@ async def search_items( search_index: Product category to search. Defaults to All. item_count: Number of items returned (1-100). Defaults to 10. item_page: Page of items to return (1-10). Defaults to 1. - availability: Filter results by availability. Defaults to - returning only the items available for purchase. condition: Filter offers by condition type. currency_of_preference: ISO 4217 currency code for prices. delivery_flags: Delivery programs to filter search results by. @@ -386,6 +384,9 @@ async def search_items( min_reviews_rating: Min review rating (1-4). sort_by: Sort method for results. resources: List of resources to retrieve. Defaults to all. + availability: Filter results by availability. Defaults to + returning only the items available for purchase. Keyword only, + so it does not shift the position of the other arguments. Returns: SearchResult containing the list of items. diff --git a/amazon_creatorsapi/aio/auth.py b/amazon_creatorsapi/aio/auth.py index a38da26..e14a1e1 100644 --- a/amazon_creatorsapi/aio/auth.py +++ b/amazon_creatorsapi/aio/auth.py @@ -100,7 +100,8 @@ def _determine_auth_endpoint( The OAuth2 token endpoint URL. Raises: - ValueError: If version is not supported and no custom endpoint provided. + InvalidArgumentError: If version is not supported and no custom + endpoint provided. """ return get_auth_endpoint(version, auth_endpoint) diff --git a/amazon_creatorsapi/api.py b/amazon_creatorsapi/api.py index cec6935..b13776e 100644 --- a/amazon_creatorsapi/api.py +++ b/amazon_creatorsapi/api.py @@ -119,13 +119,12 @@ class AmazonCreatorsApi: Raises: InvalidArgumentError: If neither country nor marketplace is provided, - if timeout is not greater than zero, if throttling is negative or - if retries is negative. - ValueError: If the version is not one of the supported ones, which - the message of the error lists, and no auth_endpoint is given, or - if it belongs to a family that the library cannot authenticate - (2.x and 3.x are the supported ones), which no auth_endpoint - makes valid. + if timeout is not greater than zero, if throttling is negative, + if retries is negative, if the version is not one of the supported + ones, which the message of the error lists, and no auth_endpoint + is given, or if it belongs to a family that the library cannot + authenticate (2.x and 3.x are the supported ones), which no + auth_endpoint makes valid. Example: >>> api = AmazonCreatorsApi( @@ -310,7 +309,6 @@ def search_items( search_index: str | None = None, item_count: int | None = None, item_page: int | None = None, - availability: Availability | None = None, condition: Condition | None = None, currency_of_preference: str | None = None, delivery_flags: list[DeliveryFlag] | None = None, @@ -321,6 +319,8 @@ def search_items( min_reviews_rating: int | None = None, sort_by: SortBy | None = None, resources: list[SearchItemsResource] | None = None, + *, + availability: Availability | None = None, ) -> SearchResult: """Search for items on Amazon based on a search query. @@ -338,8 +338,6 @@ def search_items( search_index: Product category to search. Defaults to All. item_count: Number of items returned (1-100). Defaults to 10. item_page: Page of items to return (1-10). Defaults to 1. - availability: Filter results by availability. Defaults to - returning only the items available for purchase. condition: Filter offers by condition type. currency_of_preference: ISO 4217 currency code for prices. delivery_flags: Delivery programs to filter search results by. @@ -350,6 +348,9 @@ def search_items( min_reviews_rating: Min review rating (1-4). sort_by: Sort method for results. resources: List of resources to retrieve. Defaults to all. + availability: Filter results by availability. Defaults to + returning only the items available for purchase. Keyword only, + so it does not shift the position of the other arguments. Returns: SearchResult containing the list of items. diff --git a/amazon_creatorsapi/core/oauth.py b/amazon_creatorsapi/core/oauth.py index ef5928f..f768aa0 100644 --- a/amazon_creatorsapi/core/oauth.py +++ b/amazon_creatorsapi/core/oauth.py @@ -2,6 +2,8 @@ from __future__ import annotations +from amazon_creatorsapi.errors import InvalidArgumentError + # Scopes and grant type accepted by the auth endpoints of Amazon COGNITO_SCOPE = "creatorsapi/default" LWA_SCOPE = "creatorsapi::default" @@ -107,8 +109,8 @@ def get_auth_endpoint(version: str, auth_endpoint: str | None = None) -> str: The URL used to get the OAuth2 token. Raises: - ValueError: If the family of the version is unknown, or if the version - is not in the list and no endpoint is given. + InvalidArgumentError: If the family of the version is unknown, or if + the version is not in the list and no endpoint is given. """ endpoint = auth_endpoint.strip() if auth_endpoint else "" @@ -126,7 +128,7 @@ def get_auth_endpoint(version: str, auth_endpoint: str | None = None) -> str: f"authenticate the {families} versions, so a newer one needs " f"support added to the library and not just a custom auth_endpoint" ) - raise ValueError(msg) + raise InvalidArgumentError(msg) if not endpoint: supported = ", ".join(VERSION_ENDPOINTS) @@ -135,6 +137,6 @@ def get_auth_endpoint(version: str, auth_endpoint: str | None = None) -> str: f"{supported}. A newer version of a known family can be used by " f"providing its auth_endpoint" ) - raise ValueError(msg) + raise InvalidArgumentError(msg) return endpoint diff --git a/amazon_creatorsapi/errors.py b/amazon_creatorsapi/errors.py index 00ac6fd..1d9b887 100644 --- a/amazon_creatorsapi/errors.py +++ b/amazon_creatorsapi/errors.py @@ -5,8 +5,12 @@ class AmazonCreatorsApiError(Exception): """Base exception for Amazon Creators API.""" -class InvalidArgumentError(AmazonCreatorsApiError): - """Raised when an invalid argument is provided.""" +class InvalidArgumentError(AmazonCreatorsApiError, ValueError): + """Raised when an invalid argument is provided. + + Also a ValueError, so the code written against the errors that pydantic + and the version check raised before keeps catching it. + """ class RequestError(AmazonCreatorsApiError): @@ -25,12 +29,20 @@ class AssociateValidationError(AmazonCreatorsApiError): """Raised when associate credentials are invalid.""" -class AuthenticationError(AmazonCreatorsApiError): - """Raised when OAuth2 authentication fails.""" +class AuthenticationError(RequestError): + """Raised when OAuth2 authentication fails. + + A request that fails to authenticate is a failed request, so it is also a + RequestError. + """ + +class AccessDeniedError(RequestError): + """Raised when the credentials cannot perform the requested operation. -class AccessDeniedError(AmazonCreatorsApiError): - """Raised when the credentials cannot perform the requested operation.""" + A request rejected for lack of access is a failed request, so it is also + a RequestError. + """ class ResourceNotFoundError(AmazonCreatorsApiError): diff --git a/docs/index.rst b/docs/index.rst index 0a19ca7..92e725e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,15 +56,17 @@ API Reference amazon_creatorsapi.models -Migration guide ---------------- +Migration guides +---------------- -If you are still using the removed ``amazon_paapi`` module, follow this guide to move to -``amazon_creatorsapi``. +Follow the version 7 guide to upgrade code written for ``amazon_creatorsapi`` on version +6. If you are still using the removed ``amazon_paapi`` module, start with the Creators +API guide instead. .. toctree:: :maxdepth: 1 + ./pages/migration-guide-7.md ./pages/migration-guide-6.md Changelog diff --git a/docs/pages/migration-guide-7.md b/docs/pages/migration-guide-7.md new file mode 100644 index 0000000..cd24e77 --- /dev/null +++ b/docs/pages/migration-guide-7.md @@ -0,0 +1,249 @@ +# Version 7 migration guide + +This guide covers what can break in code written for `amazon_creatorsapi` on version 6 +when upgrading to version 7. If you are still importing the removed `amazon_paapi` +module, read the [Creators API migration guide](migration-guide-6.md) first: that is a +rewrite, and this guide starts where it ends. + +Most code needs no change at all. Skip to [Nothing to do](#nothing-to-do) to rule out +the changes that look breaking and are not. + +## At a glance + +| Change | Breaks | Fix | +| --- | --- | --- | +| A rejected request raises `InvalidArgumentError` | `except RequestError` around a `400` | Catch `AmazonCreatorsApiError`, or add `InvalidArgumentError` | +| Values are validated before the request | `except pydantic.ValidationError` | Catch `InvalidArgumentError` | +| The async client validates like the synchronous one | Values out of the ranges of the API | Send values the API accepts | +| `search_items` needs a criterion | A search with only filters | Add `keywords` or any other criterion | +| `get_items` raises when nothing is found | `if not items:` | Catch `ItemsNotFoundError`, or use `include_unavailable` | +| `get_items` returns the requested order | Reading the response by position | Read it by position, or match by `asin` | +| The synchronous client times out | Requests above 30 seconds | Pass `timeout` | +| Failed requests are retried | Handling a `429` yourself | Pass `retries=0` | +| `get_asin` rejects a long identifier | Malformed URLs | Fix the URLs | +| The version is validated on creation | An unsupported `version` | Use a supported one, or pass `auth_endpoint` | +| Every client has its own SDK configuration | `Configuration.set_default()` | Pass `host` | + +## Errors + +The errors are now taken from the response of the Creators API instead of the codes of +the old Product Advertising API, so each failure has the type that describes it: + +| Status | Version 6 | Version 7 | +| --- | --- | --- | +| `400` | `InvalidArgumentError` or `RequestError` | `InvalidArgumentError` | +| `400` for an invalid associate | `AssociateValidationError` | `AssociateValidationError` | +| `401` | `RequestError` | `AuthenticationError` | +| `403` | `RequestError` | `AccessDeniedError` | +| `404` | `ItemsNotFoundError` | `ItemsNotFoundError`, or `ResourceNotFoundError` for feeds and reports | +| `429` | `TooManyRequestsError` | `TooManyRequestsError` | +| Anything else | `RequestError` | `RequestError` | + +`AuthenticationError` and `AccessDeniedError` are subclasses of `RequestError`, so code +catching `RequestError` keeps working for `401` and `403`. The one case that changes is +a `400` whose body did not name the invalid parameter, which used to be a `RequestError` +and is now an `InvalidArgumentError`: + +```python +from amazon_creatorsapi.errors import AmazonCreatorsApiError + +try: + items = api.get_items(["B01N5IB20Q"]) +except AmazonCreatorsApiError as error: # Catches every error of the library + print(error) +``` + +The message of an error now carries the reason given by Amazon, the fields it rejected +and the identifier of the request, so any code matching on the text of a message has to +be reviewed. + +## Invalid values + +Values rejected by the constraints of the API raise `InvalidArgumentError` instead of +the `ValidationError` of pydantic, so the library no longer leaks the errors of its +dependencies: + +```python +from amazon_creatorsapi.errors import InvalidArgumentError + +try: + api.search_items(keywords="laptop", min_reviews_rating=5) +except InvalidArgumentError as error: + print(error) # Invalid parameters for the request: minReviewsRating: ... +``` + +`InvalidArgumentError` is also a `ValueError`, which `pydantic.ValidationError` is too, +so an `except ValueError` written for version 6 keeps catching it. Only the code +catching `pydantic.ValidationError` by name has to be changed. + +The asynchronous client used to send its requests without validating them, so it +accepted values that Amazon rejected. It now validates the same values as the +synchronous one, and a request that used to fail with a `400` fails locally instead: + +| Argument | Accepted | +| --- | --- | +| `item_count` | 1 to 100 | +| `item_page` | 1 to 10 | +| `variation_count` | 1 to 10 | +| `variation_page` | 1 or greater | +| `min_reviews_rating` | 1 to 4 | +| `min_saving_percent` | 1 to 99 | +| `max_price`, `min_price` | 1 or greater | + +`throttling`, `timeout` and `retries` are validated as well, so a negative or +non-numeric value raises `InvalidArgumentError` when the client is created instead of +failing later with a `TypeError`. + +## Searching without a criterion + +`search_items` needs at least one of `keywords`, `actor`, `artist`, `author`, `brand`, +`title`, `browse_node_id` or `search_index`. A search carrying only filters used to be +sent to Amazon and now raises `InvalidArgumentError`: + +```python +api.search_items(min_price=1000) # InvalidArgumentError +api.search_items(keywords="laptop", min_price=1000) # Correct +``` + +## Items that are not found + +`get_items` raises `ItemsNotFoundError` when the response holds none of the requested +items, as it was documented to do and as `search_items` already did. It used to return +an empty list in some responses: + +```python +from amazon_creatorsapi.errors import ItemsNotFoundError + +try: + items = api.get_items(["B01N5IB20Q", "0000000000"]) +except ItemsNotFoundError as error: + items = [] +``` + +To get a result without handling the exception, ask for the missing items as well. Every +identifier that Amazon did not return comes back as an `Item` holding only its `asin`: + +```python +items = api.get_items(["B01N5IB20Q", "0000000000"], include_unavailable=True) +``` + +Either way, the reason for every missing item is available in the `errors` attribute of +the returned list: + +```python +for error in items.errors: + print(error.code, error.message) +``` + +## The order of the items + +`get_items` returns the items in the order they were requested, instead of the order +Amazon sent them in, and asks for duplicated identifiers only once. Reading the response +by position is now correct, but the length of the list still does not have to match the +amount of identifiers, as Amazon can leave items out: + +```python +items = api.get_items(["B01N5IB20Q", "B01N5IB20Q", "0000000000"]) +len(items) # 1, not 3 + +items = api.get_items(["B01N5IB20Q", "0000000000"], include_unavailable=True) +len(items) # 2, one item per identifier, in the order they were requested +``` + +`get_items` also splits a request with more than ten identifiers into as many calls as +needed, so it no longer fails for a long list. Keep in mind that a call is sent for +every ten items, each one waiting for the configured `throttling`. + +## Timeouts + +The synchronous client waited indefinitely for a response. It now uses the same 30 +second timeout that the asynchronous one already had: + +```python +api = AmazonCreatorsApi(..., timeout=60) # Wait up to a minute +api = AmazonCreatorsApi(..., timeout=None) # Wait indefinitely, as version 6 did +``` + +The timeout applies to each request, so a `get_items` split into several calls gets the +whole timeout for each one of them. + +## Retries + +Both clients retry the requests that Amazon asks to retry, which are the ones failing +with `429`, `500`, `502`, `503` and `504`. Every attempt waits longer than the previous +one, honouring the `Retry-After` header when the response carries it, up to 30 seconds +per wait. + +The type of the error does not change, so nothing has to be caught differently, but a +throttled call now takes longer before raising `TooManyRequestsError`. Disable the +retries to get the behaviour of version 6, which is what you want if your code already +implements its own backoff: + +```python +api = AmazonCreatorsApi(..., retries=0) +``` + +## ASINs in URLs + +`get_asin`, used by `get_items` for every identifier it receives, no longer trims an +identifier longer than ten characters. A URL such as `.../dp/B01N5IB20Q12` used to +return `B01N5IB20Q`, which is a different item, and now raises `InvalidArgumentError`. +Malformed URLs that silently returned the wrong item have to be fixed. + +## Versions + +Both clients resolve the auth endpoint from the same list of versions, and the +synchronous one validates the version when it is created instead of failing on the first +request. The error is an `InvalidArgumentError`, which is a `ValueError`, so an +`except ValueError` written for version 6 keeps working. + +A version of a family the library knows how to authenticate, which are `2.x` and `3.x`, +can be used before the library lists it by providing its endpoint: + +```python +api = AmazonCreatorsApi( + ..., + version="2.4", + auth_endpoint="https://creatorsapi.auth.eu-west-1.amazoncognito.com/oauth2/token", +) +``` + +## The configuration of the SDK + +Every client now builds its own configuration for the bundled SDK, instead of sharing +the one the SDK keeps for the whole process. Two clients in the same program no longer +overwrite each other, and a `Configuration.set_default()` no longer reaches them. Use +the `host` argument to send the requests somewhere else, which is useful to run tests +against a mock server: + +```python +api = AmazonCreatorsApi(..., host="http://localhost:8080") +``` + +The `auth_endpoint` argument does the same for the requests asking for a token. + +## Nothing to do + +These changes look breaking and are not: + +- `get_items` and `get_browse_nodes` return a `ResultList`, which is a `list` carrying + the partial errors of the response in its `errors` attribute. It behaves like any + other list. +- The arguments of `search_items` keep the position they had in version 6. `availability` + was added as a keyword only argument, after the rest, so it cannot displace them. +- `except RequestError` still catches the failures of a request that got a `401` or a + `403`, and `except ValueError` still catches an invalid value. +- Requesting more than ten items at once works instead of failing. +- The exceptions of the library are all subclasses of `AmazonCreatorsApiError`, which is + the safest thing to catch. + +## New in version 7 + +Nothing here breaks existing code, but it may replace it: + +- `AmazonCreatorsApi` closes its connections with `close()` or as a context manager, + which the asynchronous client already supported. +- `list_feeds`, `get_feed`, `list_reports` and `get_report` in both clients. +- `availability` in `search_items`, to include the items that are out of stock. +- `errors` and `get_asin` are available directly in `amazon_creatorsapi`. +- The package ships a `py.typed` marker, so type checkers use its type hints. diff --git a/tests/amazon_creatorsapi/errors_test.py b/tests/amazon_creatorsapi/errors_test.py new file mode 100644 index 0000000..687dc29 --- /dev/null +++ b/tests/amazon_creatorsapi/errors_test.py @@ -0,0 +1,70 @@ +"""Tests that pin the hierarchy of the errors of the library. + +The hierarchy is what keeps the code written for an older version working +when an error gets a more precise type, so every relation checked here is a +promise made to the users and not an implementation detail. +""" + +from __future__ import annotations + +import unittest + +from amazon_creatorsapi.errors import ( + AccessDeniedError, + AmazonCreatorsApiError, + AssociateValidationError, + AuthenticationError, + InvalidArgumentError, + ItemsNotFoundError, + RequestError, + ResourceNotFoundError, + TooManyRequestsError, +) + +ERRORS = [ + AccessDeniedError, + AssociateValidationError, + AuthenticationError, + InvalidArgumentError, + ItemsNotFoundError, + RequestError, + ResourceNotFoundError, + TooManyRequestsError, +] + + +class TestErrorHierarchy(unittest.TestCase): + """Tests for the relations between the errors of the library.""" + + def test_every_error_shares_the_base(self) -> None: + """Test that a single except catches anything raised by the library.""" + for error in ERRORS: + with self.subTest(error=error.__name__): + self.assertTrue(issubclass(error, AmazonCreatorsApiError)) + + def test_failures_of_a_request_are_request_errors(self) -> None: + """Test that the errors of a rejected request keep being caught. + + Authentication and access failures were reported as RequestError + before they got their own type, so they stay under it. + """ + self.assertTrue(issubclass(AuthenticationError, RequestError)) + self.assertTrue(issubclass(AccessDeniedError, RequestError)) + + def test_an_invalid_argument_is_a_value_error(self) -> None: + """Test that an invalid value keeps being caught as a ValueError. + + The values rejected by the API constraints raised the ValidationError + of pydantic, and an unsupported version raised a plain ValueError, + both of which are ValueError subclasses. + """ + self.assertTrue(issubclass(InvalidArgumentError, ValueError)) + + def test_a_missing_resource_is_not_a_missing_item(self) -> None: + """Test that feeds and reports are told apart from items.""" + self.assertFalse(issubclass(ResourceNotFoundError, ItemsNotFoundError)) + self.assertFalse(issubclass(ItemsNotFoundError, ResourceNotFoundError)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/amazon_creatorsapi/signatures_test.py b/tests/amazon_creatorsapi/signatures_test.py new file mode 100644 index 0000000..eb9489b --- /dev/null +++ b/tests/amazon_creatorsapi/signatures_test.py @@ -0,0 +1,128 @@ +"""Tests that pin the signatures of the public methods of both clients. + +The position of an argument is part of the API: inserting one in the middle +silently binds the values of a caller that does not use keywords to the wrong +parameter. A new argument therefore goes at the end, and preferably as +keyword only, which is what these tests check. +""" + +from __future__ import annotations + +import inspect +import unittest +from typing import Callable + +from amazon_creatorsapi import AmazonCreatorsApi +from amazon_creatorsapi.aio import AsyncAmazonCreatorsApi + +# Positional arguments of every public method, in the order they are accepted +POSITIONAL_ARGUMENTS = { + "__init__": [ + "credential_id", + "credential_secret", + "version", + "tag", + "country", + "marketplace", + "throttling", + "timeout", + "retries", + "host", + "auth_endpoint", + ], + "get_items": [ + "items", + "condition", + "currency_of_preference", + "languages_of_preference", + "resources", + ], + "search_items": [ + "keywords", + "actor", + "artist", + "author", + "brand", + "title", + "browse_node_id", + "search_index", + "item_count", + "item_page", + "condition", + "currency_of_preference", + "delivery_flags", + "languages_of_preference", + "max_price", + "min_price", + "min_saving_percent", + "min_reviews_rating", + "sort_by", + "resources", + ], + "get_variations": [ + "asin", + "variation_count", + "variation_page", + "condition", + "currency_of_preference", + "languages_of_preference", + "resources", + ], + "get_browse_nodes": [ + "browse_node_ids", + "languages_of_preference", + "resources", + ], + "list_feeds": [], + "get_feed": ["feed_name", "feed_type"], + "list_reports": [], + "get_report": ["filename", "report_type"], +} + +# Arguments that can only be given by name, added after the ones above +KEYWORD_ONLY_ARGUMENTS = { + "get_items": ["include_unavailable"], + "search_items": ["availability"], +} + +CLIENTS = [AmazonCreatorsApi, AsyncAmazonCreatorsApi] + + +def get_arguments(method: Callable[..., object], kind: object) -> list[str]: + """Return the names of the arguments of a method for a kind of parameter.""" + parameters = inspect.signature(method).parameters.values() + return [ + parameter.name + for parameter in parameters + if parameter.kind == kind and parameter.name != "self" + ] + + +class TestClientSignatures(unittest.TestCase): + """Tests for the arguments accepted by the methods of both clients.""" + + def test_positional_arguments(self) -> None: + """Test that the positional arguments keep their name and their order.""" + for client in CLIENTS: + for name, expected in POSITIONAL_ARGUMENTS.items(): + with self.subTest(client=client.__name__, method=name): + arguments = get_arguments( + getattr(client, name), + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + self.assertEqual(arguments, expected) + + def test_keyword_only_arguments(self) -> None: + """Test that the arguments given by name are the expected ones.""" + for client in CLIENTS: + for name in POSITIONAL_ARGUMENTS: + with self.subTest(client=client.__name__, method=name): + arguments = get_arguments( + getattr(client, name), + inspect.Parameter.KEYWORD_ONLY, + ) + self.assertEqual(arguments, KEYWORD_ONLY_ARGUMENTS.get(name, [])) + + +if __name__ == "__main__": + unittest.main()