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.
@@ -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__(self, url: str):
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._get_client()
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._get_client().aclose()
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
- def _get_client(self) -> httpx.AsyncClient:
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": "application/json", "Accept": "application/json"}
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._get_client()
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._get_client()
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._get_client()
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", [SWAPITooManyRequests]))
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 == retries:
233
+ if retry_count >= retries:
130
234
  raise SWAPIException(f"HTTP client exception ({exc.__class__.__name__}). Details: {str(exc)}")
131
- await asyncio.sleep(2**retry_count)
132
- retry_count += 1
235
+ retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
133
236
  continue
134
237
 
135
- if response.status_code >= 400:
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
- raise ValueError("`errors` attribute in json not a list/tuple!")
141
-
142
- error: SWAPIError | SWAPIErrorList = SWAPIError.from_errors(errors)
143
- except (json.JSONDecodeError, ValueError):
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 == retries:
297
+ if retry_count >= retries:
160
298
  raise error
161
299
 
162
- logger.debug(f"Try failed, retrying in {2 ** retry_count} seconds.")
163
- await asyncio.sleep(2**retry_count)
164
- retry_count += 1
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._get_client().aclose()
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
- endpoint: EndpointClass = getattr(self._get_client(), self._identifier).__class__(self._get_client()) # type: ignore
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 _prase_data_single(self, reponse_dict: dict[str, Any]) -> dict[str, Any]:
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._prase_data_single(result.json())
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._prase_data_single(result.json())
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._prase_data_single(result.json())
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
- self._includes.update({self._serialize_field_name(field): data for field, data in kwargs.items()})
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
@@ -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 _get_client(self) -> httpx.AsyncClient:
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
- result = response.json()
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 _get_client(self) -> httpx.AsyncClient:
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]},
@@ -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, exclude=True)
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.3
1
+ Metadata-Version: 2.4
2
2
  Name: shopware-api-client
3
- Version: 1.0.100
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=dq9o45_1jtrBlkNNnQssNv7H3_WOWsdqLRphuu32Riw,23528
3
- shopware_api_client/client.py,sha256=Ii8oRpCkCVzR6unRIanMxxOHHbqlFedaYEEyrlZwlkI,7456
4
- shopware_api_client/config.py,sha256=HStgfQcClpo_aqaTRDrqdTUjqSGPFkIMjrPwSruVnM8,1565
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=yfGXeIzGDoAIzVBp0zEGYMcWKoc-qrjMeot8HXOwfhA,17213
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=GJYSML5WGTH_7GQIoEJkc7V54LzrbmCIK0OEmk5Xncc,4855
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=VFpp1BPXb_qQ7iwxyMX2pjeE9RK8bvfnf2oUl5v7PG0,989
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=AELVvzdjH0RABF0WgqQ-DbEuZB1k-5V8L_NkKZLV6tk,4459
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.100.dist-info/LICENSE,sha256=qTihFhbGE2ZJJ7Byc9hnEYBY33yDK2Jw87SpAm0IKUs,1107
112
- shopware_api_client-1.0.100.dist-info/METADATA,sha256=bliK6_zoJTbegjL2cb-xVSQUWuGRaCC-9J7_X0FWgQA,22502
113
- shopware_api_client-1.0.100.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
114
- shopware_api_client-1.0.100.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.3
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any