kroger-api 0.3.0__tar.gz → 0.4.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.
- {kroger_api-0.3.0/kroger_api.egg-info → kroger_api-0.4.0}/PKG-INFO +47 -7
- {kroger_api-0.3.0 → kroger_api-0.4.0}/README.md +45 -6
- kroger_api-0.4.0/kroger_api/__init__.py +30 -0
- kroger_api-0.4.0/kroger_api/api/authorization.py +148 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/api/cart.py +12 -4
- kroger_api-0.4.0/kroger_api/api/location.py +210 -0
- kroger_api-0.4.0/kroger_api/api/product.py +108 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/auth/interactive.py +27 -52
- kroger_api-0.4.0/kroger_api/client.py +649 -0
- kroger_api-0.4.0/kroger_api/errors.py +531 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/kroger_api.py +13 -5
- kroger_api-0.4.0/kroger_api/tests/test_auth_utils.py +200 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/tests/test_client.py +20 -3
- kroger_api-0.4.0/kroger_api/tests/test_errors.py +665 -0
- kroger_api-0.4.0/kroger_api/tests/test_locking.py +229 -0
- kroger_api-0.4.0/kroger_api/tests/test_refresh_race.py +357 -0
- kroger_api-0.4.0/kroger_api/tests/test_tls_outcome.py +213 -0
- kroger_api-0.4.0/kroger_api/tests/test_validation.py +185 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/token_storage.py +68 -24
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/utils/__init__.py +2 -0
- kroger_api-0.4.0/kroger_api/utils/env.py +109 -0
- kroger_api-0.4.0/kroger_api/utils/locking.py +171 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/utils/oauth.py +18 -9
- kroger_api-0.4.0/kroger_api/validation.py +209 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0/kroger_api.egg-info}/PKG-INFO +47 -7
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api.egg-info/SOURCES.txt +9 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api.egg-info/requires.txt +1 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api.egg-info/top_level.txt +1 -1
- {kroger_api-0.3.0 → kroger_api-0.4.0}/pyproject.toml +2 -1
- {kroger_api-0.3.0 → kroger_api-0.4.0}/requirements.txt +1 -0
- kroger_api-0.3.0/kroger_api/__init__.py +0 -7
- kroger_api-0.3.0/kroger_api/api/authorization.py +0 -110
- kroger_api-0.3.0/kroger_api/api/location.py +0 -185
- kroger_api-0.3.0/kroger_api/api/product.py +0 -84
- kroger_api-0.3.0/kroger_api/client.py +0 -392
- kroger_api-0.3.0/kroger_api/utils/env.py +0 -76
- {kroger_api-0.3.0 → kroger_api-0.4.0}/LICENSE +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/MANIFEST.in +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/authorization_api_examples.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/cart_api_examples.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/clear_tokens.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/identity_api_examples.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/location_api_examples.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/oauth_flow.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/oauth_pkce_flow.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/product_api_examples.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/token_refresh_example.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/api/__init__.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/api/identity.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/auth/__init__.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/tests/test_auth.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/utils/pkce.py +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api.egg-info/dependency_links.txt +0 -0
- {kroger_api-0.3.0 → kroger_api-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kroger-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A Python client library for the Kroger Public API
|
|
5
5
|
Author-email: Stephen Thoemmes <thoemmes.stephen@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -54,6 +54,7 @@ Provides-Extra: dev
|
|
|
54
54
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
55
55
|
Requires-Dist: pytest-mock>=3.0.0; extra == "dev"
|
|
56
56
|
Requires-Dist: requests-mock>=1.9.0; extra == "dev"
|
|
57
|
+
Requires-Dist: cryptography; extra == "dev"
|
|
57
58
|
Requires-Dist: black; extra == "dev"
|
|
58
59
|
Requires-Dist: flake8; extra == "dev"
|
|
59
60
|
Requires-Dist: mypy; extra == "dev"
|
|
@@ -65,6 +66,8 @@ Dynamic: license-file
|
|
|
65
66
|
|
|
66
67
|
A comprehensive Python client library for the Kroger Public API, featuring robust token management, comprehensive examples, and easy-to-use interfaces for all available endpoints.
|
|
67
68
|
|
|
69
|
+
📚 **Docs:** https://cupofowls.github.io/kroger-api/ — tutorials, how-to guides, concepts, and the full reference.
|
|
70
|
+
|
|
68
71
|
## 📺 Demo
|
|
69
72
|
|
|
70
73
|
Adding an item to your cart via an interactive Python script, and checking that it appears in your account:
|
|
@@ -157,20 +160,21 @@ This library implements robust, automatic token management:
|
|
|
157
160
|
### ✨ Features
|
|
158
161
|
- **Automatic token refresh** - No manual token handling required
|
|
159
162
|
- **Persistent storage** - Tokens saved securely to avoid repeated logins
|
|
160
|
-
- **
|
|
163
|
+
- **Expiry tracking** - Checks a token's expiry time before use, with no API call
|
|
161
164
|
- **Reactive recovery** - Automatically refreshes expired tokens during API calls
|
|
162
165
|
- **PKCE Support** - Enhanced OAuth security with Proof Key for Code Exchange
|
|
166
|
+
- **Shared sign-ins** - Processes and threads that share a token file renew it one at a time, so they never sign each other out
|
|
163
167
|
|
|
164
168
|
### 🔧 How it Works
|
|
165
169
|
|
|
166
170
|
**Proactive Approach:**
|
|
167
|
-
1. Loads saved tokens and
|
|
168
|
-
2. Automatically refreshes if token is expired and refresh token is available
|
|
171
|
+
1. Loads saved tokens and checks their expiry time (no API request)
|
|
172
|
+
2. Automatically refreshes if token is expired and refresh token is available, while it holds a lock on the token file (`<token file>.lock`). A process that waited for the lock uses the token that another process renewed meanwhile, so a refresh token is never sent twice. A new sign-in and `clear_token` wait for the same lock, and a token whose file `clear_token` deleted is not renewed.
|
|
169
173
|
|
|
170
174
|
**Reactive Approach:**
|
|
171
175
|
1. Makes API requests with current token
|
|
172
|
-
2. On 401 Unauthorized errors,
|
|
173
|
-
3. Retries original request with new token
|
|
176
|
+
2. On 401 Unauthorized errors, refreshes the user token, or requests a new client-credentials token
|
|
177
|
+
3. Retries the original request once with the new token
|
|
174
178
|
|
|
175
179
|
Token files (automatically managed):
|
|
176
180
|
- `.kroger_token_client_product.compact.json` - Client credentials tokens
|
|
@@ -184,7 +188,7 @@ The directory is shared with the [kroger-mcp](https://github.com/CupOfOwls/kroge
|
|
|
184
188
|
|
|
185
189
|
### 🔒 Enhanced Security with PKCE
|
|
186
190
|
|
|
187
|
-
This library supports PKCE (Proof Key for Code Exchange) for enhanced security in the OAuth flow:
|
|
191
|
+
This library supports PKCE (Proof Key for Code Exchange) for enhanced security in the OAuth flow. `authenticate_user` uses it automatically; if you build the flow yourself, pass the parameters as shown:
|
|
188
192
|
|
|
189
193
|
```python
|
|
190
194
|
from kroger_api import KrogerAPI
|
|
@@ -213,6 +217,40 @@ token_info = kroger.authorization.get_token_with_authorization_code(
|
|
|
213
217
|
|
|
214
218
|
PKCE helps protect against authorization code interception attacks, particularly important for public clients or those using external tools to manage OAuth flows.
|
|
215
219
|
|
|
220
|
+
## ⚠️ Error Handling
|
|
221
|
+
|
|
222
|
+
Every exception the library raises derives from `KrogerAPIError`. Invalid arguments are rejected before any request is sent, and every non-2xx response raises an exception that carries Kroger's own error code and message:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from kroger_api import KrogerAPI, KrogerAPIError, InvalidParameterError, NotFoundError
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
kroger.location.get_location("99999999")
|
|
229
|
+
except NotFoundError as e:
|
|
230
|
+
print(e)
|
|
231
|
+
# Kroger API error 404 Not Found: (no error message in the response body)
|
|
232
|
+
# request: GET /v1/locations/99999999
|
|
233
|
+
# correlation id: akam_1792606007_ba10cee2-f276-444a-aeac-082b98ac9ca3
|
|
234
|
+
# hint: No store has this locationId. Find valid IDs with a location search.
|
|
235
|
+
except KrogerAPIError as e:
|
|
236
|
+
print(e.to_dict()) # the same details as JSON-safe data
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
| Exception | When |
|
|
240
|
+
|-----------|------|
|
|
241
|
+
| `InvalidParameterError` | An argument breaks a documented API constraint, such as `fulfillment="delivery"` (valid: `ais`, `csp`, `dth`, `sth`). No request is sent. |
|
|
242
|
+
| `ConfigurationError` | Credentials or the redirect URI are missing |
|
|
243
|
+
| `NotAuthenticatedError` | A call was made before any token was obtained |
|
|
244
|
+
| `BadRequestError` (400) | The API rejected a parameter; `kroger_message` names it |
|
|
245
|
+
| `AuthenticationError` (401) | The token, or at the token endpoint the client credentials, were rejected after one automatic refresh-and-retry |
|
|
246
|
+
| `PermissionDeniedError` (403) | The token type or scope cannot use this endpoint, such as a client-credentials token on the cart |
|
|
247
|
+
| `NotFoundError` (404) | The location, chain, or department does not exist |
|
|
248
|
+
| `RateLimitError` (429) | The endpoint's daily rate limit is used up |
|
|
249
|
+
| `InternalServerError` (5xx) | Kroger's server failed |
|
|
250
|
+
| `APIConnectionError`, `APITimeoutError` | No response arrived (requests time out after 30 seconds by default; set `KrogerAPI(timeout=...)`) |
|
|
251
|
+
|
|
252
|
+
HTTP and network errors also subclass the matching `requests` exceptions, so existing `except requests.exceptions.HTTPError` code still catches every error response from the API. An argument that fails a parameter check raises `InvalidParameterError` (a `ValueError`) without sending a request, so catch `KrogerAPIError` to handle both. A failed cart add whose `outcome_unknown` is `True` may have gone through: check the cart before sending it again. See the [error handling reference](https://cupofowls.github.io/kroger-api/reference/errors) for every attribute.
|
|
253
|
+
|
|
216
254
|
## 📚 Example Scripts
|
|
217
255
|
|
|
218
256
|
The `examples/` directory contains comprehensive demonstrations:
|
|
@@ -274,6 +312,8 @@ When requesting user authorization, you can specify these scopes:
|
|
|
274
312
|
- `cart.basic:write` - Add items to cart
|
|
275
313
|
- `profile.compact` - Read user profile information
|
|
276
314
|
|
|
315
|
+
Request only the scopes of the API products your app is registered for (`cart.basic:write` needs the Cart API, `profile.compact` the Identity API). Kroger refuses the whole sign-in with `invalid_scope` when one is missing.
|
|
316
|
+
|
|
277
317
|
## 📖 API Documentation
|
|
278
318
|
|
|
279
319
|
For complete API documentation, visit:
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A comprehensive Python client library for the Kroger Public API, featuring robust token management, comprehensive examples, and easy-to-use interfaces for all available endpoints.
|
|
4
4
|
|
|
5
|
+
📚 **Docs:** https://cupofowls.github.io/kroger-api/ — tutorials, how-to guides, concepts, and the full reference.
|
|
6
|
+
|
|
5
7
|
## 📺 Demo
|
|
6
8
|
|
|
7
9
|
Adding an item to your cart via an interactive Python script, and checking that it appears in your account:
|
|
@@ -94,20 +96,21 @@ This library implements robust, automatic token management:
|
|
|
94
96
|
### ✨ Features
|
|
95
97
|
- **Automatic token refresh** - No manual token handling required
|
|
96
98
|
- **Persistent storage** - Tokens saved securely to avoid repeated logins
|
|
97
|
-
- **
|
|
99
|
+
- **Expiry tracking** - Checks a token's expiry time before use, with no API call
|
|
98
100
|
- **Reactive recovery** - Automatically refreshes expired tokens during API calls
|
|
99
101
|
- **PKCE Support** - Enhanced OAuth security with Proof Key for Code Exchange
|
|
102
|
+
- **Shared sign-ins** - Processes and threads that share a token file renew it one at a time, so they never sign each other out
|
|
100
103
|
|
|
101
104
|
### 🔧 How it Works
|
|
102
105
|
|
|
103
106
|
**Proactive Approach:**
|
|
104
|
-
1. Loads saved tokens and
|
|
105
|
-
2. Automatically refreshes if token is expired and refresh token is available
|
|
107
|
+
1. Loads saved tokens and checks their expiry time (no API request)
|
|
108
|
+
2. Automatically refreshes if token is expired and refresh token is available, while it holds a lock on the token file (`<token file>.lock`). A process that waited for the lock uses the token that another process renewed meanwhile, so a refresh token is never sent twice. A new sign-in and `clear_token` wait for the same lock, and a token whose file `clear_token` deleted is not renewed.
|
|
106
109
|
|
|
107
110
|
**Reactive Approach:**
|
|
108
111
|
1. Makes API requests with current token
|
|
109
|
-
2. On 401 Unauthorized errors,
|
|
110
|
-
3. Retries original request with new token
|
|
112
|
+
2. On 401 Unauthorized errors, refreshes the user token, or requests a new client-credentials token
|
|
113
|
+
3. Retries the original request once with the new token
|
|
111
114
|
|
|
112
115
|
Token files (automatically managed):
|
|
113
116
|
- `.kroger_token_client_product.compact.json` - Client credentials tokens
|
|
@@ -121,7 +124,7 @@ The directory is shared with the [kroger-mcp](https://github.com/CupOfOwls/kroge
|
|
|
121
124
|
|
|
122
125
|
### 🔒 Enhanced Security with PKCE
|
|
123
126
|
|
|
124
|
-
This library supports PKCE (Proof Key for Code Exchange) for enhanced security in the OAuth flow:
|
|
127
|
+
This library supports PKCE (Proof Key for Code Exchange) for enhanced security in the OAuth flow. `authenticate_user` uses it automatically; if you build the flow yourself, pass the parameters as shown:
|
|
125
128
|
|
|
126
129
|
```python
|
|
127
130
|
from kroger_api import KrogerAPI
|
|
@@ -150,6 +153,40 @@ token_info = kroger.authorization.get_token_with_authorization_code(
|
|
|
150
153
|
|
|
151
154
|
PKCE helps protect against authorization code interception attacks, particularly important for public clients or those using external tools to manage OAuth flows.
|
|
152
155
|
|
|
156
|
+
## ⚠️ Error Handling
|
|
157
|
+
|
|
158
|
+
Every exception the library raises derives from `KrogerAPIError`. Invalid arguments are rejected before any request is sent, and every non-2xx response raises an exception that carries Kroger's own error code and message:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from kroger_api import KrogerAPI, KrogerAPIError, InvalidParameterError, NotFoundError
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
kroger.location.get_location("99999999")
|
|
165
|
+
except NotFoundError as e:
|
|
166
|
+
print(e)
|
|
167
|
+
# Kroger API error 404 Not Found: (no error message in the response body)
|
|
168
|
+
# request: GET /v1/locations/99999999
|
|
169
|
+
# correlation id: akam_1792606007_ba10cee2-f276-444a-aeac-082b98ac9ca3
|
|
170
|
+
# hint: No store has this locationId. Find valid IDs with a location search.
|
|
171
|
+
except KrogerAPIError as e:
|
|
172
|
+
print(e.to_dict()) # the same details as JSON-safe data
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
| Exception | When |
|
|
176
|
+
|-----------|------|
|
|
177
|
+
| `InvalidParameterError` | An argument breaks a documented API constraint, such as `fulfillment="delivery"` (valid: `ais`, `csp`, `dth`, `sth`). No request is sent. |
|
|
178
|
+
| `ConfigurationError` | Credentials or the redirect URI are missing |
|
|
179
|
+
| `NotAuthenticatedError` | A call was made before any token was obtained |
|
|
180
|
+
| `BadRequestError` (400) | The API rejected a parameter; `kroger_message` names it |
|
|
181
|
+
| `AuthenticationError` (401) | The token, or at the token endpoint the client credentials, were rejected after one automatic refresh-and-retry |
|
|
182
|
+
| `PermissionDeniedError` (403) | The token type or scope cannot use this endpoint, such as a client-credentials token on the cart |
|
|
183
|
+
| `NotFoundError` (404) | The location, chain, or department does not exist |
|
|
184
|
+
| `RateLimitError` (429) | The endpoint's daily rate limit is used up |
|
|
185
|
+
| `InternalServerError` (5xx) | Kroger's server failed |
|
|
186
|
+
| `APIConnectionError`, `APITimeoutError` | No response arrived (requests time out after 30 seconds by default; set `KrogerAPI(timeout=...)`) |
|
|
187
|
+
|
|
188
|
+
HTTP and network errors also subclass the matching `requests` exceptions, so existing `except requests.exceptions.HTTPError` code still catches every error response from the API. An argument that fails a parameter check raises `InvalidParameterError` (a `ValueError`) without sending a request, so catch `KrogerAPIError` to handle both. A failed cart add whose `outcome_unknown` is `True` may have gone through: check the cart before sending it again. See the [error handling reference](https://cupofowls.github.io/kroger-api/reference/errors) for every attribute.
|
|
189
|
+
|
|
153
190
|
## 📚 Example Scripts
|
|
154
191
|
|
|
155
192
|
The `examples/` directory contains comprehensive demonstrations:
|
|
@@ -211,6 +248,8 @@ When requesting user authorization, you can specify these scopes:
|
|
|
211
248
|
- `cart.basic:write` - Add items to cart
|
|
212
249
|
- `profile.compact` - Read user profile information
|
|
213
250
|
|
|
251
|
+
Request only the scopes of the API products your app is registered for (`cart.basic:write` needs the Cart API, `profile.compact` the Identity API). Kroger refuses the whole sign-in with `invalid_scope` when one is missing.
|
|
252
|
+
|
|
214
253
|
## 📖 API Documentation
|
|
215
254
|
|
|
216
255
|
For complete API documentation, visit:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Make the KrogerAPI class available at the package level
|
|
2
|
+
from kroger_api.kroger_api import KrogerAPI
|
|
3
|
+
from . import auth
|
|
4
|
+
from . import utils
|
|
5
|
+
from . import validation
|
|
6
|
+
from .client import KrogerClient
|
|
7
|
+
from .errors import (
|
|
8
|
+
KrogerAPIError,
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
InvalidParameterError,
|
|
11
|
+
NotAuthenticatedError,
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APITimeoutError,
|
|
14
|
+
APIStatusError,
|
|
15
|
+
BadRequestError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
PermissionDeniedError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
InternalServerError,
|
|
21
|
+
InvalidResponseError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
'KrogerAPI', 'auth', 'utils', 'validation', 'KrogerClient',
|
|
26
|
+
'KrogerAPIError', 'ConfigurationError', 'InvalidParameterError', 'NotAuthenticatedError',
|
|
27
|
+
'APIConnectionError', 'APITimeoutError', 'APIStatusError', 'BadRequestError',
|
|
28
|
+
'AuthenticationError', 'PermissionDeniedError', 'NotFoundError', 'RateLimitError',
|
|
29
|
+
'InternalServerError', 'InvalidResponseError',
|
|
30
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, Optional, Any
|
|
3
|
+
|
|
4
|
+
from kroger_api.client import KrogerClient
|
|
5
|
+
from kroger_api.errors import BadRequestError, NotAuthenticatedError
|
|
6
|
+
from kroger_api.token_storage import USER_TOKEN_FILE, get_token_file_path, load_token
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthorizationAPI:
|
|
12
|
+
"""
|
|
13
|
+
Provides access to the Kroger Authorization API endpoints.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client: KrogerClient):
|
|
17
|
+
"""
|
|
18
|
+
Initialize the Authorization API
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
client: The KrogerClient instance
|
|
22
|
+
"""
|
|
23
|
+
self.client = client
|
|
24
|
+
|
|
25
|
+
def get_authorization_url(self, scope: str, state: Optional[str] = None, banner: Optional[str] = None, code_challenge: Optional[str] = None, code_challenge_method: Optional[str] = None) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Get the URL to redirect the user to for authorization
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
scope: The level of access your application is requesting
|
|
31
|
+
state: A random string to verify that the response belongs to the initiated request
|
|
32
|
+
banner: Sets the chain specific branding displayed on the authorization consent screen
|
|
33
|
+
code_challenge: PKCE code challenge for enhanced security (recommended)
|
|
34
|
+
code_challenge_method: Method used to generate the code challenge (S256 is recommended)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The authorization URL
|
|
38
|
+
"""
|
|
39
|
+
return self.client.get_authorization_url(scope, state, banner, code_challenge, code_challenge_method)
|
|
40
|
+
|
|
41
|
+
def get_token_with_client_credentials(self, scope: str) -> Dict[str, Any]:
|
|
42
|
+
"""
|
|
43
|
+
Get an access token using client credentials
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
scope: The level of access your application is requesting
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The token information
|
|
50
|
+
"""
|
|
51
|
+
return self.client.get_token_with_client_credentials(scope)
|
|
52
|
+
|
|
53
|
+
def get_token_with_authorization_code(self, code: str, code_verifier: Optional[str] = None,
|
|
54
|
+
token_file: Optional[str] = None) -> Dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
Get an access token using an authorization code
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
code: The authorization code received from the redirect
|
|
60
|
+
code_verifier: PKCE code verifier used during authorization (if PKCE was used)
|
|
61
|
+
token_file: The file to save the user token to (default USER_TOKEN_FILE)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The token information
|
|
65
|
+
"""
|
|
66
|
+
return self.client.get_token_with_authorization_code(code, code_verifier, token_file)
|
|
67
|
+
|
|
68
|
+
def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
|
|
69
|
+
"""
|
|
70
|
+
Refresh an access token using a refresh token
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
refresh_token: The refresh token received from a previous token request
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The token information
|
|
77
|
+
"""
|
|
78
|
+
return self.client.refresh_token(refresh_token)
|
|
79
|
+
|
|
80
|
+
def load_saved_token(self, token_file: Optional[str] = None) -> Dict[str, Any]:
|
|
81
|
+
"""
|
|
82
|
+
Load a saved user token into the client, renewing it first if it has expired
|
|
83
|
+
|
|
84
|
+
An expired token is renewed with the token file locked, so threads
|
|
85
|
+
and processes that share the file renew it one at a time. One that
|
|
86
|
+
waited for the lock uses the token that another renewed meanwhile,
|
|
87
|
+
instead of sending a refresh token that was already used.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
token_file: The file to load the token from (default the client's
|
|
91
|
+
token file, else USER_TOKEN_FILE). A renewed token is saved
|
|
92
|
+
back to it, and it becomes the client's token file.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The token information, now in the client
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
NotAuthenticatedError: If there is no saved token, or it has
|
|
99
|
+
expired and has no refresh token, or it was deleted while
|
|
100
|
+
this call waited to renew it
|
|
101
|
+
BadRequestError: If Kroger rejected the refresh token, so the
|
|
102
|
+
user has to sign in again
|
|
103
|
+
KrogerAPIError: If renewing fails for another reason, such as a
|
|
104
|
+
network error
|
|
105
|
+
"""
|
|
106
|
+
token_file = token_file or self.client.token_file or USER_TOKEN_FILE
|
|
107
|
+
token_info = load_token(token_file)
|
|
108
|
+
if not isinstance(token_info, dict) or not token_info.get("access_token"):
|
|
109
|
+
raise NotAuthenticatedError(f"There is no saved token in {get_token_file_path(token_file)}.")
|
|
110
|
+
|
|
111
|
+
# The client's token file changes only when its token comes from this
|
|
112
|
+
# file, so a token that cannot be used never redirects later saves.
|
|
113
|
+
if not self.client.is_token_expired(token_info):
|
|
114
|
+
self.client.token_info = token_info
|
|
115
|
+
self.client.token_file = token_file
|
|
116
|
+
return token_info
|
|
117
|
+
if not token_info.get("refresh_token"):
|
|
118
|
+
raise NotAuthenticatedError(
|
|
119
|
+
f"The saved token in {get_token_file_path(token_file)} has expired and has no refresh token."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# token_info has a refresh token, so this renews it or raises
|
|
123
|
+
renewed = self.client._renew_user_token(token_file, token_info)
|
|
124
|
+
self.client.token_file = token_file
|
|
125
|
+
return renewed
|
|
126
|
+
|
|
127
|
+
def refresh_token_if_needed(self, token_file: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
128
|
+
"""
|
|
129
|
+
Load a saved token into the client, refreshing it first if it has expired
|
|
130
|
+
|
|
131
|
+
Like load_saved_token, but returns None where that raises
|
|
132
|
+
NotAuthenticatedError or BadRequestError.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
token_file: The file path to load the token from
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The token information (either existing or refreshed), or None if
|
|
139
|
+
there is no saved token, or it has expired and cannot be refreshed
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
KrogerAPIError: If refreshing fails for another reason than a
|
|
143
|
+
rejected refresh token, such as a network error
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
return self.load_saved_token(token_file)
|
|
147
|
+
except (NotAuthenticatedError, BadRequestError):
|
|
148
|
+
return None
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from typing import Dict, List, Any
|
|
2
2
|
|
|
3
|
+
from kroger_api import validation
|
|
3
4
|
from kroger_api.client import KrogerClient
|
|
4
5
|
|
|
5
6
|
|
|
@@ -23,15 +24,22 @@ class CartAPI:
|
|
|
23
24
|
|
|
24
25
|
Args:
|
|
25
26
|
items: A list of items to add to the cart. Each item should be a dictionary with keys:
|
|
26
|
-
- upc: The UPC of the item
|
|
27
|
-
- quantity: The quantity of the item
|
|
27
|
+
- upc: The UPC of the item (13 digits)
|
|
28
|
+
- quantity: The quantity of the item (an integer of 1 or more)
|
|
28
29
|
- modality: (Optional) The modality including: DELIVERY, PICKUP
|
|
29
30
|
|
|
30
31
|
Returns:
|
|
31
32
|
None if successful
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
InvalidParameterError: If an item is malformed; no request is sent
|
|
36
|
+
PermissionDeniedError: If the token is not a user token with cart.basic:write
|
|
37
|
+
BadRequestError: If the API rejects an item
|
|
32
38
|
"""
|
|
33
39
|
data = {
|
|
34
|
-
"items": items
|
|
40
|
+
"items": validation.cart_items(items)
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
# The 2xx status is the result: an unreadable body must not report a
|
|
44
|
+
# completed add as a failure, which a caller might resend
|
|
45
|
+
self.client._make_request('PUT', '/v1/cart/add', data=data, read_body=False)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from typing import Dict, Optional, Any
|
|
2
|
+
from urllib.parse import quote
|
|
3
|
+
|
|
4
|
+
from kroger_api import validation
|
|
5
|
+
from kroger_api.client import KrogerClient
|
|
6
|
+
from kroger_api.errors import NotFoundError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LocationAPI:
|
|
10
|
+
"""
|
|
11
|
+
Provides access to the Kroger Location API endpoints.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: KrogerClient):
|
|
15
|
+
"""
|
|
16
|
+
Initialize the Location API
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
client: The KrogerClient instance
|
|
20
|
+
"""
|
|
21
|
+
self.client = client
|
|
22
|
+
|
|
23
|
+
def search_locations(self,
|
|
24
|
+
zip_code: Optional[str] = None,
|
|
25
|
+
lat_long: Optional[str] = None,
|
|
26
|
+
lat: Optional[str] = None,
|
|
27
|
+
lon: Optional[str] = None,
|
|
28
|
+
radius_in_miles: Optional[int] = None,
|
|
29
|
+
limit: Optional[int] = None,
|
|
30
|
+
chain: Optional[str] = None,
|
|
31
|
+
department: Optional[str] = None,
|
|
32
|
+
location_id: Optional[str] = None) -> Dict[str, Any]:
|
|
33
|
+
"""
|
|
34
|
+
Search for locations
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
zip_code: The zip code to use as a starting point for results (5 digits)
|
|
38
|
+
lat_long: The latitude and longitude to use as a starting point for results (comma separated)
|
|
39
|
+
lat: The latitude to use as a starting point for results
|
|
40
|
+
lon: The longitude to use as a starting point for results
|
|
41
|
+
radius_in_miles: The mile radius of results (1-100)
|
|
42
|
+
limit: The number of results to return (1-200)
|
|
43
|
+
chain: The chain name
|
|
44
|
+
department: The departmentId of the department (2 characters each, comma separated)
|
|
45
|
+
location_id: Comma-separated list of locationIds (8 characters each)
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The locations matching the search criteria
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
InvalidParameterError: If a parameter is invalid; no request is sent
|
|
52
|
+
APIStatusError: If the API rejects the request
|
|
53
|
+
"""
|
|
54
|
+
params = {}
|
|
55
|
+
|
|
56
|
+
if zip_code is not None:
|
|
57
|
+
params['filter.zipCode.near'] = validation.zip_code(zip_code)
|
|
58
|
+
|
|
59
|
+
if lat_long is not None:
|
|
60
|
+
params['filter.latLong.near'] = validation.lat_long(lat_long)
|
|
61
|
+
|
|
62
|
+
if lat is not None:
|
|
63
|
+
params['filter.lat.near'] = validation.coordinate(lat, "lat", 90)
|
|
64
|
+
|
|
65
|
+
if lon is not None:
|
|
66
|
+
params['filter.lon.near'] = validation.coordinate(lon, "lon", 180)
|
|
67
|
+
|
|
68
|
+
if radius_in_miles is not None:
|
|
69
|
+
params['filter.radiusInMiles'] = validation.check_int(
|
|
70
|
+
"radius_in_miles", radius_in_miles, validation.LOCATION_RADIUS_MIN, validation.LOCATION_RADIUS_MAX)
|
|
71
|
+
|
|
72
|
+
if limit is not None:
|
|
73
|
+
params['filter.limit'] = validation.check_int(
|
|
74
|
+
"limit", limit, validation.LOCATION_LIMIT_MIN, validation.LOCATION_LIMIT_MAX)
|
|
75
|
+
|
|
76
|
+
if chain is not None:
|
|
77
|
+
params['filter.chain'] = validation.check_non_empty("chain", chain)
|
|
78
|
+
|
|
79
|
+
if department is not None:
|
|
80
|
+
params['filter.department'] = validation.department_id_list(department)
|
|
81
|
+
|
|
82
|
+
if location_id is not None:
|
|
83
|
+
params['filter.locationId'] = validation.location_id_list(location_id)
|
|
84
|
+
|
|
85
|
+
return self.client._make_request('GET', '/v1/locations', params=params)
|
|
86
|
+
|
|
87
|
+
def get_location(self, location_id: str) -> Dict[str, Any]:
|
|
88
|
+
"""
|
|
89
|
+
Get details for a specific location
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
location_id: The locationId of the store (8 characters)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The location details
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
InvalidParameterError: If location_id is malformed; no request is sent
|
|
99
|
+
NotFoundError: If no store has this locationId
|
|
100
|
+
"""
|
|
101
|
+
location_id = validation.location_id(location_id)
|
|
102
|
+
return self.client._make_request('GET', f'/v1/locations/{quote(location_id, safe="")}')
|
|
103
|
+
|
|
104
|
+
def location_exists(self, location_id: str) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Check if a location exists
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
location_id: The locationId of the store (8 characters)
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if the location exists, False otherwise
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
InvalidParameterError: If location_id is malformed; no request is sent
|
|
116
|
+
"""
|
|
117
|
+
location_id = validation.location_id(location_id)
|
|
118
|
+
return self._exists(f'/v1/locations/{quote(location_id, safe="")}')
|
|
119
|
+
|
|
120
|
+
def list_chains(self) -> Dict[str, Any]:
|
|
121
|
+
"""
|
|
122
|
+
Get a list of all chains
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A list of all chains
|
|
126
|
+
"""
|
|
127
|
+
return self.client._make_request('GET', '/v1/chains')
|
|
128
|
+
|
|
129
|
+
def get_chain(self, name: str) -> Dict[str, Any]:
|
|
130
|
+
"""
|
|
131
|
+
Get details for a specific chain
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
name: The name of the chain (case-insensitive; may contain spaces, such as "FRED MEYER STORES")
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
The chain details
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
InvalidParameterError: If name is empty; no request is sent
|
|
141
|
+
NotFoundError: If no chain has this name
|
|
142
|
+
"""
|
|
143
|
+
name = validation.check_non_empty("name", name)
|
|
144
|
+
return self.client._make_request('GET', f'/v1/chains/{quote(name, safe="")}')
|
|
145
|
+
|
|
146
|
+
def chain_exists(self, name: str) -> bool:
|
|
147
|
+
"""
|
|
148
|
+
Check if a chain exists
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
name: The name of the chain
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if the chain exists, False otherwise
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
InvalidParameterError: If name is empty; no request is sent
|
|
158
|
+
"""
|
|
159
|
+
name = validation.check_non_empty("name", name)
|
|
160
|
+
return self._exists(f'/v1/chains/{quote(name, safe="")}')
|
|
161
|
+
|
|
162
|
+
def list_departments(self) -> Dict[str, Any]:
|
|
163
|
+
"""
|
|
164
|
+
Get a list of all departments
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
A list of all departments
|
|
168
|
+
"""
|
|
169
|
+
return self.client._make_request('GET', '/v1/departments')
|
|
170
|
+
|
|
171
|
+
def get_department(self, department_id: str) -> Dict[str, Any]:
|
|
172
|
+
"""
|
|
173
|
+
Get details for a specific department
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
department_id: The departmentId of the department (2 characters)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The department details
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
InvalidParameterError: If department_id is malformed; no request is sent
|
|
183
|
+
NotFoundError: If no department has this ID
|
|
184
|
+
"""
|
|
185
|
+
department_id = validation.department_id(department_id)
|
|
186
|
+
return self.client._make_request('GET', f'/v1/departments/{quote(department_id, safe="")}')
|
|
187
|
+
|
|
188
|
+
def department_exists(self, department_id: str) -> bool:
|
|
189
|
+
"""
|
|
190
|
+
Check if a department exists
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
department_id: The departmentId of the department (2 characters)
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
True if the department exists, False otherwise
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
InvalidParameterError: If department_id is malformed; no request is sent
|
|
200
|
+
"""
|
|
201
|
+
department_id = validation.department_id(department_id)
|
|
202
|
+
return self._exists(f'/v1/departments/{quote(department_id, safe="")}')
|
|
203
|
+
|
|
204
|
+
def _exists(self, path: str) -> bool:
|
|
205
|
+
"""True if a HEAD request for path succeeds, False if it gets a 404"""
|
|
206
|
+
try:
|
|
207
|
+
self.client._make_request('HEAD', path)
|
|
208
|
+
return True
|
|
209
|
+
except NotFoundError:
|
|
210
|
+
return False
|