Skip to content

Commit ca2ab14

Browse files
committed
Async calls are now supported
1 parent 7681adb commit ca2ab14

File tree

5 files changed

+284
-5
lines changed

5 files changed

+284
-5
lines changed

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ from WIOpy import WalmartIO
2727

2828
wiopy = WalmartIO(private_key_version='1', private_key_filename='./WM_IO_private_key.pem', consumer_id='XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX')
2929
data = wiopy.product_lookup('33093101')[0]
30-
```
30+
```
31+
WIOpy also supports asynchronous calls. To use, everything will be the same but you must await a call and the contructed object is different.
32+
```py
33+
from WIOpy import AsyncWalmartIO
34+
wiopy = AsyncWalmartIO(...)
35+
data = await wiopy.product_lookup('33093101')[0]
36+
```
3137

3238
## Response Examples
3339
When making a call to the API, an object will be returned. That object is an object version of returned JSON.
@@ -85,6 +91,11 @@ for items in data:
8591
print(item)
8692
```
8793
Response gives generator of [WalmartProducts](https://walmart.io/docs/affiliate/item_response_groups)
94+
If you are unfamiliar with async generators; to properly call the async version:
95+
```py
96+
data = wiopy.bulk_product_lookup('33093101, 54518466, 516833054')
97+
async for items in data:
98+
```
8899

89100

90101
### [Product Recommendation](https://walmart.io/docs/affiliate/product-recommendation)

setup.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
setup(
44
name = 'WIOpy',
55
packages = ['WIOpy'],
6-
version = '0.0.6',
6+
version = '0.0.7',
77
license='MIT',
88

99
description = 'Walmart IO API python wrapper',
1010

1111
author = 'CoderJosh',
1212
author_email = '[email protected]',
1313
url = 'https://github.com/CoderJoshDK/WIOpy',
14-
download_url = 'https://github.com/CoderJoshDK/WIOpy/archive/refs/tags/v_006_alpha.tar.gz',
15-
keywords = ['API', 'Wrapper', 'Python', 'Walmart', 'Affiliate', 'WalmartIO'],
14+
download_url = 'https://github.com/CoderJoshDK/WIOpy/archive/refs/tags/v_007_alpha.tar.gz',
15+
keywords = ['API', 'Wrapper', 'Python', 'Walmart', 'Affiliate', 'WalmartIO', 'Async', 'AIOHTTP'],
1616
install_requires=[
1717
'requests',
1818
'pycryptodome',
19+
'aiohttp'
1920
],
2021

2122
classifiers=[

wiopy/AsyncWIO.py

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
from operator import is_
2+
from typing import List, Generator, Union
3+
import requests, time, datetime
4+
from Crypto.Hash import SHA256
5+
from Crypto.PublicKey import RSA
6+
from Crypto.Signature import PKCS1_v1_5
7+
import base64
8+
import json
9+
10+
import aiohttp
11+
12+
from .arguments import *
13+
from .errors import *
14+
from .WalmartResponse import *
15+
from .WalmartIO import WalmartIO
16+
17+
import logging
18+
19+
log = logging.getLogger(__name__)
20+
log.setLevel('DEBUG')
21+
22+
class AsyncWalmartIO:
23+
"""
24+
The main Walmart IO API interface as async calls.
25+
Example call:
26+
```py
27+
wiopy = AsyncWalmartIO(private_key_version='1', private_key_filename='./WM_IO_private_key.pem', consumer_id='XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX')
28+
```
29+
30+
Optional:
31+
-------
32+
You can provide `daily_calls` if it is not the default 5000.
33+
publisherId can also be provided and it will auto populate every querry.
34+
If you give publisherId as a kwarg, it will overide the default one the class has
35+
"""
36+
37+
ENDPOINT = "https://developer.api.walmart.com/api-proxy/service"
38+
39+
@is_documented_by(WalmartIO.__init__)
40+
def __init__(
41+
self, *,
42+
private_key_version:str='1', private_key_filename:str, consumer_id:str,
43+
daily_calls:int=5000, publisherId:str=None
44+
) -> None:
45+
self._private_key_version = private_key_version
46+
47+
# IOError is triggered if the file cannot be opened
48+
with open(private_key_filename, 'r') as f:
49+
self._private_key = RSA.importKey(f.read())
50+
51+
self._consumer_id = consumer_id
52+
53+
self.headers = {}
54+
self.headers["WM_CONSUMER.ID"] = consumer_id
55+
self.headers["WM_SEC.KEY_VERSION"] = private_key_version
56+
57+
self._update_daily_calls_time = datetime.datetime.now() + datetime.timedelta(days=1)
58+
self.daily_calls = daily_calls
59+
self.daily_calls_remaining = daily_calls
60+
61+
self.publisherId = publisherId or None
62+
63+
log.info(f"Walmart IO connection with consumer id ending in {consumer_id[-6:]}")
64+
65+
@is_documented_by(WalmartIO.catalog_product)
66+
async def catalog_product(self, **kwargs) -> WalmartCatalog:
67+
if 'nextPage' in kwargs:
68+
url = 'https://developer.api.walmart' + kwargs.pop('nextPage')
69+
else:
70+
url = self.ENDPOINT + '/affil/product/v2/paginated/items'
71+
72+
response = await self._send_request(url, **kwargs)
73+
return WalmartCatalog(response)
74+
75+
@is_documented_by(WalmartIO.post_browsed_products)
76+
async def post_browsed_products(self, itemId:str) -> List[WalmartProduct]:
77+
url = f'{self.ENDPOINT}/affil/product/v2/postbrowse?itemId={itemId}'
78+
response = await self._send_request(url)
79+
return [WalmartProduct(item) for item in response]
80+
81+
@is_documented_by(WalmartIO.product_lookup)
82+
async def product_lookup(self, ids:Union[str, List[str]], **kwargs) -> List[WalmartProduct]:
83+
url = self.ENDPOINT + '/affil/product/v2/items'
84+
85+
params = kwargs
86+
ids = get_items_ids(ids)
87+
if len(ids) > 200:
88+
log.warning("For large id lists, try using bulk_product_lookup. It will continue to run even if one chunk of ids raise an error")
89+
products = []
90+
91+
for idGroup in self._get_product_id_chunk(list(set(ids)), 20):
92+
params['ids'] = idGroup
93+
response = await self._send_request(url, **params)
94+
for item in response['items']:
95+
products.append(WalmartProduct(item))
96+
97+
return products
98+
99+
@is_documented_by(WalmartIO.bulk_product_lookup)
100+
async def bulk_product_lookup(self, ids:Union[str, List[str]], amount:int=20, **kwargs):
101+
url = self.ENDPOINT + '/affil/product/v2/items'
102+
103+
params = kwargs
104+
ids = get_items_ids(ids)
105+
106+
# Keep amount [1, 20]
107+
amount = min(max(1, amount), 20)
108+
109+
for idGroup in self._get_product_id_chunk(list(set(ids)), amount):
110+
params['ids'] = idGroup
111+
try:
112+
response = await self._send_request(url, **params)
113+
yield [WalmartProduct(item) for item in response['items']]
114+
except InvalidRequestException as e:
115+
log.debug(f"bulk_product_lookup failed during the request with {idGroup} ids")
116+
log.debug(e)
117+
118+
@is_documented_by(WalmartIO.product_recommendation)
119+
async def product_recommendation(self, itemId:str) -> List[WalmartProduct]:
120+
url = url = f'{self.ENDPOINT}/affil/product/v2/nbp?itemId={itemId}'
121+
response = await self._send_request(url)
122+
return [WalmartProduct(item) for item in response]
123+
124+
@is_documented_by(WalmartIO.reviews)
125+
async def reviews(self, itemId:str, **kwargs) -> WalmartReviewResponse:
126+
if 'nextPage' in kwargs:
127+
page = kwargs.pop('nextPage').split('page=')[1]
128+
kwargs['page'] = page
129+
130+
url = self.ENDPOINT + f'/affil/product/v2/reviews/{itemId}'
131+
response = await self._send_request(url, **kwargs)
132+
return WalmartReviewResponse(response)
133+
134+
@is_documented_by(WalmartIO.search)
135+
async def search(self, query:str, **kwargs) -> WalmartSearch:
136+
if "facet" in kwargs:
137+
facet = kwargs["facet"]
138+
if type(facet) == bool:
139+
kwargs["facet"] = "on" if facet else "off"
140+
141+
if "range" in kwargs:
142+
kwargs["facet.range"] = kwargs.pop("range")
143+
kwargs["facet"] = "on"
144+
if "filter" in kwargs:
145+
kwargs["facet.filter"] = kwargs.pop("filter")
146+
kwargs["facet"] = "on"
147+
148+
kwargs['query'] = query
149+
150+
151+
url = self.ENDPOINT + '/affil/product/v2/search'
152+
response = await self._send_request(url, **kwargs)
153+
return WalmartSearch(response)
154+
155+
@is_documented_by(WalmartIO.stores)
156+
async def stores(self, **kwargs) -> List[WalmartStore]:
157+
if not (('lat' in kwargs and 'lon' in kwargs) or ('zip' in kwargs)):
158+
raise InvalidParameterException("Missing lat & lon OR zip parameter")
159+
160+
url = self.ENDPOINT + '/affil/product/v2/stores'
161+
response = await self._send_request(url, **kwargs)
162+
return [WalmartStore(store) for store in response]
163+
164+
@is_documented_by(WalmartIO.taxonomy)
165+
async def taxonomy(self, **kwargs) -> WalmartTaxonomy:
166+
url = self.ENDPOINT + '/affil/product/v2/taxonomy'
167+
return await self._send_request(url, **kwargs)
168+
169+
@is_documented_by(WalmartIO.trending)
170+
async def trending(self, publisherId=None) -> List[WalmartProduct]:
171+
url = self.ENDPOINT + '/affil/product/v2/trends'
172+
173+
if publisherId:
174+
response = await self._send_request(url, publisherId=publisherId)
175+
else:
176+
response = await self._send_request(url)
177+
return [WalmartProduct(item) for item in response['items']]
178+
179+
@is_documented_by(WalmartIO._get_headers)
180+
def _get_headers(self) -> dict:
181+
timeInt = int(time.time()*1000)
182+
183+
self.headers["WM_CONSUMER.INTIMESTAMP"] = str(timeInt)
184+
185+
# <--- WM_SEC.AUTH_SIGNATURE --->
186+
# The signature generated using the private key and signing the values of consumer id, timestamp and key version.
187+
# The TTL of this signature is 180 seconds. Post that, the API Proxy will throw a "timestamp expired" error.
188+
189+
sortedHashString = self.headers['WM_CONSUMER.ID'] +'\n' \
190+
+ self.headers['WM_CONSUMER.INTIMESTAMP'] +'\n' \
191+
+ self.headers['WM_SEC.KEY_VERSION']+'\n'
192+
encodedHashString = sortedHashString.encode()
193+
194+
hasher = SHA256.new(encodedHashString)
195+
signer = PKCS1_v1_5.new(self._private_key)
196+
signature = signer.sign(hasher)
197+
signature_enc = str(base64.b64encode(signature),'utf-8')
198+
199+
self.headers['WM_SEC.AUTH_SIGNATURE'] = signature_enc
200+
201+
return self.headers
202+
203+
@is_documented_by(WalmartIO._send_request)
204+
async def _send_request(self, url, **kwargs) -> dict:
205+
log.debug(f"Making connection to {url}")
206+
207+
# Avoid format to be changed, always go for json
208+
kwargs.pop('format', None)
209+
request_params = {}
210+
for key, value in kwargs.items():
211+
request_params[key] = value
212+
213+
# Convert from native boolean python type to string 'true' or 'false'. This allows to set richAttributes with python boolean types
214+
if 'richAttributes' in request_params and type(request_params['richAttributes'])==bool:
215+
if request_params['richAttributes']:
216+
request_params['richAttributes']='true'
217+
else:
218+
request_params['richAttributes']='false'
219+
else:
220+
# Even if not specified in arguments, send request with richAttributes='true' by default
221+
request_params['richAttributes']='true'
222+
223+
if self.publisherId and 'publisherId' not in request_params:
224+
request_params['publisherId'] = self.publisherId
225+
226+
227+
if not self._validate_call():
228+
raise DailyCallLimit("Too many calls in one day. If this is incorrect, try increasing `daily_calls`")
229+
230+
async with aiohttp.ClientSession() as session:
231+
async with session.get(url, headers=self._get_headers(), params=request_params) as response:
232+
status_code = response.status
233+
if status_code == 200 or status_code == 201:
234+
return await response.json()
235+
else:
236+
if status_code == 400:
237+
# Send exception detail when it is a 400 bad error
238+
raise InvalidRequestException(status_code, detail=await response.json()['errors'][0]['message'])
239+
else:
240+
raise InvalidRequestException(status_code)
241+
242+
@is_documented_by(WalmartIO._validate_call)
243+
def _validate_call(self) -> bool:
244+
if datetime.datetime.now() > self._update_daily_calls_time:
245+
self.daily_calls_remaining = self.daily_calls
246+
self._update_daily_calls_time = datetime.datetime.now() + datetime.timedelta(days=1)
247+
248+
if self.daily_calls_remaining > 0:
249+
self.daily_calls_remaining -= 1
250+
if self.daily_calls_remaining < 500:
251+
log.warning("Fewer than 500 calls remain for the day")
252+
return True
253+
254+
return False
255+
256+
@staticmethod
257+
def _get_product_id_chunk(full_list: List[str], chunk_size:int) -> Generator[str, None, None]:
258+
"""Yield successive chunks from List."""
259+
for i in range(0, len(full_list), chunk_size):
260+
yield ','.join(full_list[i:i + chunk_size])

wiopy/WalmartIO.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def __init__(
8181

8282
self.publisherId = publisherId or None
8383

84-
log.info(f"Walmart IO connection with consumer id ending in {consumer_id[:-6]}")
84+
log.info(f"Walmart IO connection with consumer id ending in {consumer_id[-6:]}")
8585

8686
def catalog_product(self, **kwargs) -> WalmartCatalog:
8787
"""

wiopy/arguments.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,12 @@ def get_items_ids(items: Union[str, list[str]]) -> list[str]:
1818

1919
return items_ids
2020

21+
def is_documented_by(original):
22+
"""Avoid copying the documentation"""
2123

24+
def wrapper(target):
25+
target.__doc__ = original.__doc__
26+
return target
27+
28+
return wrapper
2229

0 commit comments

Comments
 (0)