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()