keepa 1.4.0__tar.gz → 1.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepa
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: Interfaces with keepa.com's API.
5
5
  Keywords: keepa
6
6
  Author-email: Alex Kaszynski <akascap@gmail.com>
@@ -33,7 +33,7 @@ keywords = ["keepa"]
33
33
  name = "keepa"
34
34
  readme = "README.rst"
35
35
  requires-python = ">=3.10"
36
- version = "1.4.0"
36
+ version = "1.4.2"
37
37
 
38
38
  [project.optional-dependencies]
39
39
  doc = [
@@ -8,23 +8,20 @@ try:
8
8
  except PackageNotFoundError:
9
9
  __version__ = "unknown"
10
10
 
11
- from keepa.data_models import ProductParams
12
- from keepa.interface import (
13
- DCODES,
14
- KEEPA_ST_ORDINAL,
15
- SCODES,
16
- AsyncKeepa,
17
- Domain,
18
- Keepa,
11
+ from keepa.constants import DCODES, KEEPA_ST_ORDINAL, SCODES, csv_indices
12
+ from keepa.keepa_async import AsyncKeepa
13
+ from keepa.keepa_sync import Keepa
14
+ from keepa.models.domain import Domain
15
+ from keepa.models.product_params import ProductParams
16
+ from keepa.plotting import plot_product
17
+ from keepa.utils import (
19
18
  convert_offer_history,
20
- csv_indices,
21
19
  format_items,
22
20
  keepa_minutes_to_time,
23
21
  parse_csv,
24
22
  process_used_buybox,
25
23
  run_and_get,
26
24
  )
27
- from keepa.plotting import plot_product
28
25
 
29
26
  __all__ = [
30
27
  "AsyncKeepa",
@@ -0,0 +1,78 @@
1
+ """Constants for Keepa API interactions."""
2
+
3
+ import numpy as np
4
+
5
+ # hardcoded ordinal time from
6
+ KEEPA_ST_ORDINAL = np.datetime64("2011-01-01")
7
+
8
+ # Request limit
9
+ REQUEST_LIMIT = 100
10
+
11
+ # Status code dictionary/key
12
+ SCODES = {
13
+ "400": "REQUEST_REJECTED",
14
+ "402": "PAYMENT_REQUIRED",
15
+ "405": "METHOD_NOT_ALLOWED",
16
+ "429": "NOT_ENOUGH_TOKEN",
17
+ }
18
+
19
+ # domain codes
20
+ # Valid values: [ 1: com | 2: co.uk | 3: de | 4: fr | 5:
21
+ # co.jp | 6: ca | 7: cn | 8: it | 9: es | 10: in | 11: com.mx | 12: com.br ]
22
+ DCODES = [
23
+ "RESERVED",
24
+ "US",
25
+ "GB",
26
+ "DE",
27
+ "FR",
28
+ "JP",
29
+ "CA",
30
+ "CN",
31
+ "IT",
32
+ "ES",
33
+ "IN",
34
+ "MX",
35
+ "BR",
36
+ ]
37
+ # developer note: appears like CN (China) has changed to RESERVED2
38
+
39
+ # csv indices. used when parsing csv and stats fields.
40
+ # https://github.com/keepacom/api_backend
41
+ # see api_backend/src/main/java/com/keepa/api/backend/structs/Product.java
42
+ # [index in csv, key name, isfloat(is price or rating)]
43
+ csv_indices: list[tuple[int, str, bool]] = [
44
+ (0, "AMAZON", True),
45
+ (1, "NEW", True),
46
+ (2, "USED", True),
47
+ (3, "SALES", False),
48
+ (4, "LISTPRICE", True),
49
+ (5, "COLLECTIBLE", True),
50
+ (6, "REFURBISHED", True),
51
+ (7, "NEW_FBM_SHIPPING", True),
52
+ (8, "LIGHTNING_DEAL", True),
53
+ (9, "WAREHOUSE", True),
54
+ (10, "NEW_FBA", True),
55
+ (11, "COUNT_NEW", False),
56
+ (12, "COUNT_USED", False),
57
+ (13, "COUNT_REFURBISHED", False),
58
+ (14, "CollectableOffers", False),
59
+ (15, "EXTRA_INFO_UPDATES", False),
60
+ (16, "RATING", True),
61
+ (17, "COUNT_REVIEWS", False),
62
+ (18, "BUY_BOX_SHIPPING", True),
63
+ (19, "USED_NEW_SHIPPING", True),
64
+ (20, "USED_VERY_GOOD_SHIPPING", True),
65
+ (21, "USED_GOOD_SHIPPING", True),
66
+ (22, "USED_ACCEPTABLE_SHIPPING", True),
67
+ (23, "COLLECTIBLE_NEW_SHIPPING", True),
68
+ (24, "COLLECTIBLE_VERY_GOOD_SHIPPING", True),
69
+ (25, "COLLECTIBLE_GOOD_SHIPPING", True),
70
+ (26, "COLLECTIBLE_ACCEPTABLE_SHIPPING", True),
71
+ (27, "REFURBISHED_SHIPPING", True),
72
+ (28, "EBAY_NEW_SHIPPING", True),
73
+ (29, "EBAY_USED_SHIPPING", True),
74
+ (30, "TRADE_IN", True),
75
+ (31, "RENT", False),
76
+ ]
77
+
78
+ _SELLER_TIME_DATA_KEYS = ["trackedSince", "lastUpdate"]
@@ -0,0 +1,566 @@
1
+ """Interface module to download Amazon product and history data from keepa.com asynchronously."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ import aiohttp
12
+ from tqdm import tqdm
13
+
14
+ from keepa.constants import SCODES
15
+ from keepa.keepa_sync import Keepa
16
+ from keepa.models.domain import Domain
17
+ from keepa.models.product_params import ProductParams
18
+ from keepa.models.status import Status
19
+ from keepa.query_keys import DEAL_REQUEST_KEYS
20
+ from keepa.utils import (
21
+ _domain_to_dcode,
22
+ _parse_seller,
23
+ _parse_stats,
24
+ format_items,
25
+ is_documented_by,
26
+ parse_csv,
27
+ )
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ # Request limit
33
+ REQUEST_LIMIT = 100
34
+
35
+
36
+ class AsyncKeepa:
37
+ r"""
38
+ Asynchronous Python interface to keepa backend.
39
+
40
+ Initializes API with access key. Access key can be obtained by signing up
41
+ for a reoccurring or one time plan. To obtain a key, sign up for one at
42
+ `Keepa Data <https://get.keepa.com/d7vrq>`_.
43
+
44
+ Parameters
45
+ ----------
46
+ accesskey : str
47
+ 64 character access key string.
48
+ timeout : float, default: 10.0
49
+ Default timeout when issuing any request. This is not a time
50
+ limit on the entire response download; rather, an exception is
51
+ raised if the server has not issued a response for timeout
52
+ seconds. Setting this to 0.0 disables the timeout, but will
53
+ cause any request to hang indefiantly should keepa.com be down
54
+
55
+ Examples
56
+ --------
57
+ Query for all of Jim Butcher's books using the asynchronous
58
+ ``keepa.AsyncKeepa`` class.
59
+
60
+ >>> import asyncio
61
+ >>> import keepa
62
+ >>> product_parms = {"author": "jim butcher"}
63
+ >>> async def main():
64
+ ... key = "<REAL_KEEPA_KEY>"
65
+ ... api = await keepa.AsyncKeepa().create(key)
66
+ ... return await api.product_finder(product_parms)
67
+ ...
68
+ >>> asins = asyncio.run(main())
69
+ >>> asins
70
+ ['B000HRMAR2',
71
+ '0578799790',
72
+ 'B07PW1SVHM',
73
+ ...
74
+ 'B003MXM744',
75
+ '0133235750',
76
+ 'B01MXXLJPZ']
77
+
78
+ Query for product with ASIN ``'B0088PUEPK'`` using the asynchronous
79
+ keepa interface.
80
+
81
+ >>> import asyncio
82
+ >>> import keepa
83
+ >>> async def main():
84
+ ... key = "<REAL_KEEPA_KEY>"
85
+ ... api = await keepa.AsyncKeepa().create(key)
86
+ ... return await api.query("B0088PUEPK")
87
+ ...
88
+ >>> response = asyncio.run(main())
89
+ >>> response[0]["title"]
90
+ 'Western Digital 1TB WD Blue PC Internal Hard Drive HDD - 7200 RPM,
91
+ SATA 6 Gb/s, 64 MB Cache, 3.5" - WD10EZEX'
92
+
93
+ """
94
+
95
+ accesskey: str
96
+ tokens_left: int
97
+ status: Status
98
+ _timeout: float
99
+
100
+ @classmethod
101
+ async def create(cls, accesskey: str, timeout: float = 10.0) -> "AsyncKeepa":
102
+ """Create the async object."""
103
+ self = AsyncKeepa()
104
+ self.accesskey = accesskey
105
+ self.tokens_left = 0
106
+ self._timeout = timeout
107
+
108
+ # don't update the user status on init
109
+ self.status = Status()
110
+ return self
111
+
112
+ @property
113
+ def time_to_refill(self) -> float:
114
+ """Return the time to refill in seconds."""
115
+ # Get current timestamp in milliseconds from UNIX epoch
116
+ now = int(time.time() * 1000)
117
+ time_at_refill = self.status.timestamp + self.status.refillIn
118
+
119
+ # wait plus one second fudge factor
120
+ time_to_refill = time_at_refill - now + 1000
121
+ if time_to_refill < 0:
122
+ time_to_refill = 0
123
+
124
+ # Account for negative tokens left
125
+ if self.tokens_left < 0:
126
+ time_to_refill += (abs(self.tokens_left) / self.status.refillRate) * 60000
127
+
128
+ # Return value in seconds
129
+ return time_to_refill / 1000.0
130
+
131
+ async def update_status(self) -> None:
132
+ """Update available tokens."""
133
+ self.status = await self._request("token", {"key": self.accesskey}, wait=False)
134
+
135
+ async def wait_for_tokens(self) -> None:
136
+ """Check if there are any remaining tokens and waits if none are available."""
137
+ await self.update_status()
138
+
139
+ # Wait if no tokens available
140
+ if self.tokens_left <= 0:
141
+ tdelay = self.time_to_refill
142
+ log.warning("Waiting %.0f seconds for additional tokens", tdelay)
143
+ await asyncio.sleep(tdelay)
144
+ await self.update_status()
145
+
146
+ @is_documented_by(Keepa.query)
147
+ async def query(
148
+ self,
149
+ items: str | Sequence[str],
150
+ stats: int | None = None,
151
+ domain: str = "US",
152
+ history: bool = True,
153
+ offers: int | None = None,
154
+ update: int | None = None,
155
+ to_datetime: bool = True,
156
+ rating: bool = False,
157
+ out_of_stock_as_nan: bool = True,
158
+ stock: bool = False,
159
+ product_code_is_asin: bool = True,
160
+ progress_bar: bool = True,
161
+ buybox: bool = False,
162
+ wait: bool = True,
163
+ days: int | None = None,
164
+ only_live_offers: bool | None = None,
165
+ raw: bool = False,
166
+ videos: bool = False,
167
+ aplus: bool = False,
168
+ extra_params: dict[str, Any] | None = None,
169
+ ):
170
+ """Documented in Keepa.query."""
171
+ if raw:
172
+ raise ValueError("Raw response is only available in the non-async class")
173
+
174
+ if extra_params is None:
175
+ extra_params = {}
176
+
177
+ # Format items into numpy array
178
+ try:
179
+ items = format_items(items)
180
+ except BaseException:
181
+ raise Exception("Invalid product codes input")
182
+ assert len(items), "No valid product codes"
183
+
184
+ nitems = len(items)
185
+ if nitems == 1:
186
+ log.debug("Executing single product query")
187
+ else:
188
+ log.debug("Executing %d item product query", nitems)
189
+
190
+ # check offer input
191
+ if offers:
192
+ if not isinstance(offers, int):
193
+ raise TypeError('Parameter "offers" must be an interger')
194
+
195
+ if offers > 100 or offers < 20:
196
+ raise ValueError('Parameter "offers" must be between 20 and 100')
197
+
198
+ # Report time to completion
199
+ if self.status.refillRate is not None and self.status.refillIn is not None:
200
+ tcomplete = (
201
+ float(nitems - self.tokens_left) / self.status.refillRate
202
+ - (60000 - self.status.refillIn) / 60000.0
203
+ )
204
+ if tcomplete < 0.0:
205
+ tcomplete = 0.5
206
+ log.debug(
207
+ "Estimated time to complete %d request(s) is %.2f minutes",
208
+ nitems,
209
+ tcomplete,
210
+ )
211
+ log.debug("\twith a refill rate of %d token(s) per minute", self.status.refillRate)
212
+
213
+ # product list
214
+ products = []
215
+
216
+ pbar = None
217
+ if progress_bar:
218
+ pbar = tqdm(total=nitems)
219
+
220
+ # Number of requests is dependent on the number of items and
221
+ # request limit. Use available tokens first
222
+ idx = 0 # or number complete
223
+ while idx < nitems:
224
+ nrequest = nitems - idx
225
+
226
+ # cap request
227
+ if nrequest > REQUEST_LIMIT:
228
+ nrequest = REQUEST_LIMIT
229
+
230
+ # request from keepa and increment current position
231
+ item_request = items[idx : idx + nrequest] # noqa: E203
232
+ response = await self._product_query(
233
+ item_request,
234
+ product_code_is_asin,
235
+ stats=stats,
236
+ domain=domain,
237
+ stock=stock,
238
+ offers=offers,
239
+ update=update,
240
+ history=history,
241
+ rating=rating,
242
+ to_datetime=to_datetime,
243
+ out_of_stock_as_nan=out_of_stock_as_nan,
244
+ buybox=buybox,
245
+ wait=wait,
246
+ days=days,
247
+ only_live_offers=only_live_offers,
248
+ videos=videos,
249
+ aplus=aplus,
250
+ **extra_params,
251
+ )
252
+ idx += nrequest
253
+ products.extend(response["products"])
254
+
255
+ if pbar is not None:
256
+ pbar.update(nrequest)
257
+
258
+ return products
259
+
260
+ @is_documented_by(Keepa._product_query)
261
+ async def _product_query(self, items, product_code_is_asin=True, **kwargs):
262
+ """Documented in Keepa._product_query."""
263
+ # ASINs convert to comma joined string
264
+ assert len(items) <= 100
265
+
266
+ if product_code_is_asin:
267
+ kwargs["asin"] = ",".join(items)
268
+ else:
269
+ kwargs["code"] = ",".join(items)
270
+
271
+ kwargs["key"] = self.accesskey
272
+ kwargs["domain"] = _domain_to_dcode(kwargs["domain"])
273
+
274
+ # Convert bool values to 0 and 1.
275
+ kwargs["stock"] = int(kwargs["stock"])
276
+ kwargs["history"] = int(kwargs["history"])
277
+ kwargs["rating"] = int(kwargs["rating"])
278
+ kwargs["buybox"] = int(kwargs["buybox"])
279
+
280
+ if kwargs["update"] is None:
281
+ del kwargs["update"]
282
+ else:
283
+ kwargs["update"] = int(kwargs["update"])
284
+
285
+ if kwargs["offers"] is None:
286
+ del kwargs["offers"]
287
+ else:
288
+ kwargs["offers"] = int(kwargs["offers"])
289
+
290
+ if kwargs["only_live_offers"] is None:
291
+ del kwargs["only_live_offers"]
292
+ else:
293
+ kwargs["only-live-offers"] = int(kwargs.pop("only_live_offers"))
294
+ # Keepa's param actually doesn't use snake_case.
295
+ # Keeping with snake case for consistency
296
+
297
+ if kwargs["days"] is None:
298
+ del kwargs["days"]
299
+ else:
300
+ assert kwargs["days"] > 0
301
+
302
+ if kwargs["stats"] is None:
303
+ del kwargs["stats"]
304
+
305
+ # videos and aplus must be ints
306
+ kwargs["videos"] = int(kwargs["videos"])
307
+ kwargs["aplus"] = int(kwargs["aplus"])
308
+
309
+ out_of_stock_as_nan = kwargs.pop("out_of_stock_as_nan", True)
310
+ to_datetime = kwargs.pop("to_datetime", True)
311
+
312
+ # Query and replace csv with parsed data if history enabled
313
+ wait = kwargs.get("wait")
314
+ kwargs.pop("wait", None)
315
+
316
+ raw_response = kwargs.pop("raw", False)
317
+ response = await self._request("product", kwargs, wait=wait, raw_response=raw_response)
318
+ if kwargs["history"]:
319
+ if "products" not in response:
320
+ raise RuntimeError("No products in response. Possibly invalid ASINs")
321
+
322
+ for product in response["products"]:
323
+ if product["csv"]: # if data exists
324
+ product["data"] = parse_csv(product["csv"], to_datetime, out_of_stock_as_nan)
325
+
326
+ if kwargs.get("stats", None):
327
+ for product in response["products"]:
328
+ stats = product.get("stats", None)
329
+ if stats:
330
+ product["stats_parsed"] = _parse_stats(stats, to_datetime)
331
+
332
+ return response
333
+
334
+ @is_documented_by(Keepa.best_sellers_query)
335
+ async def best_sellers_query(
336
+ self,
337
+ category: str,
338
+ rank_avg_range: Literal[0, 30, 90, 180] = 0,
339
+ variations: bool = False,
340
+ sublist: bool = False,
341
+ domain: str | Domain = "US",
342
+ wait: bool = True,
343
+ ):
344
+ """Documented by Keepa.best_sellers_query."""
345
+ payload = {
346
+ "key": self.accesskey,
347
+ "domain": _domain_to_dcode(domain),
348
+ "variations": int(variations),
349
+ "sublist": int(sublist),
350
+ "category": category,
351
+ "range": rank_avg_range,
352
+ }
353
+
354
+ response = await self._request("bestsellers", payload, wait=wait)
355
+ if "bestSellersList" not in response:
356
+ raise RuntimeError(f"Best sellers search results for {category} not yet available")
357
+ return response["bestSellersList"]["asinList"]
358
+
359
+ @is_documented_by(Keepa.search_for_categories)
360
+ async def search_for_categories(
361
+ self, searchterm, domain: str | Domain = "US", wait: bool = True
362
+ ):
363
+ """Documented by Keepa.search_for_categories."""
364
+ payload = {
365
+ "key": self.accesskey,
366
+ "domain": _domain_to_dcode(domain),
367
+ "type": "category",
368
+ "term": searchterm,
369
+ }
370
+
371
+ response = await self._request("search", payload, wait=wait)
372
+ if response["categories"] == {}: # pragma no cover
373
+ raise Exception(
374
+ "Categories search results not yet available " + "or no search terms found."
375
+ )
376
+ else:
377
+ return response["categories"]
378
+
379
+ @is_documented_by(Keepa.category_lookup)
380
+ async def category_lookup(
381
+ self,
382
+ category_id,
383
+ domain: str | Domain = "US",
384
+ include_parents=0,
385
+ wait: bool = True,
386
+ ):
387
+ """Documented by Keepa.category_lookup."""
388
+ payload = {
389
+ "key": self.accesskey,
390
+ "domain": _domain_to_dcode(domain),
391
+ "category": category_id,
392
+ "parents": include_parents,
393
+ }
394
+
395
+ response = await self._request("category", payload, wait=wait)
396
+ if response["categories"] == {}: # pragma no cover
397
+ raise Exception("Category lookup results not yet available or no" + "match found.")
398
+ else:
399
+ return response["categories"]
400
+
401
+ @is_documented_by(Keepa.seller_query)
402
+ async def seller_query(
403
+ self,
404
+ seller_id,
405
+ domain: str | Domain = "US",
406
+ to_datetime=True,
407
+ storefront=False,
408
+ update=None,
409
+ wait: bool = True,
410
+ ):
411
+ """Documented by Keepa.sellerer_query."""
412
+ if isinstance(seller_id, list):
413
+ if len(seller_id) > 100:
414
+ err_str = "seller_id can contain at maximum 100 sellers"
415
+ raise RuntimeError(err_str)
416
+ seller = ",".join(seller_id)
417
+ else:
418
+ seller = seller_id
419
+
420
+ payload = {
421
+ "key": self.accesskey,
422
+ "domain": _domain_to_dcode(domain),
423
+ "seller": seller,
424
+ }
425
+
426
+ if storefront:
427
+ payload["storefront"] = int(storefront)
428
+ if update:
429
+ payload["update"] = update
430
+
431
+ response = await self._request("seller", payload, wait=wait)
432
+ return _parse_seller(response["sellers"], to_datetime)
433
+
434
+ @is_documented_by(Keepa.product_finder)
435
+ async def product_finder(
436
+ self,
437
+ product_parms: dict[str, Any] | ProductParams,
438
+ domain: str | Domain = "US",
439
+ wait: bool = True,
440
+ n_products: int = 50,
441
+ ) -> list[str]:
442
+ """Documented by Keepa.product_finder."""
443
+ if isinstance(product_parms, dict):
444
+ product_parms_valid = ProductParams(**product_parms)
445
+ else:
446
+ product_parms_valid = product_parms
447
+ product_parms_dict = product_parms_valid.model_dump(exclude_none=True)
448
+ product_parms_dict.setdefault("perPage", n_products)
449
+ payload = {
450
+ "key": self.accesskey,
451
+ "domain": _domain_to_dcode(domain),
452
+ "selection": json.dumps(product_parms_dict),
453
+ }
454
+
455
+ response = await self._request("query", payload, wait=wait)
456
+ return response["asinList"]
457
+
458
+ @is_documented_by(Keepa.deals)
459
+ async def deals(self, deal_parms, domain: str | Domain = "US", wait: bool = True):
460
+ """Documented in Keepa.deals."""
461
+ # verify valid keys
462
+ for key in deal_parms:
463
+ if key not in DEAL_REQUEST_KEYS:
464
+ raise ValueError(f'Invalid key "{key}"')
465
+
466
+ # verify json type
467
+ key_type = DEAL_REQUEST_KEYS[key]
468
+ deal_parms[key] = key_type(deal_parms[key])
469
+
470
+ deal_parms.setdefault("priceTypes", 0)
471
+
472
+ payload = {
473
+ "key": self.accesskey,
474
+ "domain": _domain_to_dcode(domain),
475
+ "selection": json.dumps(deal_parms),
476
+ }
477
+
478
+ deals = await self._request("deal", payload, wait=wait)
479
+ return deals["deals"]
480
+
481
+ @is_documented_by(Keepa.download_graph_image)
482
+ async def download_graph_image(
483
+ self,
484
+ asin: str,
485
+ filename: str | Path,
486
+ domain: str | Domain = "US",
487
+ wait: bool = True,
488
+ **graph_kwargs: dict[str, Any],
489
+ ) -> None:
490
+ """Documented in Keepa.download_graph_image."""
491
+ payload = {
492
+ "asin": asin,
493
+ "key": self.accesskey,
494
+ "domain": _domain_to_dcode(domain),
495
+ }
496
+ payload.update(graph_kwargs)
497
+
498
+ async with aiohttp.ClientSession() as session:
499
+ async with session.get(
500
+ "https://api.keepa.com/graphimage",
501
+ params=payload,
502
+ timeout=self._timeout,
503
+ ) as resp:
504
+ first_chunk = True
505
+ filename = Path(filename)
506
+ with open(filename, "wb") as f:
507
+ async for chunk in resp.content.iter_chunked(8192):
508
+ if first_chunk:
509
+ if not chunk.startswith(b"\x89PNG\r\n\x1a\n"):
510
+ raise ValueError(
511
+ "Response from api.keepa.com/graphimage is not a valid "
512
+ "PNG image"
513
+ )
514
+ first_chunk = False
515
+ f.write(chunk)
516
+
517
+ async def _request(
518
+ self,
519
+ request_type: str,
520
+ payload: dict[str, Any],
521
+ wait: bool = True,
522
+ raw_response: bool = False,
523
+ is_json: bool = True,
524
+ ):
525
+ """Documented in Keepa._request."""
526
+ while True:
527
+ async with aiohttp.ClientSession() as session:
528
+ async with session.get(
529
+ f"https://api.keepa.com/{request_type}/?",
530
+ params=payload,
531
+ timeout=self._timeout,
532
+ ) as raw:
533
+ status_code = str(raw.status)
534
+
535
+ if not is_json:
536
+ return raw
537
+
538
+ try:
539
+ response = await raw.json()
540
+ except Exception:
541
+ raise RuntimeError(f"Invalid JSON from Keepa API (status {status_code})")
542
+
543
+ # user status is always returned
544
+ if "tokensLeft" in response:
545
+ self.tokens_left = response["tokensLeft"]
546
+ self.status.tokensLeft = self.tokens_left
547
+ log.info("%d tokens remain", self.tokens_left)
548
+ for key in ["refillIn", "refillRate", "timestamp"]:
549
+ if key in response:
550
+ setattr(self.status, key, response[key])
551
+
552
+ if status_code == "200":
553
+ if raw_response:
554
+ return raw
555
+ return response
556
+
557
+ if status_code == "429" and wait:
558
+ tdelay = self.time_to_refill
559
+ log.warning("Waiting %.0f seconds for additional tokens", tdelay)
560
+ time.sleep(tdelay)
561
+ continue
562
+
563
+ # otherwise, it's an error code
564
+ if status_code in SCODES:
565
+ raise RuntimeError(SCODES[status_code])
566
+ raise RuntimeError(f"REQUEST_FAILED. Status code: {status_code}")