Skip to content

Commit 5d8730d

Browse files
committed
add offers v2 compatibility
1 parent 21bb2e9 commit 5d8730d

File tree

4 files changed

+324
-16
lines changed

4 files changed

+324
-16
lines changed

amazon_paapi/models/item_result.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,99 @@ class ApiBrowseNodeInfo(sdk_models.BrowseNodeInfo):
340340
website_sales_rank: ApiWebsiteSalesRank
341341

342342

343+
class ApiMoney(sdk_models.Money):
344+
"""Money representation for OffersV2 price fields."""
345+
346+
amount: float
347+
currency: str
348+
display_amount: str
349+
350+
351+
class ApiOfferAvailabilityV2(sdk_models.OfferAvailabilityV2):
352+
"""OffersV2 availability information."""
353+
354+
max_order_quantity: int
355+
message: str
356+
min_order_quantity: int
357+
type: str
358+
359+
360+
class ApiOfferConditionV2(sdk_models.OfferConditionV2):
361+
"""OffersV2 condition information."""
362+
363+
condition_note: str
364+
sub_condition: str
365+
value: str
366+
367+
368+
class ApiDealDetails(sdk_models.DealDetails):
369+
"""Deal details for OffersV2 listings."""
370+
371+
access_type: str
372+
badge: str
373+
early_access_duration_in_milliseconds: int
374+
end_time: str
375+
percent_claimed: int
376+
start_time: str
377+
378+
379+
class ApiOfferLoyaltyPointsV2(sdk_models.OfferLoyaltyPointsV2):
380+
"""OffersV2 loyalty points information."""
381+
382+
points: int
383+
384+
385+
class ApiOfferMerchantInfoV2(sdk_models.OfferMerchantInfoV2):
386+
"""OffersV2 merchant information."""
387+
388+
id: str
389+
name: str
390+
391+
392+
class ApiOfferSavingBasis(sdk_models.OfferSavingBasis):
393+
"""Saving basis information for OffersV2."""
394+
395+
money: ApiMoney
396+
saving_basis_type: str
397+
saving_basis_type_label: str
398+
399+
400+
class ApiOfferSavingsV2(sdk_models.OfferSavingsV2):
401+
"""OffersV2 savings information."""
402+
403+
money: ApiMoney
404+
percentage: int
405+
406+
407+
class ApiOfferPriceV2(sdk_models.OfferPriceV2):
408+
"""OffersV2 price information."""
409+
410+
money: ApiMoney
411+
price_per_unit: ApiMoney
412+
saving_basis: ApiOfferSavingBasis
413+
savings: ApiOfferSavingsV2
414+
415+
416+
class ApiListingsV2(sdk_models.OfferListingV2):
417+
"""OffersV2 listing with all details."""
418+
419+
availability: ApiOfferAvailabilityV2
420+
condition: ApiOfferConditionV2
421+
deal_details: ApiDealDetails
422+
is_buy_box_winner: bool
423+
loyalty_points: ApiOfferLoyaltyPointsV2
424+
merchant_info: ApiOfferMerchantInfoV2
425+
price: ApiOfferPriceV2
426+
type: sdk_models.OfferType
427+
violates_map: bool
428+
429+
430+
class ApiOffersV2(sdk_models.OffersV2):
431+
"""Container for OffersV2 listings."""
432+
433+
listings: list[ApiListingsV2]
434+
435+
343436
class Item(sdk_models.Item):
344437
"""Amazon product item with all details."""
345438

@@ -350,6 +443,7 @@ class Item(sdk_models.Item):
350443
images: ApiImages
351444
item_info: ApiItemInfo
352445
offers: ApiOffers
446+
offers_v2: ApiOffersV2
353447
parent_asin: str
354448
rental_offers: sdk_models.RentalOffers
355449
score: float

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ build-backend = "hatchling.build"
4242
packages = ["amazon_paapi"]
4343

4444
[dependency-groups]
45-
dev = ["pre-commit>=2.21.0", "pytest>=7.4.4", "pytest-cov>=4.1.0"]
45+
dev = [
46+
"mypy>=1.19.1",
47+
"pre-commit>=2.21.0",
48+
"pytest>=7.4.4",
49+
"pytest-cov>=4.1.0",
50+
"ruff>=0.14.11",
51+
]
4652

4753
[tool.ruff]
4854
target-version = "py39"

tests/integration_test.py

Lines changed: 144 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,165 @@
33
from __future__ import annotations
44

55
import os
6+
from typing import TYPE_CHECKING, ClassVar, cast
67
from unittest import TestCase, skipUnless
78

89
from amazon_paapi.api import AmazonApi
910

11+
if TYPE_CHECKING:
12+
from amazon_paapi.models import BrowseNode, CountryCode
13+
from amazon_paapi.models.item_result import Item
14+
from amazon_paapi.models.search_result import SearchResult
15+
from amazon_paapi.models.variations_result import VariationsResult
1016

11-
def get_api_credentials() -> tuple[str | None, str | None, str | None, str | None]:
12-
api_key = os.environ.get("API_KEY")
13-
api_secret = os.environ.get("API_SECRET")
14-
affiliate_tag = os.environ.get("AFFILIATE_TAG")
15-
country_code = os.environ.get("COUNTRY_CODE")
1617

17-
return api_key, api_secret, affiliate_tag, country_code
18+
def get_api_credentials() -> tuple[str, str, str, CountryCode]:
19+
"""Get API credentials from environment variables.
1820
21+
Raises:
22+
ValueError: If any required credential is missing.
23+
"""
24+
credentials = {
25+
"API_KEY": os.environ.get("API_KEY"),
26+
"API_SECRET": os.environ.get("API_SECRET"),
27+
"AFFILIATE_TAG": os.environ.get("AFFILIATE_TAG"),
28+
"COUNTRY_CODE": os.environ.get("COUNTRY_CODE"),
29+
}
1930

20-
@skipUnless(all(get_api_credentials()), "Needs Amazon API credentials")
31+
missing = [key for key, value in credentials.items() if value is None]
32+
if missing:
33+
msg = f"Missing environment variables: {', '.join(missing)}"
34+
raise ValueError(msg)
35+
36+
return (
37+
cast("str", credentials["API_KEY"]),
38+
cast("str", credentials["API_SECRET"]),
39+
cast("str", credentials["AFFILIATE_TAG"]),
40+
cast("CountryCode", credentials["COUNTRY_CODE"]),
41+
)
42+
43+
44+
def has_api_credentials() -> bool:
45+
"""Check if all API credentials are available."""
46+
try:
47+
get_api_credentials()
48+
except ValueError:
49+
return False
50+
return True
51+
52+
53+
@skipUnless(has_api_credentials(), "Needs Amazon API credentials")
2154
class IntegrationTest(TestCase):
55+
"""Integration tests that make real API calls to Amazon.
56+
57+
All API results are cached at class level to minimize the number of
58+
requests. This reduces costs and avoids rate limiting.
59+
"""
60+
61+
api: ClassVar[AmazonApi]
62+
affiliate_tag: ClassVar[str]
63+
search_result: ClassVar[SearchResult]
64+
item_with_offers: ClassVar[Item]
65+
get_items_result: ClassVar[list[Item]]
66+
variations_result: ClassVar[VariationsResult]
67+
browse_nodes_result: ClassVar[list[BrowseNode]]
68+
2269
@classmethod
23-
def setUpClass(cls):
70+
def setUpClass(cls) -> None:
71+
"""Set up API client and make shared API calls once for all tests."""
2472
api_key, api_secret, affiliate_tag, country_code = get_api_credentials()
73+
2574
cls.api = AmazonApi(api_key, api_secret, affiliate_tag, country_code)
2675
cls.affiliate_tag = affiliate_tag
2776

28-
def test_search_items_and_get_information_for_the_first_one(self):
29-
search_result = self.api.search_items(keywords="zapatillas")
30-
searched_item = search_result.items[0]
77+
cls.search_result = cls.api.search_items(keywords="laptop")
78+
79+
cls.item_with_offers = next(
80+
(item for item in cls.search_result.items if item.offers_v2 is not None),
81+
cls.search_result.items[0],
82+
)
83+
cls.get_items_result = cls.api.get_items(cls.item_with_offers.asin)
84+
85+
item_with_variations = next(
86+
(item for item in cls.search_result.items if item.parent_asin),
87+
cls.search_result.items[0],
88+
)
89+
cls.variations_result = cls.api.get_variations(
90+
item_with_variations.parent_asin or item_with_variations.asin
91+
)
92+
93+
item_with_browse_nodes = next(
94+
(
95+
item
96+
for item in cls.search_result.items
97+
if item.browse_node_info and item.browse_node_info.browse_nodes
98+
),
99+
None,
100+
)
101+
if item_with_browse_nodes:
102+
browse_node_id = item_with_browse_nodes.browse_node_info.browse_nodes[0].id
103+
cls.browse_nodes_result = cls.api.get_browse_nodes([browse_node_id])
104+
else:
105+
cls.browse_nodes_result = []
31106

32-
self.assertEqual(10, len(search_result.items))
107+
def test_search_items_returns_expected_count(self) -> None:
108+
"""Test that search returns the default number of items."""
109+
self.assertEqual(10, len(self.search_result.items))
110+
111+
def test_search_items_includes_affiliate_tag(self) -> None:
112+
"""Test that search results include the affiliate tag in URLs."""
113+
searched_item = self.search_result.items[0]
33114
self.assertIn(self.affiliate_tag, searched_item.detail_page_url)
34115

35-
get_results = self.api.get_items(searched_item.asin)
116+
def test_search_items_returns_offers_v2(self) -> None:
117+
"""Test that search results include OffersV2 data."""
118+
self.assertGreater(len(self.search_result.items), 0)
119+
120+
item = self.item_with_offers
121+
self.assertIsNotNone(item.offers_v2)
122+
123+
if item.offers_v2.listings:
124+
listing = item.offers_v2.listings[0]
125+
self.assertIsNotNone(listing)
126+
127+
def test_get_items_returns_single_result(self) -> None:
128+
"""Test that get_items returns exactly one item when given one ASIN."""
129+
self.assertEqual(1, len(self.get_items_result))
130+
131+
def test_get_items_includes_affiliate_tag(self) -> None:
132+
"""Test that get_items results include the affiliate tag in URLs."""
133+
self.assertIn(self.affiliate_tag, self.get_items_result[0].detail_page_url)
134+
135+
def test_get_items_returns_offers_v2(self) -> None:
136+
"""Test that get_items returns OffersV2 data."""
137+
item = self.get_items_result[0]
138+
self.assertIsNotNone(item.offers_v2)
139+
140+
if item.offers_v2.listings:
141+
listing = item.offers_v2.listings[0]
142+
self.assertIsNotNone(listing)
143+
144+
def test_get_variations_returns_items(self) -> None:
145+
"""Test that get_variations returns a list of variation items."""
146+
self.assertIsNotNone(self.variations_result)
147+
self.assertGreater(len(self.variations_result.items), 0)
148+
149+
def test_get_variations_returns_variation_summary(self) -> None:
150+
"""Test that get_variations returns variation summary."""
151+
self.assertIsNotNone(self.variations_result.variation_summary)
152+
self.assertGreater(self.variations_result.variation_summary.variation_count, 0)
153+
154+
def test_get_variations_items_include_affiliate_tag(self) -> None:
155+
"""Test that variation items include the affiliate tag in URLs."""
156+
item = self.variations_result.items[0]
157+
self.assertIn(self.affiliate_tag, item.detail_page_url)
158+
159+
def test_get_browse_nodes_returns_results(self) -> None:
160+
"""Test that get_browse_nodes returns browse node information."""
161+
self.assertGreater(len(self.browse_nodes_result), 0)
36162

37-
self.assertEqual(1, len(get_results))
38-
self.assertIn(self.affiliate_tag, get_results[0].detail_page_url)
163+
def test_get_browse_nodes_returns_node_info(self) -> None:
164+
"""Test that browse nodes contain expected information."""
165+
node = self.browse_nodes_result[0]
166+
self.assertIsNotNone(node.id)
167+
self.assertIsNotNone(node.display_name)

tests/offersv2_test.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Tests for OffersV2 functionality."""
2+
3+
import unittest
4+
5+
from amazon_paapi.helpers import requests
6+
from amazon_paapi.models.item_result import (
7+
ApiDealDetails,
8+
ApiListingsV2,
9+
ApiOfferPriceV2,
10+
ApiOfferSavingBasis,
11+
Item,
12+
)
13+
from amazon_paapi.sdk.models.get_items_resource import GetItemsResource
14+
from amazon_paapi.sdk.models.get_variations_resource import GetVariationsResource
15+
from amazon_paapi.sdk.models.search_items_resource import SearchItemsResource
16+
17+
EXPECTED_OFFERSV2_RESOURCES = [
18+
"OffersV2.Listings.Availability",
19+
"OffersV2.Listings.Condition",
20+
"OffersV2.Listings.DealDetails",
21+
"OffersV2.Listings.IsBuyBoxWinner",
22+
"OffersV2.Listings.LoyaltyPoints",
23+
"OffersV2.Listings.MerchantInfo",
24+
"OffersV2.Listings.Price",
25+
"OffersV2.Listings.Type",
26+
]
27+
28+
29+
class TestOffersV2Resources(unittest.TestCase):
30+
"""Test cases for OffersV2 resource constants."""
31+
32+
def test_get_items_resources_include_all_offersv2(self) -> None:
33+
"""Verify _get_request_resources includes all OffersV2 resources."""
34+
resources = requests._get_request_resources(GetItemsResource)
35+
for resource in EXPECTED_OFFERSV2_RESOURCES:
36+
self.assertIn(resource, resources)
37+
38+
def test_search_items_resources_include_all_offersv2(self) -> None:
39+
"""Verify _get_request_resources includes all OffersV2 resources for search."""
40+
resources = requests._get_request_resources(SearchItemsResource)
41+
for resource in EXPECTED_OFFERSV2_RESOURCES:
42+
self.assertIn(resource, resources)
43+
44+
def test_get_variations_resources_include_all_offersv2(self) -> None:
45+
"""Verify _get_request_resources includes OffersV2 resources."""
46+
resources = requests._get_request_resources(GetVariationsResource)
47+
for resource in EXPECTED_OFFERSV2_RESOURCES:
48+
self.assertIn(resource, resources)
49+
50+
51+
class TestOffersV2Models(unittest.TestCase):
52+
"""Test cases for OffersV2 model structure."""
53+
54+
def test_item_has_offers_v2_attribute(self) -> None:
55+
"""Verify Item class has offers_v2 typed as ApiOffersV2."""
56+
self.assertIn("offers_v2", Item.__annotations__)
57+
self.assertEqual(Item.__annotations__["offers_v2"], "ApiOffersV2")
58+
59+
def test_api_listings_v2_has_required_attributes(self) -> None:
60+
"""Verify ApiListingsV2 has violates_map and deal_details."""
61+
annotations = ApiListingsV2.__annotations__
62+
self.assertIn("violates_map", annotations)
63+
self.assertIn("deal_details", annotations)
64+
65+
def test_api_deal_details_has_required_attributes(self) -> None:
66+
"""Verify ApiDealDetails has badge and access_type."""
67+
annotations = ApiDealDetails.__annotations__
68+
self.assertIn("badge", annotations)
69+
self.assertIn("access_type", annotations)
70+
71+
def test_api_offer_price_v2_has_required_attributes(self) -> None:
72+
"""Verify ApiOfferPriceV2 has money and savings."""
73+
annotations = ApiOfferPriceV2.__annotations__
74+
self.assertIn("money", annotations)
75+
self.assertIn("savings", annotations)
76+
77+
def test_api_offer_saving_basis_has_saving_basis_type(self) -> None:
78+
"""Verify ApiOfferSavingBasis has saving_basis_type."""
79+
self.assertIn("saving_basis_type", ApiOfferSavingBasis.__annotations__)

0 commit comments

Comments
 (0)