python-bestbuy 0.1.0__tar.gz → 0.2.0__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.
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/PKG-INFO +137 -10
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/README.md +136 -9
- python_bestbuy-0.2.0/bestbuy/__init__.py +3 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/clients/base.py +85 -47
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/clients/catalog.py +44 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/configs/base.py +5 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/models/__init__.py +22 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/models/catalog.py +220 -48
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/catalog.py +788 -555
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/commerce.py +276 -243
- python_bestbuy-0.2.0/bestbuy/query.py +260 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/pyproject.toml +1 -1
- python_bestbuy-0.1.0/bestbuy/__init__.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/.gitignore +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/LICENSE +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/clients/__init__.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/clients/commerce.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/configs/__init__.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/configs/catalog.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/configs/commerce.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/exceptions.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/loggers.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/models/commerce.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/__init__.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/base.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/pagination.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/py.typed +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/typing.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/utils/__init__.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/utils/encryption.py +0 -0
- {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/utils/errors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-bestbuy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python client library for Best Buy's Catalog and Commerce APIs
|
|
5
5
|
Project-URL: Homepage, https://github.com/bbify/python-bestbuy
|
|
6
6
|
Project-URL: Repository, https://github.com/bbify/python-bestbuy
|
|
@@ -34,10 +34,14 @@ A Python client library for Best Buy's APIs, providing synchronous and asynchron
|
|
|
34
34
|
|
|
35
35
|
## Features
|
|
36
36
|
|
|
37
|
-
- **Dual API Support**: Full support for both the Catalog API (
|
|
37
|
+
- **Dual API Support**: Full support for both the Catalog API (products, categories, stores, recommendations, open box) and Commerce API (orders, fulfillment, pricing)
|
|
38
38
|
- **Sync and Async Clients**: Both synchronous and asynchronous clients for each API
|
|
39
|
-
- **
|
|
39
|
+
- **Query Builder**: Pythonic DSL for constructing search queries with operator overloading
|
|
40
|
+
- **Streaming**: Cursor-mark based auto-pagination for efficient iteration over large result sets
|
|
41
|
+
- **Automatic Pagination**: Built-in paginators for page-level and item-level iteration
|
|
40
42
|
- **Type Safety**: Full Pydantic model validation for requests and responses
|
|
43
|
+
- **Rate Limiting**: Built-in request throttling (default 5 requests/second)
|
|
44
|
+
- **Retry Logic**: Configurable automatic retry on server errors and transient failures
|
|
41
45
|
- **Session Management**: Automatic session handling for Commerce API authentication
|
|
42
46
|
- **Payment Encryption**: Built-in utilities for encrypting credit card data for guest orders
|
|
43
47
|
- **Sandbox Support**: Easy switching between production and sandbox environments
|
|
@@ -65,7 +69,7 @@ uv add python-bestbuy
|
|
|
65
69
|
|
|
66
70
|
### Catalog API
|
|
67
71
|
|
|
68
|
-
The Catalog API provides access to Best Buy's product catalog, categories, store locations, and
|
|
72
|
+
The Catalog API provides access to Best Buy's product catalog, categories, store locations, recommendations, and open box inventory.
|
|
69
73
|
|
|
70
74
|
```python
|
|
71
75
|
from bestbuy.clients.catalog import CatalogClient, AsyncCatalogClient
|
|
@@ -74,6 +78,54 @@ from bestbuy.clients.catalog import CatalogClient, AsyncCatalogClient
|
|
|
74
78
|
client = CatalogClient(api_key="your-api-key")
|
|
75
79
|
```
|
|
76
80
|
|
|
81
|
+
#### Query Builder
|
|
82
|
+
|
|
83
|
+
The query builder provides a Pythonic way to construct Best Buy's search filter syntax. It's optional -- you can always pass raw query strings instead.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from bestbuy.query import where, search, area
|
|
87
|
+
|
|
88
|
+
# Simple comparisons
|
|
89
|
+
q = where("manufacturer") == "apple" # "manufacturer=apple"
|
|
90
|
+
q = where("salePrice") < 1000 # "salePrice<1000"
|
|
91
|
+
q = where("manufacturer") != "apple" # "manufacturer!=apple"
|
|
92
|
+
|
|
93
|
+
# Boolean values (auto-lowercased)
|
|
94
|
+
q = where("onSale") == True # "onSale=true"
|
|
95
|
+
|
|
96
|
+
# AND (&) and OR (|) operators
|
|
97
|
+
q = (where("manufacturer") == "apple") & (where("salePrice") < 1000)
|
|
98
|
+
q = (where("onSale") == True) | (where("freeShipping") == True)
|
|
99
|
+
|
|
100
|
+
# IN operator for set membership
|
|
101
|
+
q = where("sku").is_in([43900, 2088495, 7150065]) # "sku in(43900,2088495,7150065)"
|
|
102
|
+
|
|
103
|
+
# Wildcard / existence check
|
|
104
|
+
q = where("driveCapacityGb").exists() # "driveCapacityGb=*"
|
|
105
|
+
|
|
106
|
+
# Keyword search
|
|
107
|
+
q = search("laptop") # "search=laptop"
|
|
108
|
+
q = search("oven", "stainless", "steel") # "search=oven&search=stainless&search=steel"
|
|
109
|
+
|
|
110
|
+
# Geographic area query (stores)
|
|
111
|
+
q = area("55423", 10) # "area(55423,10)"
|
|
112
|
+
q = area(lat=44.97, lng=-93.26, distance=5) # "area(44.97,-93.26,5)"
|
|
113
|
+
|
|
114
|
+
# Complex grouping with automatic parenthesization
|
|
115
|
+
q = (where("platform") == "psp") & (
|
|
116
|
+
(where("salePrice") <= 15) | (
|
|
117
|
+
(where("salePrice") <= 20) & (where("inStorePickup") == True)
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
# "platform=psp&(salePrice<=15|(salePrice<=20&inStorePickup=true))"
|
|
121
|
+
|
|
122
|
+
# Use with any search method (pass as string)
|
|
123
|
+
results = client.products.search(query=str(q), page_size=10)
|
|
124
|
+
|
|
125
|
+
# Raw query strings still work
|
|
126
|
+
results = client.products.search(query="manufacturer=apple&salePrice<1000")
|
|
127
|
+
```
|
|
128
|
+
|
|
77
129
|
#### Products
|
|
78
130
|
|
|
79
131
|
```python
|
|
@@ -99,8 +151,24 @@ for product in results.products:
|
|
|
99
151
|
# List all products
|
|
100
152
|
results = client.products.list(page=1, page_size=10)
|
|
101
153
|
|
|
102
|
-
# Get warranties for a product
|
|
154
|
+
# Get warranties for a product (returns typed Warranty objects)
|
|
103
155
|
warranties = client.products.get_warranties(sku=6487435)
|
|
156
|
+
for w in warranties:
|
|
157
|
+
print(f"{w.short_name}: ${w.current_price} ({w.term})")
|
|
158
|
+
|
|
159
|
+
# Check real-time in-store availability
|
|
160
|
+
availability = client.products.get_real_time_availability(
|
|
161
|
+
sku=6487435, postal_code="55401"
|
|
162
|
+
)
|
|
163
|
+
print(f"Pickup eligible: {availability.ispu_eligible}")
|
|
164
|
+
for store in availability.stores:
|
|
165
|
+
print(f" {store.name}: low stock = {store.low_stock}")
|
|
166
|
+
|
|
167
|
+
# Check availability across multiple stores
|
|
168
|
+
response = client.products.availability(sku=6487435, store_ids=[281, 1358])
|
|
169
|
+
for product in response.products:
|
|
170
|
+
for store in product.stores:
|
|
171
|
+
print(f" Store {store.store_id}: {store.name}")
|
|
104
172
|
|
|
105
173
|
# Paginate through search results
|
|
106
174
|
for page in client.products.search_pages(query="onSale=true", page_size=100):
|
|
@@ -114,6 +182,10 @@ for product in client.products.search_pages(query="onSale=true").items():
|
|
|
114
182
|
# Limit pagination to a maximum number of pages
|
|
115
183
|
for product in client.products.search_pages(query="onSale=true", max_pages=5).items():
|
|
116
184
|
print(product.name)
|
|
185
|
+
|
|
186
|
+
# Stream products using cursor-mark pagination (most efficient for large result sets)
|
|
187
|
+
for product in client.products.stream(query="onSale=true", page_size=100):
|
|
188
|
+
print(product.name)
|
|
117
189
|
```
|
|
118
190
|
|
|
119
191
|
#### Categories
|
|
@@ -134,6 +206,10 @@ results = client.categories.list(page=1, page_size=10)
|
|
|
134
206
|
# Paginate through categories
|
|
135
207
|
for category in client.categories.search_pages().items():
|
|
136
208
|
print(category.name)
|
|
209
|
+
|
|
210
|
+
# Stream all categories
|
|
211
|
+
for category in client.categories.stream():
|
|
212
|
+
print(category.name)
|
|
137
213
|
```
|
|
138
214
|
|
|
139
215
|
#### Stores
|
|
@@ -162,6 +238,29 @@ results = client.stores.search_by_area(lat=44.9778, lng=-93.2650, distance=10)
|
|
|
162
238
|
# Paginate through stores by area
|
|
163
239
|
for store in client.stores.search_by_area_pages(postal_code="55401").items():
|
|
164
240
|
print(store.name)
|
|
241
|
+
|
|
242
|
+
# Stream all stores
|
|
243
|
+
for store in client.stores.stream():
|
|
244
|
+
print(store.name)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
#### Open Box
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
# List all open box products
|
|
251
|
+
results = client.openbox.list(page_size=10)
|
|
252
|
+
for item in results.results:
|
|
253
|
+
print(f"{item.names.title}: {len(item.offers)} offers")
|
|
254
|
+
|
|
255
|
+
# Get open box offers for a specific product
|
|
256
|
+
results = client.openbox.get(sku=6487435)
|
|
257
|
+
|
|
258
|
+
# Search open box products by category
|
|
259
|
+
results = client.openbox.search("categoryId=abcat0502000")
|
|
260
|
+
|
|
261
|
+
# Stream all open box products
|
|
262
|
+
for item in client.openbox.stream():
|
|
263
|
+
print(f"{item.sku}: {item.offers}")
|
|
165
264
|
```
|
|
166
265
|
|
|
167
266
|
#### Recommendations
|
|
@@ -170,7 +269,7 @@ for store in client.stores.search_by_area_pages(postal_code="55401").items():
|
|
|
170
269
|
# Get trending products
|
|
171
270
|
trending = client.recommendations.trending()
|
|
172
271
|
for product in trending.results:
|
|
173
|
-
print(f"Trending: {product.
|
|
272
|
+
print(f"Trending: {product.names.title}")
|
|
174
273
|
|
|
175
274
|
# Get trending products in a specific category
|
|
176
275
|
trending = client.recommendations.trending(category_id="abcat0502000")
|
|
@@ -178,12 +277,12 @@ trending = client.recommendations.trending(category_id="abcat0502000")
|
|
|
178
277
|
# Get most viewed products
|
|
179
278
|
most_viewed = client.recommendations.most_viewed()
|
|
180
279
|
for product in most_viewed.results:
|
|
181
|
-
print(f"Most viewed: {product.
|
|
280
|
+
print(f"Most viewed: {product.names.title}")
|
|
182
281
|
|
|
183
282
|
# Get products also viewed with a specific product
|
|
184
283
|
also_viewed = client.recommendations.also_viewed(sku=6487435)
|
|
185
284
|
for product in also_viewed.results:
|
|
186
|
-
print(f"Also viewed: {product.
|
|
285
|
+
print(f"Also viewed: {product.names.title}")
|
|
187
286
|
|
|
188
287
|
# Get products also bought with a specific product
|
|
189
288
|
also_bought = client.recommendations.also_bought(sku=6487435)
|
|
@@ -192,6 +291,15 @@ also_bought = client.recommendations.also_bought(sku=6487435)
|
|
|
192
291
|
ultimately_bought = client.recommendations.viewed_ultimately_bought(sku=6487435)
|
|
193
292
|
```
|
|
194
293
|
|
|
294
|
+
#### Version
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
# Get API and package version
|
|
298
|
+
version_info = client.version()
|
|
299
|
+
print(f"API version: {version_info.api_version}")
|
|
300
|
+
print(f"Package version: {version_info.package_version}")
|
|
301
|
+
```
|
|
302
|
+
|
|
195
303
|
#### Async Catalog Client
|
|
196
304
|
|
|
197
305
|
```python
|
|
@@ -209,6 +317,10 @@ async def main():
|
|
|
209
317
|
async for product in client.products.search_pages(query="onSale=true").items():
|
|
210
318
|
print(product.name)
|
|
211
319
|
|
|
320
|
+
# Async streaming
|
|
321
|
+
async for product in client.products.stream(query="onSale=true"):
|
|
322
|
+
print(product.name)
|
|
323
|
+
|
|
212
324
|
asyncio.run(main())
|
|
213
325
|
```
|
|
214
326
|
|
|
@@ -471,7 +583,11 @@ asyncio.run(main())
|
|
|
471
583
|
|--------|------|---------|-------------|
|
|
472
584
|
| `api_key` | str | Required | Best Buy API key |
|
|
473
585
|
| `base_url` | str | `https://api.bestbuy.com` | API base URL |
|
|
474
|
-
| `timeout_ms` | int | `
|
|
586
|
+
| `timeout_ms` | int | `60000` | Request timeout in milliseconds |
|
|
587
|
+
| `requests_per_second` | int | `5` | Rate limit (requests per second, 0 to disable) |
|
|
588
|
+
| `max_retries` | int | `0` | Max retry attempts on 5xx/transport errors |
|
|
589
|
+
| `retry_interval_ms` | int | `2000` | Delay between retries in milliseconds |
|
|
590
|
+
| `headers` | dict | None | Custom HTTP headers merged into every request |
|
|
475
591
|
| `log_level` | int | `logging.WARNING` | Logging level |
|
|
476
592
|
|
|
477
593
|
#### Commerce Client Options
|
|
@@ -485,7 +601,11 @@ asyncio.run(main())
|
|
|
485
601
|
| `sandbox` | bool | `True` | Use sandbox environment |
|
|
486
602
|
| `auto_logout` | bool | `True` | Auto-logout on session end |
|
|
487
603
|
| `base_url` | str | Auto | API base URL (auto-set based on sandbox) |
|
|
488
|
-
| `timeout_ms` | int | `
|
|
604
|
+
| `timeout_ms` | int | `60000` | Request timeout in milliseconds |
|
|
605
|
+
| `requests_per_second` | int | `5` | Rate limit (requests per second, 0 to disable) |
|
|
606
|
+
| `max_retries` | int | `0` | Max retry attempts on 5xx/transport errors |
|
|
607
|
+
| `retry_interval_ms` | int | `2000` | Delay between retries in milliseconds |
|
|
608
|
+
| `headers` | dict | None | Custom HTTP headers merged into every request |
|
|
489
609
|
| `log_level` | int | `logging.WARNING` | Logging level |
|
|
490
610
|
|
|
491
611
|
### Error Handling
|
|
@@ -533,6 +653,13 @@ client = CommerceClient(
|
|
|
533
653
|
uv run pytest
|
|
534
654
|
```
|
|
535
655
|
|
|
656
|
+
### Running Linter
|
|
657
|
+
|
|
658
|
+
```bash
|
|
659
|
+
uv run ruff check
|
|
660
|
+
uv run ruff format --check
|
|
661
|
+
```
|
|
662
|
+
|
|
536
663
|
### Running Type Checks
|
|
537
664
|
|
|
538
665
|
```bash
|
|
@@ -6,10 +6,14 @@ A Python client library for Best Buy's APIs, providing synchronous and asynchron
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **Dual API Support**: Full support for both the Catalog API (
|
|
9
|
+
- **Dual API Support**: Full support for both the Catalog API (products, categories, stores, recommendations, open box) and Commerce API (orders, fulfillment, pricing)
|
|
10
10
|
- **Sync and Async Clients**: Both synchronous and asynchronous clients for each API
|
|
11
|
-
- **
|
|
11
|
+
- **Query Builder**: Pythonic DSL for constructing search queries with operator overloading
|
|
12
|
+
- **Streaming**: Cursor-mark based auto-pagination for efficient iteration over large result sets
|
|
13
|
+
- **Automatic Pagination**: Built-in paginators for page-level and item-level iteration
|
|
12
14
|
- **Type Safety**: Full Pydantic model validation for requests and responses
|
|
15
|
+
- **Rate Limiting**: Built-in request throttling (default 5 requests/second)
|
|
16
|
+
- **Retry Logic**: Configurable automatic retry on server errors and transient failures
|
|
13
17
|
- **Session Management**: Automatic session handling for Commerce API authentication
|
|
14
18
|
- **Payment Encryption**: Built-in utilities for encrypting credit card data for guest orders
|
|
15
19
|
- **Sandbox Support**: Easy switching between production and sandbox environments
|
|
@@ -37,7 +41,7 @@ uv add python-bestbuy
|
|
|
37
41
|
|
|
38
42
|
### Catalog API
|
|
39
43
|
|
|
40
|
-
The Catalog API provides access to Best Buy's product catalog, categories, store locations, and
|
|
44
|
+
The Catalog API provides access to Best Buy's product catalog, categories, store locations, recommendations, and open box inventory.
|
|
41
45
|
|
|
42
46
|
```python
|
|
43
47
|
from bestbuy.clients.catalog import CatalogClient, AsyncCatalogClient
|
|
@@ -46,6 +50,54 @@ from bestbuy.clients.catalog import CatalogClient, AsyncCatalogClient
|
|
|
46
50
|
client = CatalogClient(api_key="your-api-key")
|
|
47
51
|
```
|
|
48
52
|
|
|
53
|
+
#### Query Builder
|
|
54
|
+
|
|
55
|
+
The query builder provides a Pythonic way to construct Best Buy's search filter syntax. It's optional -- you can always pass raw query strings instead.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from bestbuy.query import where, search, area
|
|
59
|
+
|
|
60
|
+
# Simple comparisons
|
|
61
|
+
q = where("manufacturer") == "apple" # "manufacturer=apple"
|
|
62
|
+
q = where("salePrice") < 1000 # "salePrice<1000"
|
|
63
|
+
q = where("manufacturer") != "apple" # "manufacturer!=apple"
|
|
64
|
+
|
|
65
|
+
# Boolean values (auto-lowercased)
|
|
66
|
+
q = where("onSale") == True # "onSale=true"
|
|
67
|
+
|
|
68
|
+
# AND (&) and OR (|) operators
|
|
69
|
+
q = (where("manufacturer") == "apple") & (where("salePrice") < 1000)
|
|
70
|
+
q = (where("onSale") == True) | (where("freeShipping") == True)
|
|
71
|
+
|
|
72
|
+
# IN operator for set membership
|
|
73
|
+
q = where("sku").is_in([43900, 2088495, 7150065]) # "sku in(43900,2088495,7150065)"
|
|
74
|
+
|
|
75
|
+
# Wildcard / existence check
|
|
76
|
+
q = where("driveCapacityGb").exists() # "driveCapacityGb=*"
|
|
77
|
+
|
|
78
|
+
# Keyword search
|
|
79
|
+
q = search("laptop") # "search=laptop"
|
|
80
|
+
q = search("oven", "stainless", "steel") # "search=oven&search=stainless&search=steel"
|
|
81
|
+
|
|
82
|
+
# Geographic area query (stores)
|
|
83
|
+
q = area("55423", 10) # "area(55423,10)"
|
|
84
|
+
q = area(lat=44.97, lng=-93.26, distance=5) # "area(44.97,-93.26,5)"
|
|
85
|
+
|
|
86
|
+
# Complex grouping with automatic parenthesization
|
|
87
|
+
q = (where("platform") == "psp") & (
|
|
88
|
+
(where("salePrice") <= 15) | (
|
|
89
|
+
(where("salePrice") <= 20) & (where("inStorePickup") == True)
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
# "platform=psp&(salePrice<=15|(salePrice<=20&inStorePickup=true))"
|
|
93
|
+
|
|
94
|
+
# Use with any search method (pass as string)
|
|
95
|
+
results = client.products.search(query=str(q), page_size=10)
|
|
96
|
+
|
|
97
|
+
# Raw query strings still work
|
|
98
|
+
results = client.products.search(query="manufacturer=apple&salePrice<1000")
|
|
99
|
+
```
|
|
100
|
+
|
|
49
101
|
#### Products
|
|
50
102
|
|
|
51
103
|
```python
|
|
@@ -71,8 +123,24 @@ for product in results.products:
|
|
|
71
123
|
# List all products
|
|
72
124
|
results = client.products.list(page=1, page_size=10)
|
|
73
125
|
|
|
74
|
-
# Get warranties for a product
|
|
126
|
+
# Get warranties for a product (returns typed Warranty objects)
|
|
75
127
|
warranties = client.products.get_warranties(sku=6487435)
|
|
128
|
+
for w in warranties:
|
|
129
|
+
print(f"{w.short_name}: ${w.current_price} ({w.term})")
|
|
130
|
+
|
|
131
|
+
# Check real-time in-store availability
|
|
132
|
+
availability = client.products.get_real_time_availability(
|
|
133
|
+
sku=6487435, postal_code="55401"
|
|
134
|
+
)
|
|
135
|
+
print(f"Pickup eligible: {availability.ispu_eligible}")
|
|
136
|
+
for store in availability.stores:
|
|
137
|
+
print(f" {store.name}: low stock = {store.low_stock}")
|
|
138
|
+
|
|
139
|
+
# Check availability across multiple stores
|
|
140
|
+
response = client.products.availability(sku=6487435, store_ids=[281, 1358])
|
|
141
|
+
for product in response.products:
|
|
142
|
+
for store in product.stores:
|
|
143
|
+
print(f" Store {store.store_id}: {store.name}")
|
|
76
144
|
|
|
77
145
|
# Paginate through search results
|
|
78
146
|
for page in client.products.search_pages(query="onSale=true", page_size=100):
|
|
@@ -86,6 +154,10 @@ for product in client.products.search_pages(query="onSale=true").items():
|
|
|
86
154
|
# Limit pagination to a maximum number of pages
|
|
87
155
|
for product in client.products.search_pages(query="onSale=true", max_pages=5).items():
|
|
88
156
|
print(product.name)
|
|
157
|
+
|
|
158
|
+
# Stream products using cursor-mark pagination (most efficient for large result sets)
|
|
159
|
+
for product in client.products.stream(query="onSale=true", page_size=100):
|
|
160
|
+
print(product.name)
|
|
89
161
|
```
|
|
90
162
|
|
|
91
163
|
#### Categories
|
|
@@ -106,6 +178,10 @@ results = client.categories.list(page=1, page_size=10)
|
|
|
106
178
|
# Paginate through categories
|
|
107
179
|
for category in client.categories.search_pages().items():
|
|
108
180
|
print(category.name)
|
|
181
|
+
|
|
182
|
+
# Stream all categories
|
|
183
|
+
for category in client.categories.stream():
|
|
184
|
+
print(category.name)
|
|
109
185
|
```
|
|
110
186
|
|
|
111
187
|
#### Stores
|
|
@@ -134,6 +210,29 @@ results = client.stores.search_by_area(lat=44.9778, lng=-93.2650, distance=10)
|
|
|
134
210
|
# Paginate through stores by area
|
|
135
211
|
for store in client.stores.search_by_area_pages(postal_code="55401").items():
|
|
136
212
|
print(store.name)
|
|
213
|
+
|
|
214
|
+
# Stream all stores
|
|
215
|
+
for store in client.stores.stream():
|
|
216
|
+
print(store.name)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### Open Box
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
# List all open box products
|
|
223
|
+
results = client.openbox.list(page_size=10)
|
|
224
|
+
for item in results.results:
|
|
225
|
+
print(f"{item.names.title}: {len(item.offers)} offers")
|
|
226
|
+
|
|
227
|
+
# Get open box offers for a specific product
|
|
228
|
+
results = client.openbox.get(sku=6487435)
|
|
229
|
+
|
|
230
|
+
# Search open box products by category
|
|
231
|
+
results = client.openbox.search("categoryId=abcat0502000")
|
|
232
|
+
|
|
233
|
+
# Stream all open box products
|
|
234
|
+
for item in client.openbox.stream():
|
|
235
|
+
print(f"{item.sku}: {item.offers}")
|
|
137
236
|
```
|
|
138
237
|
|
|
139
238
|
#### Recommendations
|
|
@@ -142,7 +241,7 @@ for store in client.stores.search_by_area_pages(postal_code="55401").items():
|
|
|
142
241
|
# Get trending products
|
|
143
242
|
trending = client.recommendations.trending()
|
|
144
243
|
for product in trending.results:
|
|
145
|
-
print(f"Trending: {product.
|
|
244
|
+
print(f"Trending: {product.names.title}")
|
|
146
245
|
|
|
147
246
|
# Get trending products in a specific category
|
|
148
247
|
trending = client.recommendations.trending(category_id="abcat0502000")
|
|
@@ -150,12 +249,12 @@ trending = client.recommendations.trending(category_id="abcat0502000")
|
|
|
150
249
|
# Get most viewed products
|
|
151
250
|
most_viewed = client.recommendations.most_viewed()
|
|
152
251
|
for product in most_viewed.results:
|
|
153
|
-
print(f"Most viewed: {product.
|
|
252
|
+
print(f"Most viewed: {product.names.title}")
|
|
154
253
|
|
|
155
254
|
# Get products also viewed with a specific product
|
|
156
255
|
also_viewed = client.recommendations.also_viewed(sku=6487435)
|
|
157
256
|
for product in also_viewed.results:
|
|
158
|
-
print(f"Also viewed: {product.
|
|
257
|
+
print(f"Also viewed: {product.names.title}")
|
|
159
258
|
|
|
160
259
|
# Get products also bought with a specific product
|
|
161
260
|
also_bought = client.recommendations.also_bought(sku=6487435)
|
|
@@ -164,6 +263,15 @@ also_bought = client.recommendations.also_bought(sku=6487435)
|
|
|
164
263
|
ultimately_bought = client.recommendations.viewed_ultimately_bought(sku=6487435)
|
|
165
264
|
```
|
|
166
265
|
|
|
266
|
+
#### Version
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
# Get API and package version
|
|
270
|
+
version_info = client.version()
|
|
271
|
+
print(f"API version: {version_info.api_version}")
|
|
272
|
+
print(f"Package version: {version_info.package_version}")
|
|
273
|
+
```
|
|
274
|
+
|
|
167
275
|
#### Async Catalog Client
|
|
168
276
|
|
|
169
277
|
```python
|
|
@@ -181,6 +289,10 @@ async def main():
|
|
|
181
289
|
async for product in client.products.search_pages(query="onSale=true").items():
|
|
182
290
|
print(product.name)
|
|
183
291
|
|
|
292
|
+
# Async streaming
|
|
293
|
+
async for product in client.products.stream(query="onSale=true"):
|
|
294
|
+
print(product.name)
|
|
295
|
+
|
|
184
296
|
asyncio.run(main())
|
|
185
297
|
```
|
|
186
298
|
|
|
@@ -443,7 +555,11 @@ asyncio.run(main())
|
|
|
443
555
|
|--------|------|---------|-------------|
|
|
444
556
|
| `api_key` | str | Required | Best Buy API key |
|
|
445
557
|
| `base_url` | str | `https://api.bestbuy.com` | API base URL |
|
|
446
|
-
| `timeout_ms` | int | `
|
|
558
|
+
| `timeout_ms` | int | `60000` | Request timeout in milliseconds |
|
|
559
|
+
| `requests_per_second` | int | `5` | Rate limit (requests per second, 0 to disable) |
|
|
560
|
+
| `max_retries` | int | `0` | Max retry attempts on 5xx/transport errors |
|
|
561
|
+
| `retry_interval_ms` | int | `2000` | Delay between retries in milliseconds |
|
|
562
|
+
| `headers` | dict | None | Custom HTTP headers merged into every request |
|
|
447
563
|
| `log_level` | int | `logging.WARNING` | Logging level |
|
|
448
564
|
|
|
449
565
|
#### Commerce Client Options
|
|
@@ -457,7 +573,11 @@ asyncio.run(main())
|
|
|
457
573
|
| `sandbox` | bool | `True` | Use sandbox environment |
|
|
458
574
|
| `auto_logout` | bool | `True` | Auto-logout on session end |
|
|
459
575
|
| `base_url` | str | Auto | API base URL (auto-set based on sandbox) |
|
|
460
|
-
| `timeout_ms` | int | `
|
|
576
|
+
| `timeout_ms` | int | `60000` | Request timeout in milliseconds |
|
|
577
|
+
| `requests_per_second` | int | `5` | Rate limit (requests per second, 0 to disable) |
|
|
578
|
+
| `max_retries` | int | `0` | Max retry attempts on 5xx/transport errors |
|
|
579
|
+
| `retry_interval_ms` | int | `2000` | Delay between retries in milliseconds |
|
|
580
|
+
| `headers` | dict | None | Custom HTTP headers merged into every request |
|
|
461
581
|
| `log_level` | int | `logging.WARNING` | Logging level |
|
|
462
582
|
|
|
463
583
|
### Error Handling
|
|
@@ -505,6 +625,13 @@ client = CommerceClient(
|
|
|
505
625
|
uv run pytest
|
|
506
626
|
```
|
|
507
627
|
|
|
628
|
+
### Running Linter
|
|
629
|
+
|
|
630
|
+
```bash
|
|
631
|
+
uv run ruff check
|
|
632
|
+
uv run ruff format --check
|
|
633
|
+
```
|
|
634
|
+
|
|
508
635
|
### Running Type Checks
|
|
509
636
|
|
|
510
637
|
```bash
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import abc
|
|
2
|
+
import asyncio
|
|
2
3
|
import logging
|
|
4
|
+
import time
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _pkg_version
|
|
3
7
|
from types import TracebackType
|
|
4
8
|
from typing import Any, Dict, List, Type
|
|
5
9
|
|
|
@@ -9,9 +13,16 @@ from pydantic import ValidationError
|
|
|
9
13
|
from ..configs import BaseConfig
|
|
10
14
|
from ..exceptions import ConfigError
|
|
11
15
|
from ..loggers import make_console_logger
|
|
16
|
+
from ..typing import SyncAsync
|
|
12
17
|
from ..utils import check_for_json_errors, check_for_xml_errors
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
|
|
20
|
+
def _get_user_agent() -> str:
|
|
21
|
+
try:
|
|
22
|
+
v = _pkg_version("python-bestbuy")
|
|
23
|
+
except PackageNotFoundError:
|
|
24
|
+
v = "unknown"
|
|
25
|
+
return f"python-bestbuy/{v}"
|
|
15
26
|
|
|
16
27
|
|
|
17
28
|
class BaseClient:
|
|
@@ -41,6 +52,9 @@ class BaseClient:
|
|
|
41
52
|
self.logger = self.options.logger or make_console_logger()
|
|
42
53
|
self.logger.setLevel(self.options.log_level)
|
|
43
54
|
self._clients: List[httpx.Client | httpx.AsyncClient] = []
|
|
55
|
+
self._last_request_time: float = 0.0
|
|
56
|
+
rps = self.options.requests_per_second
|
|
57
|
+
self._min_interval: float = 1.0 / rps if rps > 0 else 0.0
|
|
44
58
|
self.client = client
|
|
45
59
|
|
|
46
60
|
@property
|
|
@@ -58,9 +72,47 @@ class BaseClient:
|
|
|
58
72
|
headers: Dict[str, str] = {"X-API-KEY": self.options.api_key}
|
|
59
73
|
if self.options.content_type:
|
|
60
74
|
headers["Content-Type"] = self.options.content_type
|
|
75
|
+
headers["User-Agent"] = _get_user_agent()
|
|
76
|
+
if self.options.headers:
|
|
77
|
+
headers.update(self.options.headers)
|
|
61
78
|
client.headers = httpx.Headers(headers)
|
|
62
79
|
self._clients.append(client)
|
|
63
80
|
|
|
81
|
+
def _log_request(self, request: httpx.Request) -> None:
|
|
82
|
+
if self.logger.isEnabledFor(logging.DEBUG):
|
|
83
|
+
self.logger.debug(
|
|
84
|
+
f"Request: {request.method} {request.url}\n"
|
|
85
|
+
f"Headers: {dict(request.headers)}\n"
|
|
86
|
+
f"Body: {request.content.decode('utf-8') if request.content else None}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _process_response(self, response: httpx.Response) -> httpx.Response:
|
|
90
|
+
if self.logger.isEnabledFor(logging.DEBUG):
|
|
91
|
+
self.logger.debug(
|
|
92
|
+
f"Response: {response.status_code} {response.reason_phrase}\n"
|
|
93
|
+
f"Headers: {dict(response.headers)}\n"
|
|
94
|
+
f"Body: {response.text}"
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
except httpx.HTTPStatusError as e:
|
|
99
|
+
content_type = e.response.headers.get("content-type", "")
|
|
100
|
+
if "xml" in content_type:
|
|
101
|
+
check_for_xml_errors(e.response.text)
|
|
102
|
+
elif "json" in content_type:
|
|
103
|
+
check_for_json_errors(e.response.text)
|
|
104
|
+
raise
|
|
105
|
+
return response
|
|
106
|
+
|
|
107
|
+
def _should_retry(self, error: Exception, attempt: int) -> bool:
|
|
108
|
+
if attempt > self.options.max_retries:
|
|
109
|
+
return False
|
|
110
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
111
|
+
return error.response.status_code >= 500
|
|
112
|
+
if isinstance(error, httpx.TransportError):
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
|
|
64
116
|
@abc.abstractmethod
|
|
65
117
|
def request(self, request: httpx.Request) -> SyncAsync[httpx.Response]:
|
|
66
118
|
raise NotImplementedError
|
|
@@ -97,29 +149,22 @@ class Client(BaseClient):
|
|
|
97
149
|
self.client.close()
|
|
98
150
|
|
|
99
151
|
def request(self, request: httpx.Request) -> httpx.Response:
|
|
100
|
-
if self.
|
|
101
|
-
self.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
content_type = e.response.headers.get("content-type", "")
|
|
117
|
-
if "xml" in content_type:
|
|
118
|
-
check_for_xml_errors(e.response.text)
|
|
119
|
-
elif "json" in content_type:
|
|
120
|
-
check_for_json_errors(e.response.text)
|
|
121
|
-
raise
|
|
122
|
-
return response
|
|
152
|
+
if self._min_interval > 0:
|
|
153
|
+
elapsed = time.monotonic() - self._last_request_time
|
|
154
|
+
if elapsed < self._min_interval:
|
|
155
|
+
time.sleep(self._min_interval - elapsed)
|
|
156
|
+
self._log_request(request)
|
|
157
|
+
attempt = 0
|
|
158
|
+
while True:
|
|
159
|
+
try:
|
|
160
|
+
self._last_request_time = time.monotonic()
|
|
161
|
+
response = self.client.send(request)
|
|
162
|
+
return self._process_response(response)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
attempt += 1
|
|
165
|
+
if not self._should_retry(e, attempt):
|
|
166
|
+
raise
|
|
167
|
+
time.sleep(self.options.retry_interval_ms / 1000)
|
|
123
168
|
|
|
124
169
|
|
|
125
170
|
class AsyncClient(BaseClient):
|
|
@@ -153,26 +198,19 @@ class AsyncClient(BaseClient):
|
|
|
153
198
|
await self.client.aclose()
|
|
154
199
|
|
|
155
200
|
async def request(self, request: httpx.Request) -> httpx.Response:
|
|
156
|
-
if self.
|
|
157
|
-
self.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
content_type = e.response.headers.get("content-type", "")
|
|
173
|
-
if "xml" in content_type:
|
|
174
|
-
check_for_xml_errors(e.response.text)
|
|
175
|
-
elif "json" in content_type:
|
|
176
|
-
check_for_json_errors(e.response.text)
|
|
177
|
-
raise
|
|
178
|
-
return response
|
|
201
|
+
if self._min_interval > 0:
|
|
202
|
+
elapsed = time.monotonic() - self._last_request_time
|
|
203
|
+
if elapsed < self._min_interval:
|
|
204
|
+
await asyncio.sleep(self._min_interval - elapsed)
|
|
205
|
+
self._log_request(request)
|
|
206
|
+
attempt = 0
|
|
207
|
+
while True:
|
|
208
|
+
try:
|
|
209
|
+
self._last_request_time = time.monotonic()
|
|
210
|
+
response = await self.client.send(request)
|
|
211
|
+
return self._process_response(response)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
attempt += 1
|
|
214
|
+
if not self._should_retry(e, attempt):
|
|
215
|
+
raise
|
|
216
|
+
await asyncio.sleep(self.options.retry_interval_ms / 1000)
|