shopware-api-client 1.0.101__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,8 +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
4
5
  from functools import cached_property
6
+ from math import ceil
7
+ from time import time
5
8
  from typing import (
9
+ TYPE_CHECKING,
6
10
  Any,
7
11
  AsyncGenerator,
8
12
  Callable,
@@ -29,6 +33,7 @@ from pydantic import (
29
33
  from pydantic.alias_generators import to_camel
30
34
  from pydantic.main import IncEx
31
35
 
36
+ from .cache import DictCache, RedisCache
32
37
  from .endpoints.base_fields import IdField
33
38
  from .exceptions import (
34
39
  SWAPIDataValidationError,
@@ -37,20 +42,41 @@ from .exceptions import (
37
42
  SWAPIException,
38
43
  SWAPIGatewayTimeout,
39
44
  SWAPIInternalServerError,
45
+ SWAPIRetryException,
40
46
  SWAPIServiceUnavailable,
41
- SWAPITooManyRequests,
42
47
  SWFilterException,
43
48
  SWNoClientProvided,
44
49
  )
45
50
  from .logging import logger
46
51
 
52
+ if TYPE_CHECKING:
53
+ from redis.asyncio import Redis
54
+
55
+ APPLICATION_JSON = "application/json"
56
+
47
57
  EndpointClass = TypeVar("EndpointClass", bound="EndpointBase[Any]")
48
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"
49
63
 
50
64
 
51
65
  class ConfigBase:
52
- 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:
53
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
+ )
54
80
 
55
81
 
56
82
  class ClientBase:
@@ -58,12 +84,15 @@ class ClientBase:
58
84
  raw: bool
59
85
  language_id: IdField | None = None
60
86
 
61
- def __init__(self, config: ConfigBase, raw: bool = False):
87
+ def __init__(self, config: ConfigBase, raw: bool = False) -> None:
62
88
  self.api_url = config.url
89
+ self.retry_after_threshold = config.retry_after_threshold
90
+ self.cache = config.cache
63
91
  self.raw = raw
64
92
 
65
93
  async def __aenter__(self) -> "Self":
66
- self.http_client
94
+ client = self.http_client
95
+ assert isinstance(client, httpx.AsyncClient), "http_client must be an instance of httpx.AsyncClient"
67
96
  return self
68
97
 
69
98
  async def __aexit__(self, *args: Any) -> None:
@@ -86,15 +115,13 @@ class ClientBase:
86
115
 
87
116
  @cached_property
88
117
  def http_client(self) -> httpx.AsyncClient:
89
- return self._get_client()
118
+ return self._get_http_client()
90
119
 
91
- def _get_client(self) -> httpx.AsyncClient:
92
- # FIXME: rename _get_client -> _get_http_client to avoid confusion with ApiModelBase._get_client
93
- # (fix middleware usage of private method usage first)
120
+ def _get_http_client(self) -> httpx.AsyncClient:
94
121
  raise NotImplementedError()
95
122
 
96
123
  def _get_headers(self) -> dict[str, str]:
97
- headers = {"Content-Type": "application/json", "Accept": "application/json"}
124
+ headers = {"Content-Type": APPLICATION_JSON, "Accept": APPLICATION_JSON}
98
125
 
99
126
  if self.language_id is not None:
100
127
  headers["sw-language-id"] = str(self.language_id)
@@ -111,10 +138,46 @@ class ClientBase:
111
138
  client = self.http_client
112
139
  client.timeout = timeout # type: ignore
113
140
 
114
- async def retry_sleep(self, retry_wait_base: int, retry_count: int) -> None:
115
- retry_sleep = retry_wait_base**retry_count
116
- logger.debug(f"Try failed, retrying in {retry_sleep} seconds.")
117
- await asyncio.sleep(retry_sleep)
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))
118
181
 
119
182
  async def _make_request(self, method: str, relative_url: str, **kwargs: Any) -> httpx.Response:
120
183
  if relative_url.startswith("http://") or relative_url.startswith("https://"):
@@ -126,35 +189,96 @@ class ClientBase:
126
189
  headers = self._get_headers()
127
190
  headers.update(kwargs.pop("headers", {}))
128
191
 
129
- retry_wait_base = int(kwargs.pop("retriy_wait_base", 2))
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))
130
194
  retries = int(kwargs.pop("retries", 0))
131
195
  retry_errors = tuple(
132
196
  kwargs.pop("retry_errors", [SWAPIInternalServerError, SWAPIServiceUnavailable, SWAPIGatewayTimeout])
133
197
  )
134
- no_retry_errors = tuple(kwargs.pop("no_retry_errors", [SWAPITooManyRequests]))
198
+ no_retry_errors = tuple(kwargs.pop("no_retry_errors", []))
135
199
 
136
200
  kwargs.setdefault("follow_redirects", True)
137
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
+
138
212
  retry_count = 0
139
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
+
140
230
  try:
141
231
  response = await client.request(method, url, headers=headers, **kwargs)
142
232
  except httpx.RequestError as exc:
143
233
  if retry_count >= retries:
144
234
  raise SWAPIException(f"HTTP client exception ({exc.__class__.__name__}). Details: {str(exc)}")
145
- await asyncio.sleep(2**retry_count)
146
- retry_count += 1
235
+ retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
147
236
  continue
148
237
 
149
- 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
150
274
  try:
151
275
  errors: list = response.json().get("errors")
152
276
  # ensure `errors` attribute is a list/tuple, fallback to from_response if not
153
277
  if not isinstance(errors, (list, tuple)):
154
278
  raise ValueError("`errors` attribute in json not a list/tuple!")
155
279
 
156
- error: SWAPIError | SWAPIErrorList = SWAPIError.from_errors(errors)
157
- except (json.JSONDecodeError, ValueError):
280
+ error: SWAPIError | SWAPIErrorList = SWAPIError.from_errors(errors, response) # type: ignore
281
+ except ValueError:
158
282
  error: SWAPIError | SWAPIErrorList = SWAPIError.from_response(response) # type: ignore
159
283
 
160
284
  if isinstance(error, SWAPIErrorList) and len(error.errors) == 1:
@@ -170,15 +294,15 @@ class ClientBase:
170
294
  elif isinstance(error, no_retry_errors) or not isinstance(error, retry_errors):
171
295
  raise error
172
296
 
173
- if retry_count == retries:
297
+ if retry_count >= retries:
174
298
  raise error
175
299
 
176
- await self.retry_sleep(retry_wait_base, retry_count)
177
- retry_count += 1
178
- else:
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):
179
302
  # guard against "200 okay" responses with malformed json
180
303
  try:
181
304
  setattr(response, "json_cached", response.json())
305
+ return response
182
306
  except json.JSONDecodeError:
183
307
  # retries exhausted?
184
308
  if retry_count >= retries:
@@ -190,11 +314,8 @@ class ClientBase:
190
314
  )
191
315
  raise exception
192
316
 
193
- # schedule retry
194
- await self.retry_sleep(retry_wait_base, retry_count)
195
- retry_count += 1
196
- continue
197
-
317
+ retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
318
+ else:
198
319
  return response
199
320
 
200
321
  async def get(self, relative_url: str, **kwargs: Any) -> httpx.Response:
@@ -402,6 +523,9 @@ class EndpointBase(Generic[ModelClass]):
402
523
  if not self.model_class.__pydantic_complete__:
403
524
  self.model_class.model_rebuild()
404
525
 
526
+ if name == getattr(self.model_class, "_identifier").get_default():
527
+ return name
528
+
405
529
  field = self.model_class.model_fields[name]
406
530
 
407
531
  if get_origin(field.annotation) in [ForeignRelation, ManyRelation]:
@@ -473,7 +597,7 @@ class EndpointBase(Generic[ModelClass]):
473
597
 
474
598
  return data
475
599
 
476
- 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]:
477
601
  return self._parse_data(reponse_dict)[0]
478
602
 
479
603
  async def all(self) -> list[ModelClass] | list[dict[str, Any]]:
@@ -495,7 +619,7 @@ class EndpointBase(Generic[ModelClass]):
495
619
 
496
620
  async def get(self, pk: str) -> ModelClass | dict[str, Any]:
497
621
  result = await self.client.get(f"{self.path}/{pk}")
498
- result_data: dict[str, Any] = self._prase_data_single(result.json())
622
+ result_data: dict[str, Any] = self._parse_data_single(result.json())
499
623
 
500
624
  if self.raw:
501
625
  return result_data
@@ -515,7 +639,7 @@ class EndpointBase(Generic[ModelClass]):
515
639
  if result.status_code == 204:
516
640
  return None
517
641
 
518
- result_data: dict[str, Any] = self._prase_data_single(result.json())
642
+ result_data: dict[str, Any] = self._parse_data_single(result.json())
519
643
 
520
644
  if self.raw:
521
645
  return result_data
@@ -545,7 +669,7 @@ class EndpointBase(Generic[ModelClass]):
545
669
  if result.status_code == 204:
546
670
  return None
547
671
 
548
- result_data: dict[str, Any] = self._prase_data_single(result.json())
672
+ result_data: dict[str, Any] = self._parse_data_single(result.json())
549
673
 
550
674
  if self.raw:
551
675
  return result_data
@@ -575,7 +699,9 @@ class EndpointBase(Generic[ModelClass]):
575
699
  return self
576
700
 
577
701
  def only(self, **kwargs: list[str]) -> Self:
578
- 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
+
579
705
  return self
580
706
 
581
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]}
@@ -180,7 +180,7 @@ class StoreClient(ClientBase, StoreEndpoints):
180
180
  self._client: httpx.AsyncClient | None = None
181
181
  self.init_endpoints(self)
182
182
 
183
- def _get_client(self) -> httpx.AsyncClient:
183
+ def _get_http_client(self) -> httpx.AsyncClient:
184
184
  if self._client is None:
185
185
  self._client = httpx.AsyncClient(
186
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.101
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.
@@ -1,11 +1,14 @@
1
1
  shopware_api_client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- shopware_api_client/base.py,sha256=x4VIfvpDbyIaqCY60LEVCIIRm_YQZ8CVR0EIKtGCwZg,24977
3
- shopware_api_client/client.py,sha256=9b4Hvj6izHEMplus6--S2no0Sts61vg25HNIPEhJ7JQ,7573
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.101.dist-info/LICENSE,sha256=qTihFhbGE2ZJJ7Byc9hnEYBY33yDK2Jw87SpAm0IKUs,1107
112
- shopware_api_client-1.0.101.dist-info/METADATA,sha256=UwnWQIm54nwFGcGafokW5eaIX8VrAQCGqxpOztwyvC8,22659
113
- shopware_api_client-1.0.101.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
114
- shopware_api_client-1.0.101.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