shopware-api-client 1.0.100__py3-none-any.whl → 1.0.118__py3-none-any.whl
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.
- shopware_api_client/base.py +191 -32
- shopware_api_client/cache.py +162 -0
- shopware_api_client/client.py +6 -3
- shopware_api_client/config.py +4 -3
- shopware_api_client/endpoints/admin/__init__.py +9 -0
- shopware_api_client/endpoints/admin/commercial/b2b_components_shopping_list.py +42 -0
- shopware_api_client/endpoints/admin/commercial/b2b_components_shopping_list_line_item.py +37 -0
- shopware_api_client/endpoints/admin/core/media.py +1 -1
- shopware_api_client/endpoints/admin/core/media_thumbnail.py +3 -1
- shopware_api_client/exceptions.py +17 -1
- {shopware_api_client-1.0.100.dist-info → shopware_api_client-1.0.118.dist-info}/METADATA +51 -3
- {shopware_api_client-1.0.100.dist-info → shopware_api_client-1.0.118.dist-info}/RECORD +14 -11
- {shopware_api_client-1.0.100.dist-info → shopware_api_client-1.0.118.dist-info}/WHEEL +1 -1
- {shopware_api_client-1.0.100.dist-info → shopware_api_client-1.0.118.dist-info/licenses}/LICENSE +0 -0
shopware_api_client/base.py
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
|
-
from datetime import UTC, datetime
|
|
3
|
+
from datetime import UTC, datetime, timezone
|
|
4
|
+
from email.utils import parsedate_to_datetime
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from math import ceil
|
|
7
|
+
from time import time
|
|
4
8
|
from typing import (
|
|
9
|
+
TYPE_CHECKING,
|
|
5
10
|
Any,
|
|
6
11
|
AsyncGenerator,
|
|
7
12
|
Callable,
|
|
@@ -9,6 +14,7 @@ from typing import (
|
|
|
9
14
|
Self,
|
|
10
15
|
Type,
|
|
11
16
|
TypeVar,
|
|
17
|
+
cast,
|
|
12
18
|
get_origin,
|
|
13
19
|
overload,
|
|
14
20
|
)
|
|
@@ -27,6 +33,7 @@ from pydantic import (
|
|
|
27
33
|
from pydantic.alias_generators import to_camel
|
|
28
34
|
from pydantic.main import IncEx
|
|
29
35
|
|
|
36
|
+
from .cache import DictCache, RedisCache
|
|
30
37
|
from .endpoints.base_fields import IdField
|
|
31
38
|
from .exceptions import (
|
|
32
39
|
SWAPIDataValidationError,
|
|
@@ -35,20 +42,41 @@ from .exceptions import (
|
|
|
35
42
|
SWAPIException,
|
|
36
43
|
SWAPIGatewayTimeout,
|
|
37
44
|
SWAPIInternalServerError,
|
|
45
|
+
SWAPIRetryException,
|
|
38
46
|
SWAPIServiceUnavailable,
|
|
39
|
-
SWAPITooManyRequests,
|
|
40
47
|
SWFilterException,
|
|
41
48
|
SWNoClientProvided,
|
|
42
49
|
)
|
|
43
50
|
from .logging import logger
|
|
44
51
|
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from redis.asyncio import Redis
|
|
54
|
+
|
|
55
|
+
APPLICATION_JSON = "application/json"
|
|
56
|
+
|
|
45
57
|
EndpointClass = TypeVar("EndpointClass", bound="EndpointBase[Any]")
|
|
46
58
|
ModelClass = TypeVar("ModelClass", bound="ApiModelBase[Any]")
|
|
59
|
+
RETRY_CACHE_KEY = "shopware-api-client:retry:{url}:{method}"
|
|
60
|
+
HEADER_X_RATE_LIMIT_LIMIT = "X-Rate-Limit-Limit"
|
|
61
|
+
HEADER_X_RATE_LIMIT_REMAINING = "X-Rate-Limit-Remaining"
|
|
62
|
+
HEADER_X_RATE_LIMIT_RESET = "X-Rate-Limit-Reset"
|
|
47
63
|
|
|
48
64
|
|
|
49
65
|
class ConfigBase:
|
|
50
|
-
def __init__(
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
url: str,
|
|
69
|
+
retry_after_threshold: int = 60,
|
|
70
|
+
redis_client: "Redis | None" = None,
|
|
71
|
+
local_cache_cleanup_cycle_seconds: int = 10,
|
|
72
|
+
) -> None:
|
|
51
73
|
self.url = url.rstrip("/")
|
|
74
|
+
self.retry_after_threshold = retry_after_threshold
|
|
75
|
+
self.cache = (
|
|
76
|
+
RedisCache(redis_client)
|
|
77
|
+
if redis_client
|
|
78
|
+
else DictCache(cleanup_cycle_seconds=local_cache_cleanup_cycle_seconds)
|
|
79
|
+
)
|
|
52
80
|
|
|
53
81
|
|
|
54
82
|
class ClientBase:
|
|
@@ -56,16 +84,19 @@ class ClientBase:
|
|
|
56
84
|
raw: bool
|
|
57
85
|
language_id: IdField | None = None
|
|
58
86
|
|
|
59
|
-
def __init__(self, config: ConfigBase, raw: bool = False):
|
|
87
|
+
def __init__(self, config: ConfigBase, raw: bool = False) -> None:
|
|
60
88
|
self.api_url = config.url
|
|
89
|
+
self.retry_after_threshold = config.retry_after_threshold
|
|
90
|
+
self.cache = config.cache
|
|
61
91
|
self.raw = raw
|
|
62
92
|
|
|
63
93
|
async def __aenter__(self) -> "Self":
|
|
64
|
-
self.
|
|
94
|
+
client = self.http_client
|
|
95
|
+
assert isinstance(client, httpx.AsyncClient), "http_client must be an instance of httpx.AsyncClient"
|
|
65
96
|
return self
|
|
66
97
|
|
|
67
98
|
async def __aexit__(self, *args: Any) -> None:
|
|
68
|
-
await self.
|
|
99
|
+
await self.http_client.aclose()
|
|
69
100
|
|
|
70
101
|
async def log_request(self, request: httpx.Request) -> None:
|
|
71
102
|
if not hasattr(request, "_content"):
|
|
@@ -82,11 +113,15 @@ class ClientBase:
|
|
|
82
113
|
response.headers,
|
|
83
114
|
)
|
|
84
115
|
|
|
85
|
-
|
|
116
|
+
@cached_property
|
|
117
|
+
def http_client(self) -> httpx.AsyncClient:
|
|
118
|
+
return self._get_http_client()
|
|
119
|
+
|
|
120
|
+
def _get_http_client(self) -> httpx.AsyncClient:
|
|
86
121
|
raise NotImplementedError()
|
|
87
122
|
|
|
88
123
|
def _get_headers(self) -> dict[str, str]:
|
|
89
|
-
headers = {"Content-Type":
|
|
124
|
+
headers = {"Content-Type": APPLICATION_JSON, "Accept": APPLICATION_JSON}
|
|
90
125
|
|
|
91
126
|
if self.language_id is not None:
|
|
92
127
|
headers["sw-language-id"] = str(self.language_id)
|
|
@@ -95,52 +130,155 @@ class ClientBase:
|
|
|
95
130
|
|
|
96
131
|
@property
|
|
97
132
|
def timeout(self) -> httpx.Timeout:
|
|
98
|
-
client = self.
|
|
133
|
+
client = self.http_client
|
|
99
134
|
return client.timeout
|
|
100
135
|
|
|
101
136
|
@timeout.setter
|
|
102
137
|
def timeout(self, timeout: httpx._types.TimeoutTypes) -> None:
|
|
103
|
-
client = self.
|
|
138
|
+
client = self.http_client
|
|
104
139
|
client.timeout = timeout # type: ignore
|
|
105
140
|
|
|
141
|
+
async def sleep_and_increment(self, retry_wait_base: int, retry_count: int) -> int:
|
|
142
|
+
retry_count += 1
|
|
143
|
+
sleep_and_increment = retry_wait_base**retry_count
|
|
144
|
+
logger.debug(f"Try failed, retrying in {sleep_and_increment} seconds.")
|
|
145
|
+
await asyncio.sleep(sleep_and_increment)
|
|
146
|
+
return retry_count
|
|
147
|
+
|
|
148
|
+
def get_header_ts(self, header: str | None, fallback_time: float) -> float:
|
|
149
|
+
if header is None:
|
|
150
|
+
return fallback_time
|
|
151
|
+
|
|
152
|
+
server_dt = parsedate_to_datetime(header)
|
|
153
|
+
# ensure timezone-aware UTC
|
|
154
|
+
if server_dt.tzinfo is None:
|
|
155
|
+
server_dt = server_dt.replace(tzinfo=timezone.utc)
|
|
156
|
+
|
|
157
|
+
return server_dt.timestamp()
|
|
158
|
+
|
|
159
|
+
def parse_reset_time(self, headers: httpx.Headers) -> int:
|
|
160
|
+
"""Determine reset wait time based on server time"""
|
|
161
|
+
server_ts = self.get_header_ts(headers.get("Date"), time())
|
|
162
|
+
reset_ts = float(headers.get(HEADER_X_RATE_LIMIT_RESET, "0"))
|
|
163
|
+
adjusted_time = reset_ts - server_ts
|
|
164
|
+
|
|
165
|
+
return max(0, ceil(adjusted_time))
|
|
166
|
+
|
|
167
|
+
def parse_retry_after(self, headers: httpx.Headers) -> int:
|
|
168
|
+
retry_header: str | None = headers.get("Retry-After")
|
|
169
|
+
if retry_header is None:
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
if retry_header.isdigit():
|
|
173
|
+
return max(1, int(retry_header))
|
|
174
|
+
|
|
175
|
+
current_time = time()
|
|
176
|
+
server_ts = self.get_header_ts(headers.get("Date"), current_time)
|
|
177
|
+
retry_ts = self.get_header_ts(retry_header, current_time)
|
|
178
|
+
adjusted_time = retry_ts - server_ts
|
|
179
|
+
|
|
180
|
+
return max(1, ceil(adjusted_time))
|
|
181
|
+
|
|
106
182
|
async def _make_request(self, method: str, relative_url: str, **kwargs: Any) -> httpx.Response:
|
|
107
183
|
if relative_url.startswith("http://") or relative_url.startswith("https://"):
|
|
108
184
|
url = relative_url
|
|
109
185
|
else:
|
|
110
186
|
url = f"{self.api_url}{relative_url}"
|
|
111
|
-
client = self.
|
|
187
|
+
client = self.http_client
|
|
112
188
|
|
|
113
189
|
headers = self._get_headers()
|
|
114
190
|
headers.update(kwargs.pop("headers", {}))
|
|
115
191
|
|
|
192
|
+
retry_after_threshold = int(kwargs.pop("retry_after_threshold", self.retry_after_threshold))
|
|
193
|
+
retry_wait_base = int(kwargs.pop("retry_wait_base", 2))
|
|
116
194
|
retries = int(kwargs.pop("retries", 0))
|
|
117
195
|
retry_errors = tuple(
|
|
118
196
|
kwargs.pop("retry_errors", [SWAPIInternalServerError, SWAPIServiceUnavailable, SWAPIGatewayTimeout])
|
|
119
197
|
)
|
|
120
|
-
no_retry_errors = tuple(kwargs.pop("no_retry_errors", [
|
|
198
|
+
no_retry_errors = tuple(kwargs.pop("no_retry_errors", []))
|
|
121
199
|
|
|
122
200
|
kwargs.setdefault("follow_redirects", True)
|
|
123
201
|
|
|
202
|
+
key_base = RETRY_CACHE_KEY.format(
|
|
203
|
+
url=url.removeprefix("https://").removeprefix("http://"),
|
|
204
|
+
method=method,
|
|
205
|
+
)
|
|
206
|
+
x_retry_limit_cache_key = key_base + ":limit"
|
|
207
|
+
x_retry_remaining_cache_key = key_base + ":remaining"
|
|
208
|
+
x_retry_reset_cache_key = key_base + ":reset"
|
|
209
|
+
x_retry_lock_cache_key = key_base + ":lock"
|
|
210
|
+
got_lock = False
|
|
211
|
+
|
|
124
212
|
retry_count = 0
|
|
125
213
|
while True:
|
|
214
|
+
x_retry_remaining = await self.cache.get_and_decrement(x_retry_remaining_cache_key)
|
|
215
|
+
if x_retry_remaining is not None and x_retry_remaining <= 0 and not got_lock:
|
|
216
|
+
current_time = int(time())
|
|
217
|
+
reset_time = cast(int, await self.cache.get(x_retry_reset_cache_key)) or 0
|
|
218
|
+
wait_time = max(1, reset_time - current_time)
|
|
219
|
+
|
|
220
|
+
if wait_time > retry_after_threshold:
|
|
221
|
+
raise SWAPIRetryException(
|
|
222
|
+
f"Retry threshold exceeded for endpoint {url!r}. Threshold: {retry_after_threshold}s, Retry-After: {wait_time}s"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
await asyncio.sleep(wait_time)
|
|
226
|
+
|
|
227
|
+
got_lock = await self.cache.has_lock(x_retry_lock_cache_key, wait_time)
|
|
228
|
+
continue
|
|
229
|
+
|
|
126
230
|
try:
|
|
127
231
|
response = await client.request(method, url, headers=headers, **kwargs)
|
|
128
232
|
except httpx.RequestError as exc:
|
|
129
|
-
if retry_count
|
|
233
|
+
if retry_count >= retries:
|
|
130
234
|
raise SWAPIException(f"HTTP client exception ({exc.__class__.__name__}). Details: {str(exc)}")
|
|
131
|
-
await
|
|
132
|
-
retry_count += 1
|
|
235
|
+
retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
|
|
133
236
|
continue
|
|
134
237
|
|
|
135
|
-
if
|
|
238
|
+
# Set retry-cache if headers are present
|
|
239
|
+
if rl_limit := response.headers.get(HEADER_X_RATE_LIMIT_LIMIT):
|
|
240
|
+
x_retry_limit = int(rl_limit)
|
|
241
|
+
wait_time = self.parse_reset_time(response.headers)
|
|
242
|
+
remaining_requests = int(response.headers.get(HEADER_X_RATE_LIMIT_REMAINING))
|
|
243
|
+
|
|
244
|
+
tasks = [
|
|
245
|
+
self.cache.set(x_retry_remaining_cache_key, remaining_requests),
|
|
246
|
+
self.cache.set(x_retry_limit_cache_key, x_retry_limit),
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
if wait_time > 0:
|
|
250
|
+
tasks.append(self.cache.set(x_retry_reset_cache_key, int(time()) + wait_time, wait_time))
|
|
251
|
+
|
|
252
|
+
await asyncio.gather(*tasks)
|
|
253
|
+
|
|
254
|
+
if got_lock:
|
|
255
|
+
await self.cache.delete(x_retry_lock_cache_key)
|
|
256
|
+
got_lock = False
|
|
257
|
+
|
|
258
|
+
if response.status_code == 429:
|
|
259
|
+
retry_wait_time = self.parse_retry_after(response.headers)
|
|
260
|
+
if retry_wait_time > retry_after_threshold:
|
|
261
|
+
error = SWAPIError.from_response(response)
|
|
262
|
+
raise SWAPIRetryException(
|
|
263
|
+
f"Retry threshold exceeded for endpoint {url!r}. Threshold: {retry_after_threshold}s, Retry-After: {retry_wait_time}s"
|
|
264
|
+
) from error
|
|
265
|
+
|
|
266
|
+
# If 429 is thrown, Retry-After == X-Rate-Limit-Reset
|
|
267
|
+
await asyncio.gather(
|
|
268
|
+
self.cache.set(x_retry_reset_cache_key, int(time()) + retry_wait_time, retry_wait_time),
|
|
269
|
+
asyncio.sleep(retry_wait_time),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
elif response.status_code >= 400:
|
|
273
|
+
# retry other failure codes
|
|
136
274
|
try:
|
|
137
275
|
errors: list = response.json().get("errors")
|
|
138
276
|
# ensure `errors` attribute is a list/tuple, fallback to from_response if not
|
|
139
277
|
if not isinstance(errors, (list, tuple)):
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
error: SWAPIError | SWAPIErrorList = SWAPIError.from_errors(errors)
|
|
143
|
-
except
|
|
278
|
+
raise ValueError("`errors` attribute in json not a list/tuple!")
|
|
279
|
+
|
|
280
|
+
error: SWAPIError | SWAPIErrorList = SWAPIError.from_errors(errors, response) # type: ignore
|
|
281
|
+
except ValueError:
|
|
144
282
|
error: SWAPIError | SWAPIErrorList = SWAPIError.from_response(response) # type: ignore
|
|
145
283
|
|
|
146
284
|
if isinstance(error, SWAPIErrorList) and len(error.errors) == 1:
|
|
@@ -156,12 +294,27 @@ class ClientBase:
|
|
|
156
294
|
elif isinstance(error, no_retry_errors) or not isinstance(error, retry_errors):
|
|
157
295
|
raise error
|
|
158
296
|
|
|
159
|
-
if retry_count
|
|
297
|
+
if retry_count >= retries:
|
|
160
298
|
raise error
|
|
161
299
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
300
|
+
retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
|
|
301
|
+
elif response.status_code == 200 and response.headers.get("Content-Type", "").startswith(APPLICATION_JSON):
|
|
302
|
+
# guard against "200 okay" responses with malformed json
|
|
303
|
+
try:
|
|
304
|
+
setattr(response, "json_cached", response.json())
|
|
305
|
+
return response
|
|
306
|
+
except json.JSONDecodeError:
|
|
307
|
+
# retries exhausted?
|
|
308
|
+
if retry_count >= retries:
|
|
309
|
+
response.status_code = 500
|
|
310
|
+
exception = SWAPIError.from_response(response)
|
|
311
|
+
# prefix details with x-trace-header to
|
|
312
|
+
exception.detail = (
|
|
313
|
+
f"x-trace-id: {str(response.headers.get('x-trace-id', 'not-set'))}" + exception.detail
|
|
314
|
+
)
|
|
315
|
+
raise exception
|
|
316
|
+
|
|
317
|
+
retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
|
|
165
318
|
else:
|
|
166
319
|
return response
|
|
167
320
|
|
|
@@ -187,7 +340,7 @@ class ClientBase:
|
|
|
187
340
|
)
|
|
188
341
|
|
|
189
342
|
async def close(self) -> None:
|
|
190
|
-
await self.
|
|
343
|
+
await self.http_client.aclose()
|
|
191
344
|
|
|
192
345
|
async def bulk_upsert(
|
|
193
346
|
self,
|
|
@@ -289,7 +442,8 @@ class ApiModelBase(BaseModel, Generic[EndpointClass]):
|
|
|
289
442
|
|
|
290
443
|
def _get_endpoint(self) -> EndpointClass:
|
|
291
444
|
# we want a fresh endpoint
|
|
292
|
-
|
|
445
|
+
client = self._get_client()
|
|
446
|
+
endpoint: EndpointClass = getattr(client, self._identifier).__class__(client) # type: ignore
|
|
293
447
|
return endpoint
|
|
294
448
|
|
|
295
449
|
async def save(self, force_insert: bool = False, update_fields: IncEx | None = None) -> Self | dict | None:
|
|
@@ -369,10 +523,13 @@ class EndpointBase(Generic[ModelClass]):
|
|
|
369
523
|
if not self.model_class.__pydantic_complete__:
|
|
370
524
|
self.model_class.model_rebuild()
|
|
371
525
|
|
|
526
|
+
if name == getattr(self.model_class, "_identifier").get_default():
|
|
527
|
+
return name
|
|
528
|
+
|
|
372
529
|
field = self.model_class.model_fields[name]
|
|
373
530
|
|
|
374
531
|
if get_origin(field.annotation) in [ForeignRelation, ManyRelation]:
|
|
375
|
-
return to_camel(name)
|
|
532
|
+
return cast(str, to_camel(name))
|
|
376
533
|
else:
|
|
377
534
|
return self.model_class.model_fields[name].serialization_alias or name
|
|
378
535
|
|
|
@@ -440,7 +597,7 @@ class EndpointBase(Generic[ModelClass]):
|
|
|
440
597
|
|
|
441
598
|
return data
|
|
442
599
|
|
|
443
|
-
def
|
|
600
|
+
def _parse_data_single(self, reponse_dict: dict[str, Any]) -> dict[str, Any]:
|
|
444
601
|
return self._parse_data(reponse_dict)[0]
|
|
445
602
|
|
|
446
603
|
async def all(self) -> list[ModelClass] | list[dict[str, Any]]:
|
|
@@ -462,7 +619,7 @@ class EndpointBase(Generic[ModelClass]):
|
|
|
462
619
|
|
|
463
620
|
async def get(self, pk: str) -> ModelClass | dict[str, Any]:
|
|
464
621
|
result = await self.client.get(f"{self.path}/{pk}")
|
|
465
|
-
result_data: dict[str, Any] = self.
|
|
622
|
+
result_data: dict[str, Any] = self._parse_data_single(result.json())
|
|
466
623
|
|
|
467
624
|
if self.raw:
|
|
468
625
|
return result_data
|
|
@@ -482,7 +639,7 @@ class EndpointBase(Generic[ModelClass]):
|
|
|
482
639
|
if result.status_code == 204:
|
|
483
640
|
return None
|
|
484
641
|
|
|
485
|
-
result_data: dict[str, Any] = self.
|
|
642
|
+
result_data: dict[str, Any] = self._parse_data_single(result.json())
|
|
486
643
|
|
|
487
644
|
if self.raw:
|
|
488
645
|
return result_data
|
|
@@ -512,7 +669,7 @@ class EndpointBase(Generic[ModelClass]):
|
|
|
512
669
|
if result.status_code == 204:
|
|
513
670
|
return None
|
|
514
671
|
|
|
515
|
-
result_data: dict[str, Any] = self.
|
|
672
|
+
result_data: dict[str, Any] = self._parse_data_single(result.json())
|
|
516
673
|
|
|
517
674
|
if self.raw:
|
|
518
675
|
return result_data
|
|
@@ -542,7 +699,9 @@ class EndpointBase(Generic[ModelClass]):
|
|
|
542
699
|
return self
|
|
543
700
|
|
|
544
701
|
def only(self, **kwargs: list[str]) -> Self:
|
|
545
|
-
|
|
702
|
+
for field, data in kwargs.items():
|
|
703
|
+
self._includes[self._serialize_field_name(field)] = [self._serialize_field_name(d) for d in data]
|
|
704
|
+
|
|
546
705
|
return self
|
|
547
706
|
|
|
548
707
|
def filter(self, **kwargs: Any) -> Self:
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from time import time
|
|
5
|
+
from typing import Any, Awaitable, NamedTuple, cast
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from redis.asyncio import Redis
|
|
9
|
+
|
|
10
|
+
_has_redis = True
|
|
11
|
+
except ModuleNotFoundError:
|
|
12
|
+
_has_redis = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CacheBase(ABC):
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def get(self, key: str) -> Any | None:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def get_and_decrement(self, key: str) -> int | None:
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def has_lock(self, key: str, ttl: int) -> bool:
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def delete(self, key: str) -> None:
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _json_encode(key: str, value: Any) -> str:
|
|
38
|
+
try:
|
|
39
|
+
return json.dumps(value)
|
|
40
|
+
except (TypeError, ValueError) as e:
|
|
41
|
+
raise ValueError(f"Value {value!r} for cache key {key!r} must be JSON-serializable: {e}") from e
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def _json_decode(raw_value: Any) -> Any | None:
|
|
45
|
+
if raw_value is None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(raw_value)
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
# If write was broken, mimic missing key
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RedisCache(CacheBase):
|
|
56
|
+
_DECR_IF_EXISTS = """
|
|
57
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then
|
|
58
|
+
return redis.call('DECR', KEYS[1])
|
|
59
|
+
else
|
|
60
|
+
return nil
|
|
61
|
+
end
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, redis_client: "Redis") -> None:
|
|
65
|
+
if not _has_redis:
|
|
66
|
+
raise RuntimeError("Redis needs to be installed to use it as a cache.")
|
|
67
|
+
|
|
68
|
+
self.client = redis_client
|
|
69
|
+
|
|
70
|
+
async def get(self, key: str) -> Any | None:
|
|
71
|
+
value = await self.client.get(key)
|
|
72
|
+
return self._json_decode(value)
|
|
73
|
+
|
|
74
|
+
async def get_and_decrement(self, key: str) -> int | None:
|
|
75
|
+
return_value = await cast(Awaitable[int | None], self.client.eval(self._DECR_IF_EXISTS, 1, key))
|
|
76
|
+
return None if return_value is None else return_value + 1
|
|
77
|
+
|
|
78
|
+
async def has_lock(self, key: str, ttl: int) -> bool:
|
|
79
|
+
return await self.client.set(key, 1, ex=ttl, nx=True) or False
|
|
80
|
+
|
|
81
|
+
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
82
|
+
await self.client.set(name=key, value=self._json_encode(key, value), ex=ttl)
|
|
83
|
+
|
|
84
|
+
async def delete(self, key: str) -> None:
|
|
85
|
+
await self.client.delete(key)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DictCache(CacheBase):
|
|
89
|
+
class CacheValue(NamedTuple):
|
|
90
|
+
value: Any
|
|
91
|
+
expire_at: int | None
|
|
92
|
+
|
|
93
|
+
def __init__(self, cleanup_cycle_seconds: int) -> None:
|
|
94
|
+
self._cache: OrderedDict[str, DictCache.CacheValue] = OrderedDict()
|
|
95
|
+
self.cleanup_cycle_seconds = cleanup_cycle_seconds
|
|
96
|
+
self.next_cleanup: int = 0
|
|
97
|
+
|
|
98
|
+
async def get(self, key: str) -> Any | None:
|
|
99
|
+
self._cleanup_by_expiry()
|
|
100
|
+
entry = self._cache.get(key)
|
|
101
|
+
|
|
102
|
+
return self._json_decode(entry and entry.value)
|
|
103
|
+
|
|
104
|
+
async def get_and_decrement(self, key: str) -> int | None:
|
|
105
|
+
self._cleanup_by_expiry()
|
|
106
|
+
entry = self._cache.get(key)
|
|
107
|
+
if entry is None:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
decoded_value = self._json_decode(entry.value)
|
|
111
|
+
if not isinstance(decoded_value, int):
|
|
112
|
+
raise ValueError(f"Trying to decrement key {key!r}, but value {decoded_value!r} is not an int")
|
|
113
|
+
|
|
114
|
+
new_value = decoded_value - 1
|
|
115
|
+
self._cache[key] = DictCache.CacheValue(self._json_encode(key, new_value), entry.expire_at)
|
|
116
|
+
|
|
117
|
+
return decoded_value
|
|
118
|
+
|
|
119
|
+
async def has_lock(self, key: str, ttl: int) -> bool:
|
|
120
|
+
self._cleanup_by_expiry(False)
|
|
121
|
+
entry = self._cache.get(key)
|
|
122
|
+
if unlocked := entry is None:
|
|
123
|
+
self._cache[key] = DictCache.CacheValue(True, self.get_expiry(ttl))
|
|
124
|
+
|
|
125
|
+
return unlocked
|
|
126
|
+
|
|
127
|
+
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
128
|
+
self._cleanup_by_expiry()
|
|
129
|
+
cache_value = self._json_encode(key, value)
|
|
130
|
+
|
|
131
|
+
self._cache[key] = DictCache.CacheValue(cache_value, self.get_expiry(ttl))
|
|
132
|
+
|
|
133
|
+
async def delete(self, key: str) -> None:
|
|
134
|
+
self._cleanup_by_expiry()
|
|
135
|
+
self._cache.pop(key, None)
|
|
136
|
+
|
|
137
|
+
def get_expiry(self, ttl: int | None) -> int | None:
|
|
138
|
+
return int(time()) + ttl if ttl is not None else None
|
|
139
|
+
|
|
140
|
+
def _cleanup_by_expiry(self, do_wait: bool = True) -> None:
|
|
141
|
+
now = int(time())
|
|
142
|
+
|
|
143
|
+
if now < self.next_cleanup and do_wait:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
initial_size = len(self._cache)
|
|
147
|
+
for _ in range(initial_size):
|
|
148
|
+
if not self._cache:
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
first_key, first_value = next(iter(self._cache.items()))
|
|
152
|
+
|
|
153
|
+
if first_value.expire_at is None:
|
|
154
|
+
self._cache.move_to_end(first_key)
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
if first_value.expire_at > now:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
self._cache.popitem(last=False)
|
|
161
|
+
|
|
162
|
+
self.next_cleanup = now + self.cleanup_cycle_seconds
|
shopware_api_client/client.py
CHANGED
|
@@ -19,7 +19,7 @@ class AdminClient(ClientBase, AdminEndpoints):
|
|
|
19
19
|
self._client: httpx.AsyncClient | None = None
|
|
20
20
|
self.init_endpoints(self)
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def _get_http_client(self) -> httpx.AsyncClient:
|
|
23
23
|
if self._client is None:
|
|
24
24
|
self._client = httpx.AsyncClient(
|
|
25
25
|
event_hooks={"request": [self.log_request], "response": [self.log_response]}
|
|
@@ -164,7 +164,10 @@ class AdminClient(ClientBase, AdminEndpoints):
|
|
|
164
164
|
|
|
165
165
|
result = await self._retry_bulk_parts(action="delete", name=name, objs=objs, exception=e, **request_kwargs)
|
|
166
166
|
else:
|
|
167
|
-
|
|
167
|
+
if hasattr(response, "json_cached"):
|
|
168
|
+
result = response.json_cached
|
|
169
|
+
else:
|
|
170
|
+
result = response.json()
|
|
168
171
|
|
|
169
172
|
return result
|
|
170
173
|
|
|
@@ -177,7 +180,7 @@ class StoreClient(ClientBase, StoreEndpoints):
|
|
|
177
180
|
self._client: httpx.AsyncClient | None = None
|
|
178
181
|
self.init_endpoints(self)
|
|
179
182
|
|
|
180
|
-
def
|
|
183
|
+
def _get_http_client(self) -> httpx.AsyncClient:
|
|
181
184
|
if self._client is None:
|
|
182
185
|
self._client = httpx.AsyncClient(
|
|
183
186
|
event_hooks={"request": [self.log_request], "response": [self.log_response]},
|
shopware_api_client/config.py
CHANGED
|
@@ -14,6 +14,7 @@ class AdminConfig(ConfigBase):
|
|
|
14
14
|
client_secret: str | None = None,
|
|
15
15
|
grant_type: str = "client_credentials",
|
|
16
16
|
extra: dict[str, Any] | None = None,
|
|
17
|
+
**kwargs: Any,
|
|
17
18
|
) -> None:
|
|
18
19
|
match grant_type:
|
|
19
20
|
case "client_credentials":
|
|
@@ -27,7 +28,7 @@ class AdminConfig(ConfigBase):
|
|
|
27
28
|
case _:
|
|
28
29
|
raise SWAPIConfigException("Invalid 'grant_type'. Must be one of: 'client_credentials', 'password'")
|
|
29
30
|
|
|
30
|
-
super().__init__(url=url)
|
|
31
|
+
super().__init__(url=url, **kwargs)
|
|
31
32
|
self.username = username
|
|
32
33
|
self.password = password
|
|
33
34
|
self.client_id = client_id
|
|
@@ -37,7 +38,7 @@ class AdminConfig(ConfigBase):
|
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
class StoreConfig(ConfigBase):
|
|
40
|
-
def __init__(self, url: str, access_key: str, context_token: str | None = None):
|
|
41
|
-
super().__init__(url=url)
|
|
41
|
+
def __init__(self, url: str, access_key: str, context_token: str | None = None, **kwargs: Any):
|
|
42
|
+
super().__init__(url=url, **kwargs)
|
|
42
43
|
self.access_key = access_key
|
|
43
44
|
self.context_token = context_token
|
|
@@ -4,6 +4,11 @@ if TYPE_CHECKING:
|
|
|
4
4
|
from ...client import AdminClient
|
|
5
5
|
|
|
6
6
|
from .commercial.b2b_components_role import B2bComponentsRole, B2bComponentsRoleEndpoint
|
|
7
|
+
from .commercial.b2b_components_shopping_list import B2bComponentsShoppingList, B2bComponentsShoppingListEndpoint
|
|
8
|
+
from .commercial.b2b_components_shopping_list_line_item import (
|
|
9
|
+
B2bComponentsShoppingListLineItem,
|
|
10
|
+
B2bComponentsShoppingListLineItemEndpoint,
|
|
11
|
+
)
|
|
7
12
|
from .commercial.b2b_employee import B2bEmployee, B2bEmployeeEndpoint
|
|
8
13
|
from .commercial.dynamic_access import DynamicAccess, DynamicAccessEndpoint
|
|
9
14
|
from .core.acl_role import AclRole, AclRoleEndpoint
|
|
@@ -111,6 +116,8 @@ __all__ = [
|
|
|
111
116
|
"AdminEndpoints",
|
|
112
117
|
"B2bComponentsRole",
|
|
113
118
|
"B2bEmployee",
|
|
119
|
+
"B2bComponentsShoppingList",
|
|
120
|
+
"B2bComponentsShoppingListLineItem",
|
|
114
121
|
"ApiInfo",
|
|
115
122
|
"App",
|
|
116
123
|
"AppScriptCondition",
|
|
@@ -269,6 +276,8 @@ class AdminEndpoints:
|
|
|
269
276
|
self.b2b_employee = B2bEmployeeEndpoint(client)
|
|
270
277
|
self.b2b_components_role = B2bComponentsRoleEndpoint(client)
|
|
271
278
|
self.dynamic_access = DynamicAccessEndpoint(client)
|
|
279
|
+
self.b2b_components_shopping_list = B2bComponentsShoppingListEndpoint(client)
|
|
280
|
+
self.b2b_components_shopping_list_line_item = B2bComponentsShoppingListLineItemEndpoint(client)
|
|
272
281
|
|
|
273
282
|
# Core
|
|
274
283
|
self.acl_role = AclRoleEndpoint(client)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ....base import ApiModelBase, EndpointBase, EndpointClass
|
|
6
|
+
from ...base_fields import Price
|
|
7
|
+
from ...relations import ForeignRelation, ManyRelation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class B2bComponentsShoppingListBase(ApiModelBase[EndpointClass]):
|
|
11
|
+
_identifier: str = "b2b_components_shopping_list"
|
|
12
|
+
|
|
13
|
+
name: str | None = None
|
|
14
|
+
active: bool | None = None
|
|
15
|
+
custom_fields: dict[str, Any] | None = None
|
|
16
|
+
price: Price | None = None
|
|
17
|
+
sales_channel_id: str | None = None
|
|
18
|
+
customer_id: str | None = None
|
|
19
|
+
employee_id: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class B2bComponentsShoppingListRelations:
|
|
23
|
+
sales_channel: ForeignRelation["SalesChannel"]
|
|
24
|
+
line_items: ManyRelation["B2bComponentsShoppingListLineItem"]
|
|
25
|
+
customer: ForeignRelation["Customer"]
|
|
26
|
+
employee: ForeignRelation["B2bEmployee"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class B2bComponentsShoppingList(B2bComponentsShoppingListBase["B2bComponentsShoppingListEndpoint"], B2bComponentsShoppingListRelations):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class B2bComponentsShoppingListEndpoint(EndpointBase[B2bComponentsShoppingList]):
|
|
34
|
+
name = "b2b_components_shopping_list"
|
|
35
|
+
path = "/b2b-components-shopping-list"
|
|
36
|
+
model_class = B2bComponentsShoppingList
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
from ..core.customer import Customer # noqa: E402
|
|
40
|
+
from ..core.sales_channel import SalesChannel # noqa: E402
|
|
41
|
+
from .b2b_components_shopping_list_line_item import B2bComponentsShoppingListLineItem # noqa: E402
|
|
42
|
+
from .b2b_employee import B2bEmployee # noqa: E402
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ....base import ApiModelBase, EndpointBase, EndpointClass
|
|
4
|
+
from ...base_fields import IdField
|
|
5
|
+
from ...relations import ForeignRelation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class B2bComponentsShoppingListLineItemBase(ApiModelBase[EndpointClass]):
|
|
9
|
+
_identifier: str = "b2b_components_shopping_list_line_item"
|
|
10
|
+
product_id: IdField
|
|
11
|
+
product_version_id: IdField | None = None
|
|
12
|
+
quantity: int
|
|
13
|
+
price: dict[str, Any] | None = None
|
|
14
|
+
custom_fields: dict[str, Any] | None = None
|
|
15
|
+
shopping_list_id: IdField
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class B2bComponentsShoppingListLineItemRelations:
|
|
19
|
+
shopping_list: ForeignRelation["B2bComponentsShoppingList"]
|
|
20
|
+
product: ForeignRelation["Product"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class B2bComponentsShoppingListLineItem(
|
|
24
|
+
B2bComponentsShoppingListLineItemBase["B2bComponentsShoppingListLineItemEndpoint"],
|
|
25
|
+
B2bComponentsShoppingListLineItemRelations,
|
|
26
|
+
):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class B2bComponentsShoppingListLineItemEndpoint(EndpointBase[B2bComponentsShoppingListLineItem]):
|
|
31
|
+
name = "b2b_components_shopping_list_line_item"
|
|
32
|
+
path = "/b2b-components-shopping-list-line-item"
|
|
33
|
+
model_class = B2bComponentsShoppingListLineItem
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
from ..core.product import Product # noqa: E402
|
|
37
|
+
from .b2b_components_shopping_list import B2bComponentsShoppingList # noqa: E402
|
|
@@ -13,7 +13,7 @@ class MediaBase(ApiModelBase[EndpointClass]):
|
|
|
13
13
|
user_id: IdField | None = None
|
|
14
14
|
media_folder_id: IdField | None = Field(default=None)
|
|
15
15
|
mime_type: str | None = Field(default=None)
|
|
16
|
-
file_extension: str | None = Field(default=None
|
|
16
|
+
file_extension: str | None = Field(default=None)
|
|
17
17
|
uploaded_at: AwareDatetime | None = Field(default=None, exclude=True)
|
|
18
18
|
file_name: str | None = Field(default=None)
|
|
19
19
|
file_size: int | None = Field(default=None, exclude=True)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
from datetime import UTC, datetime
|
|
1
2
|
from typing import Any
|
|
2
3
|
|
|
3
|
-
from pydantic import Field
|
|
4
|
+
from pydantic import AwareDatetime, Field
|
|
4
5
|
|
|
5
6
|
from ....base import ApiModelBase, EndpointBase, EndpointClass
|
|
6
7
|
from ...base_fields import IdField
|
|
@@ -9,6 +10,7 @@ from ...relations import ForeignRelation
|
|
|
9
10
|
|
|
10
11
|
class MediaThumbnailBase(ApiModelBase[EndpointClass]):
|
|
11
12
|
_identifier = "media_thumbnail"
|
|
13
|
+
created_at: AwareDatetime | None = Field(default_factory=lambda: datetime.now(UTC), exclude=True) # type: ignore
|
|
12
14
|
|
|
13
15
|
media_id: IdField
|
|
14
16
|
width: int = Field(default=0, exclude=True)
|
|
@@ -24,6 +24,10 @@ class SWAPIConfigException(SWAPIException):
|
|
|
24
24
|
pass
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
class SWAPIRetryException(SWAPIException):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
27
31
|
class SWAPIMethodNotAvailable(SWAPIConfigException):
|
|
28
32
|
def __init__(self, msg: str | None = None, *args: list[Any], **kwargs: dict[Any, Any]) -> None:
|
|
29
33
|
if not msg:
|
|
@@ -43,6 +47,8 @@ class SWAPIError(SWAPIException):
|
|
|
43
47
|
self.source = kwargs.get("source", {})
|
|
44
48
|
self.meta = kwargs.get("meta", {})
|
|
45
49
|
self.headers = kwargs.get("headers", {})
|
|
50
|
+
self.request = kwargs.get("request", None)
|
|
51
|
+
self.response = kwargs.get("response", None)
|
|
46
52
|
|
|
47
53
|
def __str__(self) -> str:
|
|
48
54
|
return f"Status: {self.status} {self.title} - {self.detail} - {self.source}"
|
|
@@ -83,10 +89,11 @@ class SWAPIError(SWAPIException):
|
|
|
83
89
|
return SWAPIError
|
|
84
90
|
|
|
85
91
|
@classmethod
|
|
86
|
-
def from_errors(cls, errors: list[dict[str, Any]]) -> "SWAPIErrorList":
|
|
92
|
+
def from_errors(cls, errors: list[dict[str, Any]], response: Response) -> "SWAPIErrorList":
|
|
87
93
|
errlist = []
|
|
88
94
|
|
|
89
95
|
for error in errors:
|
|
96
|
+
error.update({"response": response, "request": response.request})
|
|
90
97
|
exception_class = cls.get_exception_class(int(error["status"]))
|
|
91
98
|
errlist.append(exception_class(**error))
|
|
92
99
|
|
|
@@ -95,11 +102,20 @@ class SWAPIError(SWAPIException):
|
|
|
95
102
|
@classmethod
|
|
96
103
|
def from_response(cls, response: Response) -> "SWAPIError":
|
|
97
104
|
exception_class = cls.get_exception_class(response.status_code)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
response.headers["requested-url"] = str(response.request.url)
|
|
108
|
+
except RuntimeError:
|
|
109
|
+
# If the request URL is not available, we can ignore it.
|
|
110
|
+
pass
|
|
111
|
+
|
|
98
112
|
return exception_class(
|
|
99
113
|
status=response.status_code,
|
|
100
114
|
title=response.reason_phrase,
|
|
101
115
|
detail=response.text,
|
|
102
116
|
headers=response.headers,
|
|
117
|
+
request=response._request,
|
|
118
|
+
response=response,
|
|
103
119
|
)
|
|
104
120
|
|
|
105
121
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: shopware-api-client
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.118
|
|
4
4
|
Summary: An api client for the Shopware API
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Keywords: shopware,api,client
|
|
7
8
|
Author: GWS Gesellschaft für Warenwirtschafts-Systeme mbH
|
|
8
9
|
Author-email: ebusiness@gws.ms
|
|
@@ -12,10 +13,13 @@ Classifier: Operating System :: OS Independent
|
|
|
12
13
|
Classifier: Programming Language :: Python :: 3
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Provides-Extra: redis
|
|
15
18
|
Requires-Dist: httpx (>=0.26,<0.27)
|
|
16
19
|
Requires-Dist: httpx-auth (>=0.21,<0.22)
|
|
17
20
|
Requires-Dist: pydantic (>=2.6,<3.0)
|
|
18
21
|
Requires-Dist: pytest-random-order (>=1.1.1,<2.0.0)
|
|
22
|
+
Requires-Dist: redis (>5.0,<7.0) ; extra == "redis"
|
|
19
23
|
Project-URL: Bugtracker, https://github.com/GWS-mbH/shopware-api-client/issues
|
|
20
24
|
Project-URL: Changelog, https://github.com/GWS-mbH/shopware-api-client
|
|
21
25
|
Project-URL: Documentation, https://github.com/GWS-mbH/shopware-api-client/wiki
|
|
@@ -29,8 +33,13 @@ A Django-ORM like, Python 3.12, async Shopware 6 admin and store-front API clien
|
|
|
29
33
|
|
|
30
34
|
## Installation
|
|
31
35
|
|
|
36
|
+
```sh
|
|
32
37
|
pip install shopware-api-client
|
|
33
38
|
|
|
39
|
+
# If you want to use the redis cache
|
|
40
|
+
pip install shopware-api-client[redis]
|
|
41
|
+
```
|
|
42
|
+
|
|
34
43
|
## Usage
|
|
35
44
|
|
|
36
45
|
There are two kinds of clients provided by this library. The `client.AdminClient` for the Admin API and the
|
|
@@ -151,7 +160,7 @@ await client.ce_blog.all()
|
|
|
151
160
|
|
|
152
161
|
# Pydantic Model for the custom entity ce_blog
|
|
153
162
|
CeBlog = client.ce_blog.model_class
|
|
154
|
-
```
|
|
163
|
+
```
|
|
155
164
|
Since custom entities are completely dynamic no autocompletion in IDE is available. However there are some pydantic validations added for the field-types of the custom entity. Relations are currently not supported, but everything else should work as expected.
|
|
156
165
|
|
|
157
166
|
### client.StoreClient
|
|
@@ -175,6 +184,39 @@ config = StoreConfig(url=SHOP_URL, access_key=STORE_API_ACCESS_KEY, context_toke
|
|
|
175
184
|
|
|
176
185
|
This config can be used with the `StoreClient`, which works exactly like the `AdminClient`.
|
|
177
186
|
|
|
187
|
+
### Redis Caching for Rate Limits
|
|
188
|
+
|
|
189
|
+
Both the AdminClient and the StoreClient use a built-in rate limiter. Shopware's rate limits differ based on the endpoints, both for the [SaaS-](https://docs.shopware.com/en/en/shopware-6-en/saas/rate-limits) and the [on-premise-solution](https://developer.shopware.com/docs/guides/hosting/infrastructure/rate-limiter.html).
|
|
190
|
+
|
|
191
|
+
To be able to respect the rate limit when sending requests from multiple clients, it is possible to use redis as a cache-backend for route-based rate-limit data. If redis is not used, each Client independently keeps track of the rate limit. Please note that the non-Redis cache is not thread-safe.
|
|
192
|
+
|
|
193
|
+
To use redis, simply hand over a redis-client to the client config:
|
|
194
|
+
```py
|
|
195
|
+
import redis
|
|
196
|
+
from shopware_api_client.config import AdminConfig, StoreConfig
|
|
197
|
+
from shopware_api_client.client import AdminClient, StoreClient
|
|
198
|
+
|
|
199
|
+
redis_client = redis.Redis()
|
|
200
|
+
|
|
201
|
+
admin_config = AdminConfig(
|
|
202
|
+
url='',
|
|
203
|
+
client_id='...',
|
|
204
|
+
client_secre='...',
|
|
205
|
+
redis_client=redis_client,
|
|
206
|
+
)
|
|
207
|
+
admin_client = AdminClient(config=config) # <- This client uses the redis client now
|
|
208
|
+
|
|
209
|
+
store_config = StoreConfig(
|
|
210
|
+
url='',
|
|
211
|
+
access_key='',
|
|
212
|
+
context_token=''
|
|
213
|
+
redis_client=redis_client,
|
|
214
|
+
)
|
|
215
|
+
store_client = StoreClient(config=config) # <- Works for store client as well (Only do this in safe environments)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
__Note:__ Shopware currently enforces rate limits on a per–public‑IP basis. As a result, you should only share Redis‑backed rate‑limit caching among clients that originate from the same public IP address.
|
|
219
|
+
|
|
178
220
|
## EndpointBase
|
|
179
221
|
The `base.EndpointBase` class should be used for creating new Endpoints. It provides some usefull functions to call
|
|
180
222
|
the Shopware-API.
|
|
@@ -315,6 +357,12 @@ We have two classes `Base` and `Relations`. This way we can [reuse the Base-Mode
|
|
|
315
357
|
|
|
316
358
|
## Development
|
|
317
359
|
|
|
360
|
+
### Testing
|
|
361
|
+
|
|
362
|
+
You can use `poetry build` and `poetry run pip install -e .` to install the current src.
|
|
363
|
+
|
|
364
|
+
Then run `poetry run pytest .` to execute the tests.
|
|
365
|
+
|
|
318
366
|
### Model Creation
|
|
319
367
|
|
|
320
368
|
Shopware provides API-definitions for their whole API. You can download it from `<shopurl>/api/_info/openapi3.json`
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
shopware_api_client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
shopware_api_client/base.py,sha256=
|
|
3
|
-
shopware_api_client/
|
|
4
|
-
shopware_api_client/
|
|
2
|
+
shopware_api_client/base.py,sha256=pyGavZ4Z22mbn6WbZgaLOSVeu4ZwFOFdCZQa2O4e-Ms,30409
|
|
3
|
+
shopware_api_client/cache.py,sha256=JtojK7yDfAM937rDoqJj_FpNFf3zsfueTSyUX_Gsk4w,4997
|
|
4
|
+
shopware_api_client/client.py,sha256=vkTZ8WLIu0Q3_73gXvidkTSpMJlWsJUjFyhDY2f2ISM,7583
|
|
5
|
+
shopware_api_client/config.py,sha256=6IzpkuoYsh0d5c0BR453n1ijQwxB74HDYx1L6dmlJwQ,1623
|
|
5
6
|
shopware_api_client/endpoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
shopware_api_client/endpoints/admin/__init__.py,sha256
|
|
7
|
+
shopware_api_client/endpoints/admin/__init__.py,sha256=-gRCCoyflh96K5XbBzVNSnhD2GKeNuSSpKQoJVeY-xQ,17744
|
|
7
8
|
shopware_api_client/endpoints/admin/commercial/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
9
|
shopware_api_client/endpoints/admin/commercial/b2b_components_role.py,sha256=gnqCh5s4j5ReBgBMrTrSBlUzJJ7nSiaNtlEXiIuHc30,977
|
|
10
|
+
shopware_api_client/endpoints/admin/commercial/b2b_components_shopping_list.py,sha256=yOP9CEFQ7ZctbFUgsnXiwAXKu_Vt_WXE8uX_qzxHR18,1422
|
|
11
|
+
shopware_api_client/endpoints/admin/commercial/b2b_components_shopping_list_line_item.py,sha256=tgy2Z4QX2zB-YHgjOOCzvbNoa5hP0S4YEhnisk1TnTE,1246
|
|
9
12
|
shopware_api_client/endpoints/admin/commercial/b2b_employee.py,sha256=dzE9h6UFPI94ZXuENJvpQb6tBY8wk7-3WXu7QW_CT2I,1166
|
|
10
13
|
shopware_api_client/endpoints/admin/commercial/dynamic_access.py,sha256=W-3WFyxiA_5ph3cbDaUeHIaiqSMiRNPJAAgbbQQIVUU,805
|
|
11
14
|
shopware_api_client/endpoints/admin/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -40,11 +43,11 @@ shopware_api_client/endpoints/admin/core/landing_page.py,sha256=ohqquIPIECgHh1c5
|
|
|
40
43
|
shopware_api_client/endpoints/admin/core/language.py,sha256=ZikBOB8ekS2zepjNuD55JiZ8UEstxNJDGZVyvMB88VY,1749
|
|
41
44
|
shopware_api_client/endpoints/admin/core/locale.py,sha256=bXSHNF16VJJgwtge34vA_jOK23JmKxFarOeIS30kpQM,712
|
|
42
45
|
shopware_api_client/endpoints/admin/core/main_category.py,sha256=KHk7MIrhh56A8IPreRnKvbl5za3710MoWYdmsZRaVsI,954
|
|
43
|
-
shopware_api_client/endpoints/admin/core/media.py,sha256=
|
|
46
|
+
shopware_api_client/endpoints/admin/core/media.py,sha256=dBQ1WHocbew4YOjUV0JdPqfa7H_8dmJZMfkSt5jlQNc,4841
|
|
44
47
|
shopware_api_client/endpoints/admin/core/media_default_folder.py,sha256=WBVoJhKF1j20VhBdZs5fsp8FN3q_yS46TRXCsyOejpA,695
|
|
45
48
|
shopware_api_client/endpoints/admin/core/media_folder.py,sha256=t18uurbPXtOZB7RR-pGQnF_IJ7UP0VAOABk0mHRypdg,1333
|
|
46
49
|
shopware_api_client/endpoints/admin/core/media_folder_configuration.py,sha256=DabEuDxDMRcbYpLun--LVHxX6cg8NH0FP1xv4-t_a3Y,1114
|
|
47
|
-
shopware_api_client/endpoints/admin/core/media_thumbnail.py,sha256=
|
|
50
|
+
shopware_api_client/endpoints/admin/core/media_thumbnail.py,sha256=7X1nLKk9hnXwFDQvBF0CM_3JxTWFUM6hjs3eOXawHB4,1157
|
|
48
51
|
shopware_api_client/endpoints/admin/core/media_thumbnail_size.py,sha256=Uw06yOFlhb-_H3VIxGiWgn--OmkysMancuOPu9A-NjU,800
|
|
49
52
|
shopware_api_client/endpoints/admin/core/order.py,sha256=ly5EzLYWUyufixT7Uv_lZrhNtOxvNHJmMSgh9a_P7l4,3040
|
|
50
53
|
shopware_api_client/endpoints/admin/core/order_address.py,sha256=BQXFwzKypu1xZWTed-sZxrvs2T9CrSPuYapynjAMO-A,1625
|
|
@@ -105,10 +108,10 @@ shopware_api_client/endpoints/store/__init__.py,sha256=LE_GL4jYVGrRdp6M1cPURI-UJ
|
|
|
105
108
|
shopware_api_client/endpoints/store/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
106
109
|
shopware_api_client/endpoints/store/core/address.py,sha256=Tm7ri-i0zhyt4LVEHaQy3xEvjapCCPRsYAp5lOCDOew,2774
|
|
107
110
|
shopware_api_client/endpoints/store/core/cart.py,sha256=34eNwuv7H9WZUtJGf4TkTGHiGynDT6xeAt0gIl7V4KI,2584
|
|
108
|
-
shopware_api_client/exceptions.py,sha256=
|
|
111
|
+
shopware_api_client/exceptions.py,sha256=sxQ1O_XSzmuEnuJkztnZ-zu-NCcOQ3kMWn_8M4j5YxA,4989
|
|
109
112
|
shopware_api_client/logging.py,sha256=4QSTK1vcdBew4shvLG-fm-xDOlddhOZeyb5T9Og0fSA,251
|
|
110
113
|
shopware_api_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
111
|
-
shopware_api_client-1.0.
|
|
112
|
-
shopware_api_client-1.0.
|
|
113
|
-
shopware_api_client-1.0.
|
|
114
|
-
shopware_api_client-1.0.
|
|
114
|
+
shopware_api_client-1.0.118.dist-info/METADATA,sha256=ixCVUBkvzftJkGWBOvA4hL4Q0hhj_G6NBE-BZbNxPkA,24450
|
|
115
|
+
shopware_api_client-1.0.118.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
116
|
+
shopware_api_client-1.0.118.dist-info/licenses/LICENSE,sha256=qTihFhbGE2ZJJ7Byc9hnEYBY33yDK2Jw87SpAm0IKUs,1107
|
|
117
|
+
shopware_api_client-1.0.118.dist-info/RECORD,,
|
{shopware_api_client-1.0.100.dist-info → shopware_api_client-1.0.118.dist-info/licenses}/LICENSE
RENAMED
|
File without changes
|