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.
Files changed (54) hide show
  1. {kroger_api-0.3.0/kroger_api.egg-info → kroger_api-0.4.0}/PKG-INFO +47 -7
  2. {kroger_api-0.3.0 → kroger_api-0.4.0}/README.md +45 -6
  3. kroger_api-0.4.0/kroger_api/__init__.py +30 -0
  4. kroger_api-0.4.0/kroger_api/api/authorization.py +148 -0
  5. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/api/cart.py +12 -4
  6. kroger_api-0.4.0/kroger_api/api/location.py +210 -0
  7. kroger_api-0.4.0/kroger_api/api/product.py +108 -0
  8. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/auth/interactive.py +27 -52
  9. kroger_api-0.4.0/kroger_api/client.py +649 -0
  10. kroger_api-0.4.0/kroger_api/errors.py +531 -0
  11. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/kroger_api.py +13 -5
  12. kroger_api-0.4.0/kroger_api/tests/test_auth_utils.py +200 -0
  13. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/tests/test_client.py +20 -3
  14. kroger_api-0.4.0/kroger_api/tests/test_errors.py +665 -0
  15. kroger_api-0.4.0/kroger_api/tests/test_locking.py +229 -0
  16. kroger_api-0.4.0/kroger_api/tests/test_refresh_race.py +357 -0
  17. kroger_api-0.4.0/kroger_api/tests/test_tls_outcome.py +213 -0
  18. kroger_api-0.4.0/kroger_api/tests/test_validation.py +185 -0
  19. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/token_storage.py +68 -24
  20. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/utils/__init__.py +2 -0
  21. kroger_api-0.4.0/kroger_api/utils/env.py +109 -0
  22. kroger_api-0.4.0/kroger_api/utils/locking.py +171 -0
  23. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/utils/oauth.py +18 -9
  24. kroger_api-0.4.0/kroger_api/validation.py +209 -0
  25. {kroger_api-0.3.0 → kroger_api-0.4.0/kroger_api.egg-info}/PKG-INFO +47 -7
  26. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api.egg-info/SOURCES.txt +9 -0
  27. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api.egg-info/requires.txt +1 -0
  28. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api.egg-info/top_level.txt +1 -1
  29. {kroger_api-0.3.0 → kroger_api-0.4.0}/pyproject.toml +2 -1
  30. {kroger_api-0.3.0 → kroger_api-0.4.0}/requirements.txt +1 -0
  31. kroger_api-0.3.0/kroger_api/__init__.py +0 -7
  32. kroger_api-0.3.0/kroger_api/api/authorization.py +0 -110
  33. kroger_api-0.3.0/kroger_api/api/location.py +0 -185
  34. kroger_api-0.3.0/kroger_api/api/product.py +0 -84
  35. kroger_api-0.3.0/kroger_api/client.py +0 -392
  36. kroger_api-0.3.0/kroger_api/utils/env.py +0 -76
  37. {kroger_api-0.3.0 → kroger_api-0.4.0}/LICENSE +0 -0
  38. {kroger_api-0.3.0 → kroger_api-0.4.0}/MANIFEST.in +0 -0
  39. {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/authorization_api_examples.py +0 -0
  40. {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/cart_api_examples.py +0 -0
  41. {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/clear_tokens.py +0 -0
  42. {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/identity_api_examples.py +0 -0
  43. {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/location_api_examples.py +0 -0
  44. {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/oauth_flow.py +0 -0
  45. {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/oauth_pkce_flow.py +0 -0
  46. {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/product_api_examples.py +0 -0
  47. {kroger_api-0.3.0 → kroger_api-0.4.0}/examples/token_refresh_example.py +0 -0
  48. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/api/__init__.py +0 -0
  49. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/api/identity.py +0 -0
  50. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/auth/__init__.py +0 -0
  51. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/tests/test_auth.py +0 -0
  52. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api/utils/pkce.py +0 -0
  53. {kroger_api-0.3.0 → kroger_api-0.4.0}/kroger_api.egg-info/dependency_links.txt +0 -0
  54. {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.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
- - **Proactive validation** - Tests tokens before use
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 tests them with a lightweight API request
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, attempts token refresh
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
- - **Proactive validation** - Tests tokens before use
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 tests them with a lightweight API request
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, attempts token refresh
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
- return self.client._make_request('PUT', '/v1/cart/add', data=data)
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