shopware-api-client 1.0.107__tar.gz → 1.0.109__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shopware-api-client might be problematic. Click here for more details.

Files changed (115) hide show
  1. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/PKG-INFO +42 -2
  2. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/README.md +39 -1
  3. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/pyproject.toml +12 -1
  4. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/base.py +133 -21
  5. shopware_api_client-1.0.109/src/shopware_api_client/cache.py +162 -0
  6. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/config.py +4 -3
  7. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/exceptions.py +11 -1
  8. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/LICENSE +0 -0
  9. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/__init__.py +0 -0
  10. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/client.py +0 -0
  11. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/__init__.py +0 -0
  12. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/__init__.py +0 -0
  13. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/commercial/__init__.py +0 -0
  14. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/commercial/b2b_components_role.py +0 -0
  15. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/commercial/b2b_employee.py +0 -0
  16. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/commercial/dynamic_access.py +0 -0
  17. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/__init__.py +0 -0
  18. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/acl_role.py +0 -0
  19. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/api_info.py +0 -0
  20. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/app.py +0 -0
  21. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/app_script_condition.py +0 -0
  22. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/category.py +0 -0
  23. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/cms_block.py +0 -0
  24. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/cms_page.py +0 -0
  25. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/cms_section.py +0 -0
  26. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/cms_slot.py +0 -0
  27. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/country.py +0 -0
  28. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/country_state.py +0 -0
  29. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/currency.py +0 -0
  30. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/currency_country_rounding.py +0 -0
  31. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/custom_entity.py +0 -0
  32. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/custom_field.py +0 -0
  33. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/customer.py +0 -0
  34. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/customer_address.py +0 -0
  35. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/customer_group.py +0 -0
  36. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/customer_recovery.py +0 -0
  37. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/customer_wishlist.py +0 -0
  38. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/customer_wishlist_product.py +0 -0
  39. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/delivery_time.py +0 -0
  40. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/document.py +0 -0
  41. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/document_base_config.py +0 -0
  42. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/document_base_config_sales_channel.py +0 -0
  43. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/document_type.py +0 -0
  44. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/integration.py +0 -0
  45. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/landing_page.py +0 -0
  46. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/language.py +0 -0
  47. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/locale.py +0 -0
  48. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/main_category.py +0 -0
  49. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/media.py +0 -0
  50. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/media_default_folder.py +0 -0
  51. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/media_folder.py +0 -0
  52. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/media_folder_configuration.py +0 -0
  53. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/media_thumbnail.py +0 -0
  54. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/media_thumbnail_size.py +0 -0
  55. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order.py +0 -0
  56. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_address.py +0 -0
  57. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_customer.py +0 -0
  58. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_delivery.py +0 -0
  59. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_delivery_position.py +0 -0
  60. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_line_item.py +0 -0
  61. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_line_item_download.py +0 -0
  62. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_transaction.py +0 -0
  63. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_transaction_capture.py +0 -0
  64. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_transaction_capture_refund.py +0 -0
  65. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/order_transaction_capture_refund_position.py +0 -0
  66. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/payment_method.py +0 -0
  67. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product.py +0 -0
  68. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_configurator_setting.py +0 -0
  69. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_cross_selling.py +0 -0
  70. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_cross_selling_assigned_products.py +0 -0
  71. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_download.py +0 -0
  72. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_export.py +0 -0
  73. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_feature_set.py +0 -0
  74. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_manufacturer.py +0 -0
  75. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_media.py +0 -0
  76. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_price.py +0 -0
  77. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_review.py +0 -0
  78. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_search_keyword.py +0 -0
  79. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_stream.py +0 -0
  80. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_visibility.py +0 -0
  81. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/product_warehouse.py +0 -0
  82. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/promotion.py +0 -0
  83. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/promotion_discount.py +0 -0
  84. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/promotion_discount_prices.py +0 -0
  85. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/property_group.py +0 -0
  86. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/property_group_option.py +0 -0
  87. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/rule.py +0 -0
  88. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/rule_condition.py +0 -0
  89. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/sales_channel.py +0 -0
  90. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/sales_channel_domain.py +0 -0
  91. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/salutation.py +0 -0
  92. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/seo_url.py +0 -0
  93. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/shipping_method.py +0 -0
  94. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/state_machine.py +0 -0
  95. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/state_machine_history.py +0 -0
  96. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/state_machine_state.py +0 -0
  97. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/state_machine_transition.py +0 -0
  98. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/system_config.py +0 -0
  99. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/tag.py +0 -0
  100. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/tax.py +0 -0
  101. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/tax_rule.py +0 -0
  102. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/tax_rule_type.py +0 -0
  103. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/unit.py +0 -0
  104. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/user.py +0 -0
  105. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/warehouse.py +0 -0
  106. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/warehouse_group.py +0 -0
  107. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/admin/core/warehouse_group_warehouse.py +0 -0
  108. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/base_fields.py +0 -0
  109. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/relations.py +0 -0
  110. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/store/__init__.py +0 -0
  111. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/store/core/__init__.py +0 -0
  112. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/store/core/address.py +0 -0
  113. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/endpoints/store/core/cart.py +0 -0
  114. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/logging.py +0 -0
  115. {shopware_api_client-1.0.107 → shopware_api_client-1.0.109}/src/shopware_api_client/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: shopware-api-client
3
- Version: 1.0.107
3
+ Version: 1.0.109
4
4
  Summary: An api client for the Shopware API
5
5
  License: MIT
6
6
  Keywords: shopware,api,client
@@ -12,10 +12,12 @@ Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
+ Provides-Extra: redis
15
16
  Requires-Dist: httpx (>=0.26,<0.27)
16
17
  Requires-Dist: httpx-auth (>=0.21,<0.22)
17
18
  Requires-Dist: pydantic (>=2.6,<3.0)
18
19
  Requires-Dist: pytest-random-order (>=1.1.1,<2.0.0)
20
+ Requires-Dist: redis (>5.0,<7.0) ; extra == "redis"
19
21
  Project-URL: Bugtracker, https://github.com/GWS-mbH/shopware-api-client/issues
20
22
  Project-URL: Changelog, https://github.com/GWS-mbH/shopware-api-client
21
23
  Project-URL: Documentation, https://github.com/GWS-mbH/shopware-api-client/wiki
@@ -29,8 +31,13 @@ A Django-ORM like, Python 3.12, async Shopware 6 admin and store-front API clien
29
31
 
30
32
  ## Installation
31
33
 
34
+ ```sh
32
35
  pip install shopware-api-client
33
36
 
37
+ # If you want to use the redis cache
38
+ pip install shopware-api-client[redis]
39
+ ```
40
+
34
41
  ## Usage
35
42
 
36
43
  There are two kinds of clients provided by this library. The `client.AdminClient` for the Admin API and the
@@ -151,7 +158,7 @@ await client.ce_blog.all()
151
158
 
152
159
  # Pydantic Model for the custom entity ce_blog
153
160
  CeBlog = client.ce_blog.model_class
154
- ```
161
+ ```
155
162
  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
163
 
157
164
  ### client.StoreClient
@@ -175,6 +182,39 @@ config = StoreConfig(url=SHOP_URL, access_key=STORE_API_ACCESS_KEY, context_toke
175
182
 
176
183
  This config can be used with the `StoreClient`, which works exactly like the `AdminClient`.
177
184
 
185
+ ### Redis Caching for Rate Limits
186
+
187
+ 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).
188
+
189
+ 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.
190
+
191
+ To use redis, simply hand over a redis-client to the client config:
192
+ ```py
193
+ import redis
194
+ from shopware_api_client.config import AdminConfig, StoreConfig
195
+ from shopware_api_client.client import AdminClient, StoreClient
196
+
197
+ redis_client = redis.Redis()
198
+
199
+ admin_config = AdminConfig(
200
+ url='',
201
+ client_id='...',
202
+ client_secre='...',
203
+ redis_client=redis_client,
204
+ )
205
+ admin_client = AdminClient(config=config) # <- This client uses the redis client now
206
+
207
+ store_config = StoreConfig(
208
+ url='',
209
+ access_key='',
210
+ context_token=''
211
+ redis_client=redis_client,
212
+ )
213
+ store_client = StoreClient(config=config) # <- Works for store client as well (Only do this in safe environments)
214
+ ```
215
+
216
+ __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.
217
+
178
218
  ## EndpointBase
179
219
  The `base.EndpointBase` class should be used for creating new Endpoints. It provides some usefull functions to call
180
220
  the Shopware-API.
@@ -4,8 +4,13 @@ A Django-ORM like, Python 3.12, async Shopware 6 admin and store-front API clien
4
4
 
5
5
  ## Installation
6
6
 
7
+ ```sh
7
8
  pip install shopware-api-client
8
9
 
10
+ # If you want to use the redis cache
11
+ pip install shopware-api-client[redis]
12
+ ```
13
+
9
14
  ## Usage
10
15
 
11
16
  There are two kinds of clients provided by this library. The `client.AdminClient` for the Admin API and the
@@ -126,7 +131,7 @@ await client.ce_blog.all()
126
131
 
127
132
  # Pydantic Model for the custom entity ce_blog
128
133
  CeBlog = client.ce_blog.model_class
129
- ```
134
+ ```
130
135
  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.
131
136
 
132
137
  ### client.StoreClient
@@ -150,6 +155,39 @@ config = StoreConfig(url=SHOP_URL, access_key=STORE_API_ACCESS_KEY, context_toke
150
155
 
151
156
  This config can be used with the `StoreClient`, which works exactly like the `AdminClient`.
152
157
 
158
+ ### Redis Caching for Rate Limits
159
+
160
+ 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).
161
+
162
+ 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.
163
+
164
+ To use redis, simply hand over a redis-client to the client config:
165
+ ```py
166
+ import redis
167
+ from shopware_api_client.config import AdminConfig, StoreConfig
168
+ from shopware_api_client.client import AdminClient, StoreClient
169
+
170
+ redis_client = redis.Redis()
171
+
172
+ admin_config = AdminConfig(
173
+ url='',
174
+ client_id='...',
175
+ client_secre='...',
176
+ redis_client=redis_client,
177
+ )
178
+ admin_client = AdminClient(config=config) # <- This client uses the redis client now
179
+
180
+ store_config = StoreConfig(
181
+ url='',
182
+ access_key='',
183
+ context_token=''
184
+ redis_client=redis_client,
185
+ )
186
+ store_client = StoreClient(config=config) # <- Works for store client as well (Only do this in safe environments)
187
+ ```
188
+
189
+ __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.
190
+
153
191
  ## EndpointBase
154
192
  The `base.EndpointBase` class should be used for creating new Endpoints. It provides some usefull functions to call
155
193
  the Shopware-API.
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
  name = "shopware-api-client"
3
3
  description = " An api client for the Shopware API"
4
- version = "1.0.107"
4
+ version = "1.0.109"
5
5
  license = "MIT"
6
6
  authors = ["GWS Gesellschaft für Warenwirtschafts-Systeme mbH <ebusiness@gws.ms>"]
7
7
  readme = "README.md"
@@ -25,6 +25,10 @@ httpx = "^0.26"
25
25
  httpx-auth = "^0.21"
26
26
  pydantic = "^2.6"
27
27
  pytest-random-order = "^1.1.1"
28
+ redis = {version = ">5.0,<7.0", optional = true}
29
+
30
+ [tool.poetry.extras]
31
+ redis = ["redis"]
28
32
 
29
33
  [tool.poetry.group.dev.dependencies]
30
34
  mypy = "^1.8.0"
@@ -35,6 +39,8 @@ ruff = ">=0.5.0,<0.10"
35
39
  pytest-asyncio = ">=0.23.3,<0.26.0"
36
40
  pytest-recording = "^0.13.1"
37
41
  pytest-mock = "^3.14.0"
42
+ redis = ">5.0,<7.0"
43
+ fakeredis = {extras = ["lua"], version = "^2.30.1"}
38
44
 
39
45
  [build-system]
40
46
  requires = ["poetry-core"]
@@ -47,6 +53,11 @@ warn_unused_configs = true
47
53
  disallow_untyped_defs = true
48
54
  strict_optional = true
49
55
 
56
+ [[tool.mypy.overrides]]
57
+ # Redis stubs don't exist for redis >5.0
58
+ module = "redis.asyncio"
59
+ ignore_missing_imports = true
60
+
50
61
  [tool.pytest.ini_options]
51
62
  python_files = ["test_*.py", "*_test.py"]
52
63
  filterwarnings = ["ignore::DeprecationWarning"]
@@ -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,22 +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
+
47
55
  APPLICATION_JSON = "application/json"
48
56
 
49
57
  EndpointClass = TypeVar("EndpointClass", bound="EndpointBase[Any]")
50
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"
51
63
 
52
64
 
53
65
  class ConfigBase:
54
- 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:
55
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
+ )
56
80
 
57
81
 
58
82
  class ClientBase:
@@ -60,8 +84,10 @@ class ClientBase:
60
84
  raw: bool
61
85
  language_id: IdField | None = None
62
86
 
63
- def __init__(self, config: ConfigBase, raw: bool = False):
87
+ def __init__(self, config: ConfigBase, raw: bool = False) -> None:
64
88
  self.api_url = config.url
89
+ self.retry_after_threshold = config.retry_after_threshold
90
+ self.cache = config.cache
65
91
  self.raw = raw
66
92
 
67
93
  async def __aenter__(self) -> "Self":
@@ -112,10 +138,46 @@ class ClientBase:
112
138
  client = self.http_client
113
139
  client.timeout = timeout # type: ignore
114
140
 
115
- async def retry_sleep(self, retry_wait_base: int, retry_count: int) -> None:
116
- retry_sleep = retry_wait_base**retry_count
117
- logger.debug(f"Try failed, retrying in {retry_sleep} seconds.")
118
- 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 0
171
+
172
+ if retry_header.isdigit():
173
+ return 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(0, ceil(adjusted_time))
119
181
 
120
182
  async def _make_request(self, method: str, relative_url: str, **kwargs: Any) -> httpx.Response:
121
183
  if relative_url.startswith("http://") or relative_url.startswith("https://"):
@@ -127,29 +189,82 @@ class ClientBase:
127
189
  headers = self._get_headers()
128
190
  headers.update(kwargs.pop("headers", {}))
129
191
 
192
+ retry_after_threshold = int(kwargs.pop("retry_after_threshold", self.retry_after_threshold))
130
193
  retry_wait_base = int(kwargs.pop("retry_wait_base", 2))
131
194
  retries = int(kwargs.pop("retries", 0))
132
195
  retry_errors = tuple(
133
196
  kwargs.pop("retry_errors", [SWAPIInternalServerError, SWAPIServiceUnavailable, SWAPIGatewayTimeout])
134
197
  )
135
- no_retry_errors = tuple(kwargs.pop("no_retry_errors", [SWAPITooManyRequests]))
198
+ no_retry_errors = tuple(kwargs.pop("no_retry_errors", []))
136
199
 
137
200
  kwargs.setdefault("follow_redirects", True)
138
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
+
139
212
  retry_count = 0
140
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
+
141
230
  try:
142
231
  response = await client.request(method, url, headers=headers, **kwargs)
143
232
  except httpx.RequestError as exc:
144
233
  if retry_count >= retries:
145
234
  raise SWAPIException(f"HTTP client exception ({exc.__class__.__name__}). Details: {str(exc)}")
146
- await asyncio.sleep(2**retry_count)
147
- retry_count += 1
235
+ retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
148
236
  continue
237
+
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
+ await asyncio.gather(
245
+ self.cache.set(x_retry_limit_cache_key, x_retry_limit),
246
+ self.cache.set(x_retry_reset_cache_key, int(time()) + wait_time, wait_time),
247
+ self.cache.set(x_retry_remaining_cache_key, remaining_requests),
248
+ )
249
+
250
+ if got_lock:
251
+ await self.cache.delete(x_retry_lock_cache_key)
252
+ got_lock = False
253
+
149
254
  if response.status_code == 429:
150
- # directly raise 429
151
- error: SWAPIError = SWAPIError.from_response(response)
152
- raise error
255
+ retry_wait_time = self.parse_retry_after(response.headers)
256
+ if retry_wait_time > retry_after_threshold:
257
+ error = SWAPIError.from_response(response)
258
+ raise SWAPIRetryException(
259
+ f"Retry threshold exceeded for endpoint {url!r}. Threshold: {retry_after_threshold}s, Retry-After: {retry_wait_time}s"
260
+ ) from error
261
+
262
+ # If 429 is thrown, Retry-After == X-Rate-Limit-Reset
263
+ await asyncio.gather(
264
+ self.cache.set(x_retry_reset_cache_key, int(time()) + retry_wait_time, retry_wait_time),
265
+ asyncio.sleep(retry_wait_time),
266
+ )
267
+
153
268
  elif response.status_code >= 400:
154
269
  # retry other failure codes
155
270
  try:
@@ -159,7 +274,7 @@ class ClientBase:
159
274
  raise ValueError("`errors` attribute in json not a list/tuple!")
160
275
 
161
276
  error: SWAPIError | SWAPIErrorList = SWAPIError.from_errors(errors) # type: ignore
162
- except (json.JSONDecodeError, ValueError):
277
+ except ValueError:
163
278
  error: SWAPIError | SWAPIErrorList = SWAPIError.from_response(response) # type: ignore
164
279
 
165
280
  if isinstance(error, SWAPIErrorList) and len(error.errors) == 1:
@@ -175,11 +290,10 @@ class ClientBase:
175
290
  elif isinstance(error, no_retry_errors) or not isinstance(error, retry_errors):
176
291
  raise error
177
292
 
178
- if retry_count == retries:
293
+ if retry_count >= retries:
179
294
  raise error
180
295
 
181
- await self.retry_sleep(retry_wait_base, retry_count)
182
- retry_count += 1
296
+ retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
183
297
  elif response.status_code == 200 and response.headers.get("Content-Type", "").startswith(APPLICATION_JSON):
184
298
  # guard against "200 okay" responses with malformed json
185
299
  try:
@@ -196,9 +310,7 @@ class ClientBase:
196
310
  )
197
311
  raise exception
198
312
 
199
- # schedule retry
200
- await self.retry_sleep(retry_wait_base, retry_count)
201
- retry_count += 1
313
+ retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
202
314
  else:
203
315
  return response
204
316
 
@@ -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
@@ -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
@@ -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:
@@ -95,7 +99,13 @@ class SWAPIError(SWAPIException):
95
99
  @classmethod
96
100
  def from_response(cls, response: Response) -> "SWAPIError":
97
101
  exception_class = cls.get_exception_class(response.status_code)
98
- response.headers["requested-url"] = str(response.request.url)
102
+
103
+ try:
104
+ response.headers["requested-url"] = str(response.request.url)
105
+ except RuntimeError:
106
+ # If the request URL is not available, we can ignore it.
107
+ pass
108
+
99
109
  return exception_class(
100
110
  status=response.status_code,
101
111
  title=response.reason_phrase,