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.
Files changed (31) hide show
  1. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/PKG-INFO +137 -10
  2. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/README.md +136 -9
  3. python_bestbuy-0.2.0/bestbuy/__init__.py +3 -0
  4. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/clients/base.py +85 -47
  5. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/clients/catalog.py +44 -0
  6. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/configs/base.py +5 -0
  7. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/models/__init__.py +22 -0
  8. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/models/catalog.py +220 -48
  9. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/catalog.py +788 -555
  10. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/commerce.py +276 -243
  11. python_bestbuy-0.2.0/bestbuy/query.py +260 -0
  12. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/pyproject.toml +1 -1
  13. python_bestbuy-0.1.0/bestbuy/__init__.py +0 -0
  14. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/.gitignore +0 -0
  15. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/LICENSE +0 -0
  16. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/clients/__init__.py +0 -0
  17. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/clients/commerce.py +0 -0
  18. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/configs/__init__.py +0 -0
  19. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/configs/catalog.py +0 -0
  20. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/configs/commerce.py +0 -0
  21. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/exceptions.py +0 -0
  22. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/loggers.py +0 -0
  23. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/models/commerce.py +0 -0
  24. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/__init__.py +0 -0
  25. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/base.py +0 -0
  26. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/operations/pagination.py +0 -0
  27. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/py.typed +0 -0
  28. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/typing.py +0 -0
  29. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/utils/__init__.py +0 -0
  30. {python_bestbuy-0.1.0 → python_bestbuy-0.2.0}/bestbuy/utils/encryption.py +0 -0
  31. {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.1.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 (product information, categories, stores, recommendations) and Commerce API (orders, fulfillment, pricing)
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
- - **Automatic Pagination**: Built-in paginators for iterating over large result sets
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 product recommendations.
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.name}")
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.name}")
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.name}")
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 | `30000` | Request timeout in milliseconds |
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 | `30000` | Request timeout in milliseconds |
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 (product information, categories, stores, recommendations) and Commerce API (orders, fulfillment, pricing)
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
- - **Automatic Pagination**: Built-in paginators for iterating over large result sets
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 product recommendations.
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.name}")
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.name}")
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.name}")
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 | `30000` | Request timeout in milliseconds |
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 | `30000` | Request timeout in milliseconds |
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
@@ -0,0 +1,3 @@
1
+ from bestbuy.query import area, search, where
2
+
3
+ __all__ = ["area", "search", "where"]
@@ -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
- from ..typing import SyncAsync
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.logger.isEnabledFor(logging.DEBUG):
101
- self.logger.debug(
102
- f"Request: {request.method} {request.url}\n"
103
- f"Headers: {dict(request.headers)}\n"
104
- f"Body: {request.content.decode('utf-8') if request.content else None}"
105
- )
106
- response = self.client.send(request)
107
- if self.logger.isEnabledFor(logging.DEBUG):
108
- self.logger.debug(
109
- f"Response: {response.status_code} {response.reason_phrase}\n"
110
- f"Headers: {dict(response.headers)}\n"
111
- f"Body: {response.text}"
112
- )
113
- try:
114
- response.raise_for_status()
115
- except httpx.HTTPStatusError as e:
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.logger.isEnabledFor(logging.DEBUG):
157
- self.logger.debug(
158
- f"Request: {request.method} {request.url}\n"
159
- f"Headers: {dict(request.headers)}\n"
160
- f"Body: {request.content.decode('utf-8') if request.content else None}"
161
- )
162
- response = await self.client.send(request)
163
- if self.logger.isEnabledFor(logging.DEBUG):
164
- self.logger.debug(
165
- f"Response: {response.status_code} {response.reason_phrase}\n"
166
- f"Headers: {dict(response.headers)}\n"
167
- f"Body: {response.text}"
168
- )
169
- try:
170
- response.raise_for_status()
171
- except httpx.HTTPStatusError as e:
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)