shopware-api-client 1.0.101__py3-none-any.whl → 1.1.1__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.
Files changed (292) hide show
  1. shopware_api_client/base.py +490 -202
  2. shopware_api_client/cache.py +157 -0
  3. shopware_api_client/client.py +2 -2
  4. shopware_api_client/config.py +4 -3
  5. shopware_api_client/endpoints/admin/__init__.py +20 -4
  6. shopware_api_client/endpoints/admin/commercial/b2b_components_role.py +5 -20
  7. shopware_api_client/endpoints/admin/commercial/b2b_components_shopping_list.py +22 -0
  8. shopware_api_client/endpoints/admin/commercial/b2b_components_shopping_list_line_item.py +20 -0
  9. shopware_api_client/endpoints/admin/commercial/b2b_employee.py +5 -27
  10. shopware_api_client/endpoints/admin/commercial/dynamic_access.py +9 -19
  11. shopware_api_client/endpoints/admin/core/acl_role.py +5 -19
  12. shopware_api_client/endpoints/admin/core/api_info.py +11 -12
  13. shopware_api_client/endpoints/admin/core/app.py +5 -42
  14. shopware_api_client/endpoints/admin/core/app_script_condition.py +5 -24
  15. shopware_api_client/endpoints/admin/core/category.py +5 -56
  16. shopware_api_client/endpoints/admin/core/cms_block.py +5 -34
  17. shopware_api_client/endpoints/admin/core/cms_page.py +5 -26
  18. shopware_api_client/endpoints/admin/core/cms_section.py +5 -35
  19. shopware_api_client/endpoints/admin/core/cms_slot.py +5 -31
  20. shopware_api_client/endpoints/admin/core/country.py +5 -37
  21. shopware_api_client/endpoints/admin/core/country_state.py +5 -23
  22. shopware_api_client/endpoints/admin/core/currency.py +5 -33
  23. shopware_api_client/endpoints/admin/core/currency_country_rounding.py +5 -20
  24. shopware_api_client/endpoints/admin/core/custom_entity.py +4 -26
  25. shopware_api_client/endpoints/admin/core/custom_field.py +4 -14
  26. shopware_api_client/endpoints/admin/core/customer.py +5 -57
  27. shopware_api_client/endpoints/admin/core/customer_address.py +6 -33
  28. shopware_api_client/endpoints/admin/core/customer_group.py +5 -24
  29. shopware_api_client/endpoints/admin/core/customer_recovery.py +5 -16
  30. shopware_api_client/endpoints/admin/core/customer_wishlist.py +5 -19
  31. shopware_api_client/endpoints/admin/core/customer_wishlist_product.py +5 -19
  32. shopware_api_client/endpoints/admin/core/delivery_time.py +5 -21
  33. shopware_api_client/endpoints/admin/core/document.py +5 -28
  34. shopware_api_client/endpoints/admin/core/document_base_config.py +5 -27
  35. shopware_api_client/endpoints/admin/core/document_base_config_sales_channel.py +7 -20
  36. shopware_api_client/endpoints/admin/core/document_type.py +5 -19
  37. shopware_api_client/endpoints/admin/core/integration.py +5 -25
  38. shopware_api_client/endpoints/admin/core/landing_page.py +5 -28
  39. shopware_api_client/endpoints/admin/core/language.py +5 -21
  40. shopware_api_client/endpoints/admin/core/locale.py +5 -20
  41. shopware_api_client/endpoints/admin/core/main_category.py +5 -19
  42. shopware_api_client/endpoints/admin/core/media.py +25 -37
  43. shopware_api_client/endpoints/admin/core/media_default_folder.py +4 -17
  44. shopware_api_client/endpoints/admin/core/media_folder.py +5 -26
  45. shopware_api_client/endpoints/admin/core/media_folder_configuration.py +5 -21
  46. shopware_api_client/endpoints/admin/core/media_thumbnail.py +5 -24
  47. shopware_api_client/endpoints/admin/core/media_thumbnail_size.py +7 -18
  48. shopware_api_client/endpoints/admin/core/order.py +12 -48
  49. shopware_api_client/endpoints/admin/core/order_address.py +6 -34
  50. shopware_api_client/endpoints/admin/core/order_customer.py +6 -29
  51. shopware_api_client/endpoints/admin/core/order_delivery.py +5 -30
  52. shopware_api_client/endpoints/admin/core/order_delivery_position.py +5 -26
  53. shopware_api_client/endpoints/admin/core/order_line_item.py +7 -44
  54. shopware_api_client/endpoints/admin/core/order_line_item_download.py +5 -23
  55. shopware_api_client/endpoints/admin/core/order_transaction.py +5 -25
  56. shopware_api_client/endpoints/admin/core/order_transaction_capture.py +5 -27
  57. shopware_api_client/endpoints/admin/core/order_transaction_capture_refund.py +7 -28
  58. shopware_api_client/endpoints/admin/core/order_transaction_capture_refund_position.py +8 -29
  59. shopware_api_client/endpoints/admin/core/payment_method.py +5 -52
  60. shopware_api_client/endpoints/admin/core/product.py +24 -90
  61. shopware_api_client/endpoints/admin/core/product_configurator_setting.py +5 -26
  62. shopware_api_client/endpoints/admin/core/product_cross_selling.py +5 -27
  63. shopware_api_client/endpoints/admin/core/product_cross_selling_assigned_products.py +7 -21
  64. shopware_api_client/endpoints/admin/core/product_download.py +5 -22
  65. shopware_api_client/endpoints/admin/core/product_export.py +5 -34
  66. shopware_api_client/endpoints/admin/core/product_feature_set.py +5 -19
  67. shopware_api_client/endpoints/admin/core/product_manufacturer.py +5 -23
  68. shopware_api_client/endpoints/admin/core/product_media.py +5 -22
  69. shopware_api_client/endpoints/admin/core/product_price.py +5 -24
  70. shopware_api_client/endpoints/admin/core/product_review.py +5 -29
  71. shopware_api_client/endpoints/admin/core/product_search_keyword.py +5 -20
  72. shopware_api_client/endpoints/admin/core/product_stream.py +5 -23
  73. shopware_api_client/endpoints/admin/core/product_visibility.py +5 -18
  74. shopware_api_client/endpoints/admin/core/product_warehouse.py +5 -17
  75. shopware_api_client/endpoints/admin/core/promotion.py +5 -38
  76. shopware_api_client/endpoints/admin/core/promotion_discount.py +5 -24
  77. shopware_api_client/endpoints/admin/core/promotion_discount_prices.py +5 -19
  78. shopware_api_client/endpoints/admin/core/property_group.py +5 -24
  79. shopware_api_client/endpoints/admin/core/property_group_option.py +5 -23
  80. shopware_api_client/endpoints/admin/core/rule.py +5 -24
  81. shopware_api_client/endpoints/admin/core/rule_condition.py +5 -23
  82. shopware_api_client/endpoints/admin/core/sales_channel.py +6 -53
  83. shopware_api_client/endpoints/admin/core/sales_channel_domain.py +5 -23
  84. shopware_api_client/endpoints/admin/core/salutation.py +5 -20
  85. shopware_api_client/endpoints/admin/core/seo_url.py +5 -30
  86. shopware_api_client/endpoints/admin/core/shipping_method.py +8 -30
  87. shopware_api_client/endpoints/admin/core/shipping_method_price.py +20 -0
  88. shopware_api_client/endpoints/admin/core/state_machine.py +5 -21
  89. shopware_api_client/endpoints/admin/core/state_machine_history.py +5 -25
  90. shopware_api_client/endpoints/admin/core/state_machine_state.py +6 -20
  91. shopware_api_client/endpoints/admin/core/state_machine_transition.py +5 -23
  92. shopware_api_client/endpoints/admin/core/system_config.py +5 -19
  93. shopware_api_client/endpoints/admin/core/tag.py +5 -14
  94. shopware_api_client/endpoints/admin/core/tax.py +5 -21
  95. shopware_api_client/endpoints/admin/core/tax_rule.py +5 -22
  96. shopware_api_client/endpoints/admin/core/tax_rule_type.py +5 -21
  97. shopware_api_client/endpoints/admin/core/unit.py +5 -19
  98. shopware_api_client/endpoints/admin/core/user.py +5 -31
  99. shopware_api_client/endpoints/admin/core/warehouse.py +5 -15
  100. shopware_api_client/endpoints/admin/core/warehouse_group.py +6 -18
  101. shopware_api_client/endpoints/admin/core/warehouse_group_warehouse.py +6 -18
  102. shopware_api_client/endpoints/base_fields.py +13 -22
  103. shopware_api_client/endpoints/relations.py +36 -24
  104. shopware_api_client/endpoints/store/__init__.py +37 -4
  105. shopware_api_client/endpoints/store/core/address.py +51 -68
  106. shopware_api_client/endpoints/store/core/cart.py +39 -85
  107. shopware_api_client/endpoints/store/core/category.py +16 -0
  108. shopware_api_client/endpoints/store/core/cms_block.py +10 -0
  109. shopware_api_client/endpoints/store/core/cms_page.py +12 -0
  110. shopware_api_client/endpoints/store/core/cms_section.py +12 -0
  111. shopware_api_client/endpoints/store/core/cms_slot.py +8 -0
  112. shopware_api_client/endpoints/store/core/context.py +58 -0
  113. shopware_api_client/endpoints/store/core/country.py +15 -0
  114. shopware_api_client/endpoints/store/core/country_state.py +19 -0
  115. shopware_api_client/endpoints/store/core/currency.py +12 -0
  116. shopware_api_client/endpoints/store/core/customer.py +34 -0
  117. shopware_api_client/endpoints/store/core/customer_group.py +5 -0
  118. shopware_api_client/endpoints/store/core/delivery_time.py +5 -0
  119. shopware_api_client/endpoints/store/core/document.py +15 -0
  120. shopware_api_client/endpoints/store/core/document_type.py +5 -0
  121. shopware_api_client/endpoints/store/core/landing_page.py +10 -0
  122. shopware_api_client/endpoints/store/core/language.py +18 -0
  123. shopware_api_client/endpoints/store/core/locale.py +5 -0
  124. shopware_api_client/endpoints/store/core/main_category.py +5 -0
  125. shopware_api_client/endpoints/store/core/media.py +8 -0
  126. shopware_api_client/endpoints/store/core/media_thumbnail.py +5 -0
  127. shopware_api_client/endpoints/store/core/order.py +67 -0
  128. shopware_api_client/endpoints/store/core/order_address.py +12 -0
  129. shopware_api_client/endpoints/store/core/order_customer.py +8 -0
  130. shopware_api_client/endpoints/store/core/order_delivery.py +14 -0
  131. shopware_api_client/endpoints/store/core/order_delivery_position.py +5 -0
  132. shopware_api_client/endpoints/store/core/order_line_item.py +14 -0
  133. shopware_api_client/endpoints/store/core/order_transaction.py +12 -0
  134. shopware_api_client/endpoints/store/core/order_transaction_capture.py +12 -0
  135. shopware_api_client/endpoints/store/core/order_transaction_capture_refund.py +12 -0
  136. shopware_api_client/endpoints/store/core/order_transaction_capture_refund_position.py +11 -0
  137. shopware_api_client/endpoints/store/core/payment_method.py +8 -0
  138. shopware_api_client/endpoints/store/core/product.py +60 -0
  139. shopware_api_client/endpoints/store/core/product_configurator_setting.py +10 -0
  140. shopware_api_client/endpoints/store/core/product_cross_selling.py +5 -0
  141. shopware_api_client/endpoints/store/core/product_download.py +10 -0
  142. shopware_api_client/endpoints/store/core/product_manufacturer.py +8 -0
  143. shopware_api_client/endpoints/store/core/product_media.py +10 -0
  144. shopware_api_client/endpoints/store/core/product_review.py +5 -0
  145. shopware_api_client/endpoints/store/core/product_stream.py +5 -0
  146. shopware_api_client/endpoints/store/core/property_group.py +8 -0
  147. shopware_api_client/endpoints/store/core/property_group_option.py +10 -0
  148. shopware_api_client/endpoints/store/core/rule.py +5 -0
  149. shopware_api_client/endpoints/store/core/sales_channel.py +23 -0
  150. shopware_api_client/endpoints/store/core/sales_channel_domain.py +12 -0
  151. shopware_api_client/endpoints/store/core/salutation.py +12 -0
  152. shopware_api_client/endpoints/store/core/seo_url.py +5 -0
  153. shopware_api_client/endpoints/store/core/shipping_method.py +18 -0
  154. shopware_api_client/endpoints/store/core/shipping_method_price.py +5 -0
  155. shopware_api_client/endpoints/store/core/state_machine_state.py +5 -0
  156. shopware_api_client/endpoints/store/core/tag.py +5 -0
  157. shopware_api_client/endpoints/store/core/tax.py +5 -0
  158. shopware_api_client/endpoints/store/core/unit.py +5 -0
  159. shopware_api_client/exceptions.py +21 -1
  160. shopware_api_client/fieldsets.py +12 -0
  161. shopware_api_client/models/__init__.py +0 -0
  162. shopware_api_client/models/acl_role.py +11 -0
  163. shopware_api_client/models/app.py +33 -0
  164. shopware_api_client/models/app_script_condition.py +16 -0
  165. shopware_api_client/models/b2b_components_role.py +12 -0
  166. shopware_api_client/models/b2b_components_shopping_list.py +13 -0
  167. shopware_api_client/models/b2b_components_shopping_list_line_item.py +14 -0
  168. shopware_api_client/models/b2b_employee.py +17 -0
  169. shopware_api_client/models/category.py +44 -0
  170. shopware_api_client/models/cms_block.py +23 -0
  171. shopware_api_client/models/cms_page.py +15 -0
  172. shopware_api_client/models/cms_section.py +20 -0
  173. shopware_api_client/models/cms_slot.py +17 -0
  174. shopware_api_client/models/country.py +27 -0
  175. shopware_api_client/models/country_state.py +12 -0
  176. shopware_api_client/models/currency.py +20 -0
  177. shopware_api_client/models/currency_country_rounding.py +11 -0
  178. shopware_api_client/models/custom_entity.py +19 -0
  179. shopware_api_client/models/custom_field.py +8 -0
  180. shopware_api_client/models/customer.py +47 -0
  181. shopware_api_client/models/customer_address.py +22 -0
  182. shopware_api_client/models/customer_group.py +13 -0
  183. shopware_api_client/models/customer_recovery.py +9 -0
  184. shopware_api_client/models/customer_wishlist.py +9 -0
  185. shopware_api_client/models/customer_wishlist_product.py +10 -0
  186. shopware_api_client/models/delivery_time.py +12 -0
  187. shopware_api_client/models/document.py +20 -0
  188. shopware_api_client/models/document_base_config.py +19 -0
  189. shopware_api_client/models/document_base_config_sales_channel.py +10 -0
  190. shopware_api_client/models/document_type.py +8 -0
  191. shopware_api_client/models/dynamic_access.py +9 -0
  192. shopware_api_client/models/integration.py +15 -0
  193. shopware_api_client/models/landing_page.py +15 -0
  194. shopware_api_client/models/language.py +11 -0
  195. shopware_api_client/models/locale.py +9 -0
  196. shopware_api_client/models/main_category.py +12 -0
  197. shopware_api_client/models/media.py +26 -0
  198. shopware_api_client/models/media_default_folder.py +7 -0
  199. shopware_api_client/models/media_folder.py +16 -0
  200. shopware_api_client/models/media_folder_configuration.py +11 -0
  201. shopware_api_client/models/media_thumbnail.py +15 -0
  202. shopware_api_client/models/media_thumbnail_size.py +8 -0
  203. shopware_api_client/models/order.py +36 -0
  204. shopware_api_client/models/order_address.py +23 -0
  205. shopware_api_client/models/order_customer.py +18 -0
  206. shopware_api_client/models/order_delivery.py +19 -0
  207. shopware_api_client/models/order_delivery_position.py +15 -0
  208. shopware_api_client/models/order_line_item.py +34 -0
  209. shopware_api_client/models/order_line_item_download.py +12 -0
  210. shopware_api_client/models/order_transaction.py +15 -0
  211. shopware_api_client/models/order_transaction_capture.py +15 -0
  212. shopware_api_client/models/order_transaction_capture_refund.py +16 -0
  213. shopware_api_client/models/order_transaction_capture_refund_position.py +15 -0
  214. shopware_api_client/models/payment_method.py +29 -0
  215. shopware_api_client/models/product.py +72 -0
  216. shopware_api_client/models/product_configurator_setting.py +14 -0
  217. shopware_api_client/models/product_cross_selling.py +17 -0
  218. shopware_api_client/models/product_cross_selling_assigned_products.py +11 -0
  219. shopware_api_client/models/product_download.py +11 -0
  220. shopware_api_client/models/product_export.py +27 -0
  221. shopware_api_client/models/product_feature_set.py +11 -0
  222. shopware_api_client/models/product_manufacturer.py +11 -0
  223. shopware_api_client/models/product_media.py +11 -0
  224. shopware_api_client/models/product_price.py +15 -0
  225. shopware_api_client/models/product_review.py +19 -0
  226. shopware_api_client/models/product_search_keyword.py +13 -0
  227. shopware_api_client/models/product_stream.py +14 -0
  228. shopware_api_client/models/product_visibility.py +11 -0
  229. shopware_api_client/models/product_warehouse.py +10 -0
  230. shopware_api_client/models/promotion.py +29 -0
  231. shopware_api_client/models/promotion_discount.py +17 -0
  232. shopware_api_client/models/promotion_discount_prices.py +10 -0
  233. shopware_api_client/models/property_group.py +13 -0
  234. shopware_api_client/models/property_group_option.py +12 -0
  235. shopware_api_client/models/rule.py +16 -0
  236. shopware_api_client/models/rule_condition.py +15 -0
  237. shopware_api_client/models/sales_channel.py +44 -0
  238. shopware_api_client/models/sales_channel_domain.py +13 -0
  239. shopware_api_client/models/salutation.py +9 -0
  240. shopware_api_client/models/seo_url.py +19 -0
  241. shopware_api_client/models/shipping_method.py +18 -0
  242. shopware_api_client/models/shipping_method_price.py +15 -0
  243. shopware_api_client/models/state_machine.py +10 -0
  244. shopware_api_client/models/state_machine_history.py +18 -0
  245. shopware_api_client/models/state_machine_state.py +8 -0
  246. shopware_api_client/models/state_machine_transition.py +11 -0
  247. shopware_api_client/models/system_config.py +12 -0
  248. shopware_api_client/models/tag.py +7 -0
  249. shopware_api_client/models/tax.py +9 -0
  250. shopware_api_client/models/tax_rule.py +15 -0
  251. shopware_api_client/models/tax_rule_type.py +11 -0
  252. shopware_api_client/models/unit.py +8 -0
  253. shopware_api_client/models/user.py +21 -0
  254. shopware_api_client/models/warehouse.py +8 -0
  255. shopware_api_client/models/warehouse_group.py +11 -0
  256. shopware_api_client/models/warehouse_group_warehouse.py +11 -0
  257. shopware_api_client/structs/__init__.py +0 -0
  258. shopware_api_client/structs/absolute_price_definition.py +6 -0
  259. shopware_api_client/structs/calculated_cheapest_price.py +6 -0
  260. shopware_api_client/structs/calculated_price.py +17 -0
  261. shopware_api_client/structs/calculated_tax.py +8 -0
  262. shopware_api_client/structs/cart.py +21 -0
  263. shopware_api_client/structs/cart_price.py +15 -0
  264. shopware_api_client/structs/cash_rounding_config.py +7 -0
  265. shopware_api_client/structs/context.py +15 -0
  266. shopware_api_client/structs/delivery.py +16 -0
  267. shopware_api_client/structs/delivery_date.py +8 -0
  268. shopware_api_client/structs/delivery_information.py +13 -0
  269. shopware_api_client/structs/delivery_position.py +12 -0
  270. shopware_api_client/structs/delivery_time.py +8 -0
  271. shopware_api_client/structs/language_info.py +6 -0
  272. shopware_api_client/structs/line_item.py +39 -0
  273. shopware_api_client/structs/list_price.py +7 -0
  274. shopware_api_client/structs/measurement_units.py +8 -0
  275. shopware_api_client/structs/percentage_price_definition.py +6 -0
  276. shopware_api_client/structs/price.py +14 -0
  277. shopware_api_client/structs/quantity_information.py +7 -0
  278. shopware_api_client/structs/quantity_price_definition.py +14 -0
  279. shopware_api_client/structs/reference_price.py +5 -0
  280. shopware_api_client/structs/reference_price_definition.py +7 -0
  281. shopware_api_client/structs/regulation_price.py +5 -0
  282. shopware_api_client/structs/sales_channel_context.py +33 -0
  283. shopware_api_client/structs/shipping_location.py +12 -0
  284. shopware_api_client/structs/tax_free_config.py +8 -0
  285. shopware_api_client/structs/tax_rule.py +6 -0
  286. shopware_api_client/structs/transaction.py +8 -0
  287. shopware_api_client/structs/variant_listing_config.py +8 -0
  288. {shopware_api_client-1.0.101.dist-info → shopware_api_client-1.1.1.dist-info}/METADATA +89 -275
  289. shopware_api_client-1.1.1.dist-info/RECORD +298 -0
  290. {shopware_api_client-1.0.101.dist-info → shopware_api_client-1.1.1.dist-info}/WHEEL +1 -1
  291. shopware_api_client-1.0.101.dist-info/RECORD +0 -114
  292. {shopware_api_client-1.0.101.dist-info → shopware_api_client-1.1.1.dist-info/licenses}/LICENSE +0 -0
@@ -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,
@@ -25,10 +29,12 @@ from pydantic import (
25
29
  Field,
26
30
  ValidationError,
27
31
  model_serializer,
32
+ PydanticUserError,
28
33
  )
29
34
  from pydantic.alias_generators import to_camel
30
35
  from pydantic.main import IncEx
31
36
 
37
+ from .cache import DictCache, RedisCache
32
38
  from .endpoints.base_fields import IdField
33
39
  from .exceptions import (
34
40
  SWAPIDataValidationError,
@@ -37,20 +43,46 @@ from .exceptions import (
37
43
  SWAPIException,
38
44
  SWAPIGatewayTimeout,
39
45
  SWAPIInternalServerError,
46
+ SWAPIRetryException,
40
47
  SWAPIServiceUnavailable,
41
- SWAPITooManyRequests,
42
48
  SWFilterException,
43
49
  SWNoClientProvided,
44
50
  )
51
+ from .fieldsets import FieldSetBase
45
52
  from .logging import logger
46
53
 
47
- EndpointClass = TypeVar("EndpointClass", bound="EndpointBase[Any]")
48
- ModelClass = TypeVar("ModelClass", bound="ApiModelBase[Any]")
54
+ if TYPE_CHECKING:
55
+ from redis.asyncio import Redis
56
+
57
+ APPLICATION_JSON = "application/json"
58
+
59
+ EndpointClass = TypeVar("EndpointClass", bound="EndpointBase")
60
+ AdminEndpointClass = TypeVar("AdminEndpointClass", bound="AdminEndpoint")
61
+ ModelClass = TypeVar("ModelClass", bound="ApiModelBase")
62
+ AdminModelClass = TypeVar("AdminModelClass", bound="AdminModel")
63
+ FieldSet = TypeVar("FieldSet", bound="FieldSetBase")
64
+
65
+ RETRY_CACHE_KEY = "shopware-api-client:retry:{url}:{method}"
66
+ HEADER_X_RATE_LIMIT_LIMIT = "X-Rate-Limit-Limit"
67
+ HEADER_X_RATE_LIMIT_REMAINING = "X-Rate-Limit-Remaining"
68
+ HEADER_X_RATE_LIMIT_RESET = "X-Rate-Limit-Reset"
49
69
 
50
70
 
51
71
  class ConfigBase:
52
- def __init__(self, url: str):
72
+ def __init__(
73
+ self,
74
+ url: str,
75
+ retry_after_threshold: int = 60,
76
+ redis_client: "Redis | None" = None,
77
+ local_cache_cleanup_cycle_seconds: int = 10,
78
+ ) -> None:
53
79
  self.url = url.rstrip("/")
80
+ self.retry_after_threshold = retry_after_threshold
81
+ self.cache = (
82
+ RedisCache(redis_client)
83
+ if redis_client
84
+ else DictCache(cleanup_cycle_seconds=local_cache_cleanup_cycle_seconds)
85
+ )
54
86
 
55
87
 
56
88
  class ClientBase:
@@ -58,12 +90,15 @@ class ClientBase:
58
90
  raw: bool
59
91
  language_id: IdField | None = None
60
92
 
61
- def __init__(self, config: ConfigBase, raw: bool = False):
93
+ def __init__(self, config: ConfigBase, raw: bool = False) -> None:
62
94
  self.api_url = config.url
95
+ self.retry_after_threshold = config.retry_after_threshold
96
+ self.cache = config.cache
63
97
  self.raw = raw
64
98
 
65
99
  async def __aenter__(self) -> "Self":
66
- self.http_client
100
+ client = self.http_client
101
+ assert isinstance(client, httpx.AsyncClient), "http_client must be an instance of httpx.AsyncClient"
67
102
  return self
68
103
 
69
104
  async def __aexit__(self, *args: Any) -> None:
@@ -86,15 +121,13 @@ class ClientBase:
86
121
 
87
122
  @cached_property
88
123
  def http_client(self) -> httpx.AsyncClient:
89
- return self._get_client()
124
+ return self._get_http_client()
90
125
 
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)
126
+ def _get_http_client(self) -> httpx.AsyncClient:
94
127
  raise NotImplementedError()
95
128
 
96
129
  def _get_headers(self) -> dict[str, str]:
97
- headers = {"Content-Type": "application/json", "Accept": "application/json"}
130
+ headers = {"Content-Type": APPLICATION_JSON, "Accept": APPLICATION_JSON}
98
131
 
99
132
  if self.language_id is not None:
100
133
  headers["sw-language-id"] = str(self.language_id)
@@ -111,10 +144,46 @@ class ClientBase:
111
144
  client = self.http_client
112
145
  client.timeout = timeout # type: ignore
113
146
 
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)
147
+ async def sleep_and_increment(self, retry_wait_base: int, retry_count: int) -> int:
148
+ retry_count += 1
149
+ sleep_and_increment = retry_wait_base ** retry_count
150
+ logger.debug(f"Try failed, retrying in {sleep_and_increment} seconds.")
151
+ await asyncio.sleep(sleep_and_increment)
152
+ return retry_count
153
+
154
+ def get_header_ts(self, header: str | None, fallback_time: float) -> float:
155
+ if header is None:
156
+ return fallback_time
157
+
158
+ server_dt = parsedate_to_datetime(header)
159
+ # ensure timezone-aware UTC
160
+ if server_dt.tzinfo is None:
161
+ server_dt = server_dt.replace(tzinfo=timezone.utc)
162
+
163
+ return server_dt.timestamp()
164
+
165
+ def parse_reset_time(self, headers: httpx.Headers) -> int:
166
+ """Determine reset wait time based on server time"""
167
+ server_ts = self.get_header_ts(headers.get("Date"), time())
168
+ reset_ts = float(headers.get(HEADER_X_RATE_LIMIT_RESET, "0"))
169
+ adjusted_time = reset_ts - server_ts
170
+
171
+ return max(0, ceil(adjusted_time))
172
+
173
+ def parse_retry_after(self, headers: httpx.Headers) -> int:
174
+ retry_header: str | None = headers.get("Retry-After")
175
+ if retry_header is None:
176
+ return 1
177
+
178
+ if retry_header.isdigit():
179
+ return max(1, int(retry_header))
180
+
181
+ current_time = time()
182
+ server_ts = self.get_header_ts(headers.get("Date"), current_time)
183
+ retry_ts = self.get_header_ts(retry_header, current_time)
184
+ adjusted_time = retry_ts - server_ts
185
+
186
+ return max(1, ceil(adjusted_time))
118
187
 
119
188
  async def _make_request(self, method: str, relative_url: str, **kwargs: Any) -> httpx.Response:
120
189
  if relative_url.startswith("http://") or relative_url.startswith("https://"):
@@ -126,59 +195,120 @@ class ClientBase:
126
195
  headers = self._get_headers()
127
196
  headers.update(kwargs.pop("headers", {}))
128
197
 
129
- retry_wait_base = int(kwargs.pop("retriy_wait_base", 2))
198
+ retry_after_threshold = int(kwargs.pop("retry_after_threshold", self.retry_after_threshold))
199
+ retry_wait_base = int(kwargs.pop("retry_wait_base", 2))
130
200
  retries = int(kwargs.pop("retries", 0))
131
201
  retry_errors = tuple(
132
202
  kwargs.pop("retry_errors", [SWAPIInternalServerError, SWAPIServiceUnavailable, SWAPIGatewayTimeout])
133
203
  )
134
- no_retry_errors = tuple(kwargs.pop("no_retry_errors", [SWAPITooManyRequests]))
204
+ no_retry_errors = tuple(kwargs.pop("no_retry_errors", []))
135
205
 
136
206
  kwargs.setdefault("follow_redirects", True)
137
207
 
208
+ key_base = RETRY_CACHE_KEY.format(
209
+ url=url.removeprefix("https://").removeprefix("http://"),
210
+ method=method,
211
+ )
212
+ x_retry_limit_cache_key = key_base + ":limit"
213
+ x_retry_remaining_cache_key = key_base + ":remaining"
214
+ x_retry_reset_cache_key = key_base + ":reset"
215
+ x_retry_lock_cache_key = key_base + ":lock"
216
+ got_lock = False
217
+
138
218
  retry_count = 0
139
219
  while True:
220
+ x_retry_remaining = await self.cache.get_and_decrement(x_retry_remaining_cache_key)
221
+ if x_retry_remaining is not None and x_retry_remaining <= 0 and not got_lock:
222
+ current_time = int(time())
223
+ reset_time = cast(int, await self.cache.get(x_retry_reset_cache_key)) or 0
224
+ wait_time = max(1, reset_time - current_time)
225
+
226
+ if wait_time > retry_after_threshold:
227
+ raise SWAPIRetryException(
228
+ f"Retry threshold exceeded for endpoint {url!r}. Threshold: {retry_after_threshold}s, Retry-After: {wait_time}s"
229
+ )
230
+
231
+ await asyncio.sleep(wait_time)
232
+
233
+ got_lock = await self.cache.has_lock(x_retry_lock_cache_key, wait_time)
234
+ continue
235
+
140
236
  try:
141
237
  response = await client.request(method, url, headers=headers, **kwargs)
142
238
  except httpx.RequestError as exc:
143
239
  if retry_count >= retries:
144
240
  raise SWAPIException(f"HTTP client exception ({exc.__class__.__name__}). Details: {str(exc)}")
145
- await asyncio.sleep(2**retry_count)
146
- retry_count += 1
241
+ retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
147
242
  continue
148
243
 
149
- if response.status_code >= 400:
244
+ # Set retry-cache if headers are present
245
+ if rl_limit := response.headers.get(HEADER_X_RATE_LIMIT_LIMIT):
246
+ x_retry_limit = int(rl_limit)
247
+ wait_time = self.parse_reset_time(response.headers)
248
+ remaining_requests = int(response.headers.get(HEADER_X_RATE_LIMIT_REMAINING))
249
+
250
+ tasks = [
251
+ self.cache.set(x_retry_remaining_cache_key, remaining_requests),
252
+ self.cache.set(x_retry_limit_cache_key, x_retry_limit),
253
+ ]
254
+
255
+ if wait_time > 0:
256
+ tasks.append(self.cache.set(x_retry_reset_cache_key, int(time()) + wait_time, wait_time))
257
+
258
+ await asyncio.gather(*tasks)
259
+
260
+ if got_lock:
261
+ await self.cache.delete(x_retry_lock_cache_key)
262
+ got_lock = False
263
+
264
+ if response.status_code == 429:
265
+ retry_wait_time = self.parse_retry_after(response.headers)
266
+ if retry_wait_time > retry_after_threshold:
267
+ error = SWAPIError.from_response(response)
268
+ raise SWAPIRetryException(
269
+ f"Retry threshold exceeded for endpoint {url!r}. Threshold: {retry_after_threshold}s, Retry-After: {retry_wait_time}s"
270
+ ) from error
271
+
272
+ # If 429 is thrown, Retry-After == X-Rate-Limit-Reset
273
+ await asyncio.gather(
274
+ self.cache.set(x_retry_reset_cache_key, int(time()) + retry_wait_time, retry_wait_time),
275
+ asyncio.sleep(retry_wait_time),
276
+ )
277
+
278
+ elif response.status_code >= 400:
279
+ # retry other failure codes
150
280
  try:
151
281
  errors: list = response.json().get("errors")
152
282
  # ensure `errors` attribute is a list/tuple, fallback to from_response if not
153
283
  if not isinstance(errors, (list, tuple)):
154
284
  raise ValueError("`errors` attribute in json not a list/tuple!")
155
285
 
156
- error: SWAPIError | SWAPIErrorList = SWAPIError.from_errors(errors)
157
- except (json.JSONDecodeError, ValueError):
286
+ error: SWAPIError | SWAPIErrorList = SWAPIError.from_errors(errors, response) # type: ignore
287
+ except ValueError:
158
288
  error: SWAPIError | SWAPIErrorList = SWAPIError.from_response(response) # type: ignore
159
289
 
160
290
  if isinstance(error, SWAPIErrorList) and len(error.errors) == 1:
161
291
  error = error.errors[0]
162
292
 
163
293
  if isinstance(error, SWAPIErrorList):
164
- if any([isinstance(err, no_retry_errors) for err in error.errors]):
294
+ if any(isinstance(err, no_retry_errors) for err in error.errors):
165
295
  raise error
166
296
 
167
- if not any([isinstance(err, retry_errors) for err in error.errors]):
297
+ if not any(isinstance(err, retry_errors) for err in error.errors):
168
298
  raise error
169
299
 
170
300
  elif isinstance(error, no_retry_errors) or not isinstance(error, retry_errors):
171
301
  raise error
172
302
 
173
- if retry_count == retries:
303
+ if retry_count >= retries:
174
304
  raise error
175
305
 
176
- await self.retry_sleep(retry_wait_base, retry_count)
177
- retry_count += 1
178
- else:
306
+ retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
307
+ elif response.status_code == 200 and response.headers.get("Content-Type", "").startswith(APPLICATION_JSON):
179
308
  # guard against "200 okay" responses with malformed json
180
309
  try:
181
310
  setattr(response, "json_cached", response.json())
311
+ return response
182
312
  except json.JSONDecodeError:
183
313
  # retries exhausted?
184
314
  if retry_count >= retries:
@@ -186,15 +316,12 @@ class ClientBase:
186
316
  exception = SWAPIError.from_response(response)
187
317
  # prefix details with x-trace-header to
188
318
  exception.detail = (
189
- f"x-trace-id: {str(response.headers.get('x-trace-id', 'not-set'))}" + exception.detail
319
+ f"x-trace-id: {str(response.headers.get('x-trace-id', 'not-set'))}" + exception.detail
190
320
  )
191
321
  raise exception
192
322
 
193
- # schedule retry
194
- await self.retry_sleep(retry_wait_base, retry_count)
195
- retry_count += 1
196
- continue
197
-
323
+ retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
324
+ else:
198
325
  return response
199
326
 
200
327
  async def get(self, relative_url: str, **kwargs: Any) -> httpx.Response:
@@ -222,28 +349,51 @@ class ClientBase:
222
349
  await self.http_client.aclose()
223
350
 
224
351
  async def bulk_upsert(
225
- self,
226
- name: str,
227
- objs: list[ModelClass] | list[dict[str, Any]],
228
- fail_silently: bool = False,
229
- **request_kwargs: Any,
352
+ self,
353
+ name: str,
354
+ objs: list[ModelClass] | list[dict[str, Any]],
355
+ fail_silently: bool = False,
356
+ **request_kwargs: Any,
230
357
  ) -> dict[str, Any]:
231
358
  raise SWAPIException("bulk_upsert is only supported in the admin API")
232
359
 
233
360
  async def bulk_delete(
234
- self,
235
- name: str,
236
- objs: list[ModelClass] | list[dict[str, Any]],
237
- fail_silently: bool = False,
238
- **request_kwargs: Any,
361
+ self,
362
+ name: str,
363
+ objs: list[ModelClass] | list[dict[str, Any]],
364
+ fail_silently: bool = False,
365
+ **request_kwargs: Any,
239
366
  ) -> dict[str, Any]:
240
367
  raise SWAPIException("bulk_delete is only supported in the admin API")
241
368
 
242
- def set_language(self, language_id: IdField | None) -> None:
369
+ def set_language(self, language_id: "IdField | None") -> None:
243
370
  self.language_id = language_id
244
371
 
245
372
 
246
- class ApiModelBase(BaseModel, Generic[EndpointClass]):
373
+ class EndpointMixin(Generic[EndpointClass]):
374
+ def __init__(self, client: ClientBase | None = None, **kwargs: dict[str, Any]) -> None:
375
+ self._client: ClientBase | None = client
376
+ super().__init__(**kwargs)
377
+
378
+ @classmethod
379
+ def using(cls, client: ClientBase) -> EndpointClass:
380
+ # we want a fresh endpoint
381
+ endpoint: EndpointClass = getattr(client, cls._identifier.get_default()).__class__(client) # type: ignore
382
+ return endpoint
383
+
384
+ def _get_client(self) -> ClientBase:
385
+ if self._client is None:
386
+ raise SWNoClientProvided("Model has no api client set. Use `using` to set a client.")
387
+ return self._client
388
+
389
+ def _get_endpoint(self) -> EndpointClass:
390
+ # we want a fresh endpoint
391
+ client = self._get_client()
392
+ endpoint: EndpointClass = getattr(client, self._identifier).__class__(client) # type: ignore
393
+ return endpoint
394
+
395
+
396
+ class ApiModelBase(BaseModel):
247
397
  model_config = ConfigDict(
248
398
  alias_generator=AliasGenerator(
249
399
  validation_alias=lambda field_name: AliasChoices(field_name, to_camel(field_name)),
@@ -252,13 +402,27 @@ class ApiModelBase(BaseModel, Generic[EndpointClass]):
252
402
  validate_assignment=True,
253
403
  )
254
404
 
255
- id: IdField | None = None
256
- created_at: AwareDatetime = Field(default_factory=lambda: datetime.now(UTC), exclude=True)
405
+ id: "IdField | None" = None
406
+ version_id: IdField | None = None
407
+ translated: dict[str, Any] | list[Any] | None = None
408
+ created_at: AwareDatetime | None = Field(default_factory=lambda: datetime.now(UTC), exclude=True)
257
409
  updated_at: AwareDatetime | None = Field(default=None, exclude=True)
258
410
 
259
411
  def __init__(self, client: ClientBase | None = None, **kwargs: dict[str, Any]) -> None:
260
- super().__init__(**kwargs)
261
- self._client = client
412
+ self._insert_translations(
413
+ data=kwargs,
414
+ translations=kwargs.get("translated")
415
+ )
416
+
417
+ try:
418
+ super().__init__(**kwargs)
419
+ except PydanticUserError:
420
+ self.model_rebuild()
421
+ super().__init__(**kwargs)
422
+
423
+ # Pydantic doesn't do a good job at calling the parents, so we have to help
424
+ if isinstance(self, EndpointMixin):
425
+ EndpointMixin.__init__(self, client=client)
262
426
 
263
427
  def __setattr__(self, name: str, value: Any) -> Any:
264
428
  from .endpoints.relations import ForeignRelation, ManyRelation
@@ -289,6 +453,17 @@ class ApiModelBase(BaseModel, Generic[EndpointClass]):
289
453
 
290
454
  return super().__getattribute__(name)
291
455
 
456
+ @staticmethod
457
+ def _insert_translations(data: dict[str, Any], translations: dict[str, Any] | list[Any] | None) -> dict[str, Any]:
458
+ if not isinstance(translations, dict):
459
+ return data
460
+
461
+ for key, value in translations.items():
462
+ if value and data.get(key) is None:
463
+ data[key] = value
464
+
465
+ return data
466
+
292
467
  @model_serializer(mode="wrap")
293
468
  def ser_model(self, serializer: Callable[..., dict[str, Any]]) -> dict[str, Any]:
294
469
  from .endpoints.relations import ForeignRelation, ManyRelation
@@ -308,24 +483,14 @@ class ApiModelBase(BaseModel, Generic[EndpointClass]):
308
483
 
309
484
  return ser_dict
310
485
 
311
- @classmethod
312
- def using(cls: type[Self], client: ClientBase) -> EndpointClass:
313
- # we want a fresh endpoint
314
- endpoint: EndpointClass = getattr(client, cls._identifier.get_default()).__class__(client) # type: ignore
315
- return endpoint
316
486
 
317
- def _get_client(self) -> ClientBase:
318
- if self._client is None:
319
- raise SWNoClientProvided("Model has no api client set. Use `using` to set a client.")
320
- return self._client
321
-
322
- def _get_endpoint(self) -> EndpointClass:
323
- # we want a fresh endpoint
324
- client = self._get_client()
325
- endpoint: EndpointClass = getattr(client, self._identifier).__class__(client) # type: ignore
326
- return endpoint
487
+ class AdminModel(ApiModelBase, EndpointMixin[AdminEndpointClass], Generic[AdminEndpointClass]):
488
+ def __init__(self, client: ClientBase | None = None, **kwargs: dict[str, Any]) -> None:
489
+ super().__init__(client, **kwargs)
327
490
 
328
- async def save(self, force_insert: bool = False, update_fields: IncEx | None = None) -> Self | dict | None:
491
+ async def save(
492
+ self, force_insert: bool = False, update_fields: IncEx | None = None
493
+ ) -> "AdminModel[Any] | dict | None":
329
494
  endpoint = self._get_endpoint()
330
495
 
331
496
  if force_insert or self.id is None:
@@ -345,15 +510,46 @@ class ApiModelBase(BaseModel, Generic[EndpointClass]):
345
510
  return await endpoint.delete(pk=self.id)
346
511
 
347
512
 
348
- class EndpointBase(Generic[ModelClass]):
513
+ class CustomFieldsMixin(BaseModel):
514
+ custom_fields: dict[str, Any] | None = Field(default=None)
515
+
516
+
517
+ class EndpointBase:
349
518
  name: str
350
519
  path: str
351
- model_class: Type[ModelClass]
352
520
  raw: bool
521
+ search_prefix: str = "/search"
353
522
 
354
- def __init__(self, client: ClientBase):
523
+ def __init__(self, client: ClientBase, *args: Any, **kwargs: Any) -> None:
524
+ super().__init__(*args, **kwargs)
355
525
  self.client = client
356
526
  self.raw = client.raw
527
+
528
+ def _parse_data(self, response_dict: dict[str, Any]) -> list[dict[str, Any]]:
529
+ if "data" in response_dict:
530
+ key = "data"
531
+ elif "elements" in response_dict:
532
+ key = "elements"
533
+ else:
534
+ key = None
535
+
536
+ data: list[dict[str, Any]] | dict[str, Any] = response_dict[key] if key else response_dict
537
+
538
+ if isinstance(data, dict):
539
+ return [data]
540
+
541
+ return data
542
+
543
+ def _parse_data_single(self, reponse_dict: dict[str, Any]) -> dict[str, Any]:
544
+ return self._parse_data(reponse_dict)[0]
545
+
546
+
547
+ class EndpointSearchMixin(Generic[ModelClass]):
548
+ model_class: Type[ModelClass]
549
+
550
+ def __init__(self, *args: Any, **kwargs: Any):
551
+ super().__init__(*args, **kwargs)
552
+
357
553
  self._filter: list[dict[str, Any]] = []
358
554
  self._limit: int | None = None
359
555
  self._page: int | None = None
@@ -402,6 +598,9 @@ class EndpointBase(Generic[ModelClass]):
402
598
  if not self.model_class.__pydantic_complete__:
403
599
  self.model_class.model_rebuild()
404
600
 
601
+ if name == getattr(self.model_class, "_identifier").get_default():
602
+ return name
603
+
405
604
  field = self.model_class.model_fields[name]
406
605
 
407
606
  if get_origin(field.annotation) in [ForeignRelation, ManyRelation]:
@@ -409,24 +608,129 @@ class EndpointBase(Generic[ModelClass]):
409
608
  else:
410
609
  return self.model_class.model_fields[name].serialization_alias or name
411
610
 
611
+ def select_related(self, **kwargs: Any) -> Self:
612
+ self._associations.update({self._serialize_field_name(field): data for field, data in kwargs.items()})
613
+ return self
614
+
615
+ def only(self, **kwargs: list[str]) -> Self:
616
+ for field, data in kwargs.items():
617
+ self._includes[self._serialize_field_name(field)] = [self._serialize_field_name(d) for d in data]
618
+
619
+ return self
620
+
621
+ def filter(self, **kwargs: Any) -> Self:
622
+ for key, value in kwargs.items():
623
+ filter_term = ""
624
+ filter_type = "equals"
625
+
626
+ field_parts = key.split("__")
627
+
628
+ if len(field_parts) > 1:
629
+ filter_term = field_parts[-1]
630
+
631
+ match filter_term:
632
+ case "in":
633
+ filter_type = "equalsAny"
634
+ case "contains":
635
+ filter_type = "contains"
636
+ case "gt":
637
+ filter_type = "range"
638
+ case "gte":
639
+ filter_type = "range"
640
+ case "lt":
641
+ filter_type = "range"
642
+ case "lte":
643
+ filter_type = "range"
644
+ case "range":
645
+ filter_type = "range"
646
+ case "startswith":
647
+ filter_type = "prefix"
648
+ case "endswith":
649
+ filter_type = "suffix"
650
+ case _:
651
+ filter_term = ""
652
+
653
+ if field_parts[0] not in self.model_class.model_fields:
654
+ raise SWFilterException(
655
+ f"Unknown Field: {field_parts[0]}. Available fields: {self.model_class.model_fields.keys()}"
656
+ )
657
+
658
+ if filter_term != "":
659
+ field_parts = field_parts[:-1]
660
+
661
+ if len(field_parts) >= 2:
662
+ field = "%s.%s" % (
663
+ self._serialize_field_name(field_parts[0]),
664
+ ".".join(field_parts[1:]),
665
+ )
666
+ else:
667
+ field = self._serialize_field_name(field_parts[0])
668
+
669
+ parameters = {}
670
+
671
+ # range has additional parameters
672
+ if filter_type == "range":
673
+ if filter_term == "range":
674
+ parameters = {"gte": value[0], "lte": value[1]}
675
+ else:
676
+ parameters = {filter_term: value}
677
+
678
+ self._filter.append({"type": filter_type, "field": field, "value": value, "parameters": parameters})
679
+
680
+ return self
681
+
682
+ def limit(self, count: int | None) -> "Self":
683
+ self._limit = count
684
+ return self
685
+
686
+ def page(self, num: int | None) -> "Self":
687
+ self._page = num
688
+ return self
689
+
690
+ def order_by(self, fields: str | tuple[str]) -> "Self":
691
+ if isinstance(fields, str):
692
+ fields = (fields,)
693
+
694
+ for field in fields:
695
+ if field.startswith("-"):
696
+ field = field[1:]
697
+ order = "DESC"
698
+ else:
699
+ order = "ASC"
700
+
701
+ if field not in self.model_class.model_fields:
702
+ raise SWFilterException(
703
+ f"Unknown Field: {field}. Available fields: {self.model_class.model_fields.keys()}"
704
+ )
705
+ else:
706
+ field = self._serialize_field_name(field)
707
+
708
+ self._sort.append({"field": field, "order": order})
709
+
710
+ return self
711
+
712
+
713
+ class AdminEndpoint(EndpointBase, EndpointSearchMixin, Generic[AdminModelClass]):
714
+ model_class: Type[AdminModelClass]
715
+
412
716
  @overload
413
- def _parse_response(self, data: list[dict[str, Any]]) -> list[ModelClass]:
717
+ def _parse_response(self, data: list[dict[str, Any]]) -> list[AdminModelClass]:
414
718
  # typing overload
415
719
  ...
416
720
 
417
721
  @overload
418
- def _parse_response(self, data: dict[str, Any]) -> ModelClass:
722
+ def _parse_response(self, data: dict[str, Any]) -> AdminModelClass:
419
723
  # typing overload
420
724
  ...
421
725
 
422
- def _parse_response(self, data: list[dict[str, Any]] | dict[str, Any]) -> list[ModelClass] | ModelClass:
726
+ def _parse_response(self, data: list[dict[str, Any]] | dict[str, Any]) -> list[AdminModelClass] | AdminModelClass:
423
727
  single = False
424
728
 
425
729
  if isinstance(data, dict):
426
730
  single = True
427
731
  data = [data]
428
732
 
429
- result_list: list[ModelClass] = []
733
+ result_list: list[AdminModelClass] = []
430
734
  errors = []
431
735
 
432
736
  for entry in data:
@@ -458,31 +762,13 @@ class EndpointBase(Generic[ModelClass]):
458
762
 
459
763
  return result_list
460
764
 
461
- def _parse_data(self, response_dict: dict[str, Any]) -> list[dict[str, Any]]:
462
- if "data" in response_dict:
463
- key = "data"
464
- elif "elements" in response_dict:
465
- key = "elements"
466
- else:
467
- key = None
468
-
469
- data: list[dict[str, Any]] | dict[str, Any] = response_dict[key] if key else response_dict
470
-
471
- if isinstance(data, dict):
472
- return [data]
473
-
474
- return data
475
-
476
- def _prase_data_single(self, reponse_dict: dict[str, Any]) -> dict[str, Any]:
477
- return self._parse_data(reponse_dict)[0]
478
-
479
- async def all(self) -> list[ModelClass] | list[dict[str, Any]]:
765
+ async def all(self) -> list[AdminModelClass] | list[dict[str, Any]]:
480
766
  data = self._get_data_dict()
481
767
 
482
768
  if self._is_search_query():
483
- result = await self.client.post(f"/search{self.path}", json=data)
769
+ result = await self.client.post(f"{self.search_prefix}{self.path}", json=data)
484
770
  else:
485
- result = await self.client.get(f"{self.path}", params=data)
771
+ result = await self.client.get(self.path, params=data)
486
772
 
487
773
  result_data: list[dict[str, Any]] = self._parse_data(result.json())
488
774
 
@@ -493,9 +779,9 @@ class EndpointBase(Generic[ModelClass]):
493
779
 
494
780
  return self._parse_response(result_data)
495
781
 
496
- async def get(self, pk: str) -> ModelClass | dict[str, Any]:
782
+ async def get(self, pk: str) -> AdminModelClass | dict[str, Any]:
497
783
  result = await self.client.get(f"{self.path}/{pk}")
498
- result_data: dict[str, Any] = self._prase_data_single(result.json())
784
+ result_data: dict[str, Any] = self._parse_data_single(result.json())
499
785
 
500
786
  if self.raw:
501
787
  return result_data
@@ -503,8 +789,8 @@ class EndpointBase(Generic[ModelClass]):
503
789
  return self._parse_response(result_data)
504
790
 
505
791
  async def update(
506
- self, pk: str, obj: ModelClass | dict[str, Any], update_fields: IncEx | None = None
507
- ) -> ModelClass | dict[str, Any] | None:
792
+ self, pk: str, obj: AdminModelClass | dict[str, Any], update_fields: IncEx | None = None
793
+ ) -> AdminModelClass | dict[str, Any] | None:
508
794
  if isinstance(obj, ApiModelBase):
509
795
  data = obj.model_dump_json(by_alias=True, include=update_fields)
510
796
  else:
@@ -515,14 +801,14 @@ class EndpointBase(Generic[ModelClass]):
515
801
  if result.status_code == 204:
516
802
  return None
517
803
 
518
- result_data: dict[str, Any] = self._prase_data_single(result.json())
804
+ result_data: dict[str, Any] = self._parse_data_single(result.json())
519
805
 
520
806
  if self.raw:
521
807
  return result_data
522
808
 
523
809
  return self._parse_response(result_data)
524
810
 
525
- async def first(self) -> ModelClass | dict[str, Any] | None:
811
+ async def first(self) -> AdminModelClass | dict[str, Any] | None:
526
812
  self._limit = 1
527
813
  result = await self.all()
528
814
 
@@ -534,7 +820,7 @@ class EndpointBase(Generic[ModelClass]):
534
820
 
535
821
  return result[0]
536
822
 
537
- async def create(self, obj: ModelClass | dict[str, Any]) -> ModelClass | dict[str, Any] | None:
823
+ async def create(self, obj: AdminModelClass | dict[str, Any]) -> AdminModelClass | dict[str, Any] | None:
538
824
  if isinstance(obj, ApiModelBase):
539
825
  data = obj.model_dump_json(by_alias=True)
540
826
  else:
@@ -545,7 +831,7 @@ class EndpointBase(Generic[ModelClass]):
545
831
  if result.status_code == 204:
546
832
  return None
547
833
 
548
- result_data: dict[str, Any] = self._prase_data_single(result.json())
834
+ result_data: dict[str, Any] = self._parse_data_single(result.json())
549
835
 
550
836
  if self.raw:
551
837
  return result_data
@@ -560,7 +846,7 @@ class EndpointBase(Generic[ModelClass]):
560
846
 
561
847
  return False
562
848
 
563
- async def get_related(self, parent: ModelClass, relation: str) -> list[ModelClass] | list[dict[str, Any]]:
849
+ async def get_related(self, parent: AdminModelClass, relation: str) -> list[AdminModelClass] | list[dict[str, Any]]:
564
850
  parent_endpoint = parent._get_endpoint()
565
851
  result = await self.client.get(f"{parent_endpoint.path}/{parent.id}/{relation}")
566
852
  result_data: list[dict[str, Any]] = self._parse_data(result.json())
@@ -570,131 +856,121 @@ class EndpointBase(Generic[ModelClass]):
570
856
 
571
857
  return self._parse_response(result_data)
572
858
 
573
- def select_related(self, **kwargs: Any) -> Self:
574
- self._associations.update({self._serialize_field_name(field): data for field, data in kwargs.items()})
575
- return self
576
-
577
- def only(self, **kwargs: list[str]) -> Self:
578
- self._includes.update({self._serialize_field_name(field): data for field, data in kwargs.items()})
579
- return self
859
+ async def bulk_upsert(
860
+ self, objs: list[AdminModelClass] | list[dict[str, Any]], fail_silently: bool = False, **request_kwargs: Any
861
+ ) -> dict[str, Any]:
862
+ return await self.client.bulk_upsert(name=self.name, objs=objs, fail_silently=fail_silently, **request_kwargs)
580
863
 
581
- def filter(self, **kwargs: Any) -> Self:
582
- for key, value in kwargs.items():
583
- filter_term = ""
584
- filter_type = "equals"
864
+ async def bulk_delete(
865
+ self, objs: list[AdminModelClass] | list[dict[str, Any]], fail_silently: bool = False, **request_kwargs: Any
866
+ ) -> dict[str, Any]:
867
+ return await self.client.bulk_delete(name=self.name, objs=objs, fail_silently=fail_silently, **request_kwargs)
585
868
 
586
- field_parts = key.split("__")
869
+ async def iter(self, batch_size: int = 100) -> AsyncGenerator[AdminModelClass | dict[str, Any], None]:
870
+ self._limit = batch_size
871
+ data = self._get_data_dict()
872
+ page = 1
587
873
 
588
- if len(field_parts) > 1:
589
- filter_term = field_parts[-1]
874
+ if self._is_search_query():
875
+ url = f"/search{self.path}"
876
+ else:
877
+ url = self.path
590
878
 
591
- match filter_term:
592
- case "in":
593
- filter_type = "equalsAny"
594
- case "contains":
595
- filter_type = "contains"
596
- case "gt":
597
- filter_type = "range"
598
- case "gte":
599
- filter_type = "range"
600
- case "lt":
601
- filter_type = "range"
602
- case "lte":
603
- filter_type = "range"
604
- case "range":
605
- filter_type = "range"
606
- case "startswith":
607
- filter_type = "prefix"
608
- case "endswith":
609
- filter_type = "suffix"
610
- case _:
611
- filter_term = ""
879
+ while True:
880
+ data["page"] = page
881
+ if self._is_search_query():
882
+ result = await self.client.post(url, json=data)
883
+ else:
884
+ result = await self.client.get(url, params=data)
612
885
 
613
- if field_parts[0] not in self.model_class.model_fields:
614
- raise SWFilterException(
615
- f"Unknown Field: {field_parts[0]}. Available fields: {self.model_class.model_fields.keys()}"
616
- )
886
+ result_dict: dict[str, Any] = result.json()
887
+ result_data: list[dict[str, Any]] = self._parse_data(result_dict)
617
888
 
618
- if filter_term != "":
619
- field_parts = field_parts[:-1]
889
+ for entry in result_data:
890
+ if self.raw:
891
+ yield entry
892
+ else:
893
+ yield self._parse_response(entry)
620
894
 
621
- if len(field_parts) >= 2:
622
- field = "%s.%s" % (
623
- self._serialize_field_name(field_parts[0]),
624
- ".".join(field_parts[1:]),
625
- )
895
+ if len(result_data) >= self._limit:
896
+ page += 1
626
897
  else:
627
- field = self._serialize_field_name(field_parts[0])
898
+ break
628
899
 
629
- parameters = {}
630
900
 
631
- # range has additional parameters
632
- if filter_type == "range":
633
- if filter_term == "range":
634
- parameters = {"gte": value[0], "lte": value[1]}
635
- else:
636
- parameters = {filter_term: value}
901
+ class StoreEndpoint(EndpointBase):
902
+ @overload
903
+ @staticmethod
904
+ def _parse_response(data: list[dict[str, Any]], cls: Type[ModelClass | FieldSet]) -> list[ModelClass | FieldSet]:
905
+ # typing overload
906
+ ...
637
907
 
638
- self._filter.append({"type": filter_type, "field": field, "value": value, "parameters": parameters})
908
+ @overload
909
+ @staticmethod
910
+ def _parse_response(data: dict[str, Any], cls: Type[ModelClass | FieldSet]) -> ModelClass | FieldSet:
911
+ # typing overload
912
+ ...
639
913
 
640
- return self
914
+ @staticmethod
915
+ def _parse_response(
916
+ data: list[dict[str, Any]] | dict[str, Any], cls: Type[ModelClass | FieldSet]
917
+ ) -> list[ModelClass | FieldSet] | ModelClass | FieldSet:
918
+ single = False
641
919
 
642
- async def bulk_upsert(
643
- self, objs: list[ModelClass] | list[dict[str, Any]], fail_silently: bool = False, **request_kwargs: Any
644
- ) -> dict[str, Any]:
645
- return await self.client.bulk_upsert(name=self.name, objs=objs, fail_silently=fail_silently, **request_kwargs)
920
+ if isinstance(data, dict):
921
+ single = True
922
+ data = [data]
646
923
 
647
- async def bulk_delete(
648
- self, objs: list[ModelClass] | list[dict[str, Any]], fail_silently: bool = False, **request_kwargs: Any
649
- ) -> dict[str, Any]:
650
- return await self.client.bulk_delete(name=self.name, objs=objs, fail_silently=fail_silently, **request_kwargs)
924
+ result_list: list[ModelClass | FieldSet] = []
925
+ errors = []
651
926
 
652
- def limit(self, count: int | None) -> "Self":
653
- self._limit = count
654
- return self
927
+ for entry in data:
928
+ try:
929
+ obj = cls(**entry)
930
+ except ValidationError as exc:
931
+ # catch pydantic validation errors, log faulty result with tracking data and attach to errors
932
+ # (errors will be raised after checking all result objects)
933
+ logger.error(
934
+ "Invalid Shopware data",
935
+ extra={"ModelClass": cls, "id": entry.get("id"), "data": entry, "detail": str(exc)},
936
+ )
937
+ errors.append(exc)
938
+ continue
655
939
 
656
- def page(self, num: int | None) -> "Self":
657
- self._page = num
658
- return self
940
+ result_list.append(obj)
659
941
 
660
- def order_by(self, fields: str | tuple[str]) -> "Self":
661
- if isinstance(fields, str):
662
- fields = (fields,)
942
+ if errors:
943
+ raise SWAPIDataValidationError(errors=errors)
663
944
 
664
- for field in fields:
665
- if field.startswith("-"):
666
- field = field[1:]
667
- order = "DESC"
668
- else:
669
- order = "ASC"
945
+ if single:
946
+ return result_list[0]
670
947
 
671
- if field not in self.model_class.model_fields:
672
- raise SWFilterException(
673
- f"Unknown Field: {field}. Available fields: {self.model_class.model_fields.keys()}"
674
- )
675
- else:
676
- field = self._serialize_field_name(field)
948
+ return result_list
677
949
 
678
- self._sort.append({"field": field, "order": order})
679
950
 
680
- return self
951
+ class StoreSearchEndpoint(StoreEndpoint, EndpointSearchMixin, Generic[ModelClass]):
952
+ path: str
953
+
954
+ async def all(self) -> list[ModelClass] | list[dict[str, Any]]:
955
+ data = self._get_data_dict()
956
+
957
+ result = await self.client.post(self.path, json=data)
958
+
959
+ result_data: list[dict[str, Any]] = result.json().get("elements", [])
960
+
961
+ if self.raw:
962
+ return result_data
963
+
964
+ return self._parse_response(result_data, cls=self.model_class)
681
965
 
682
966
  async def iter(self, batch_size: int = 100) -> AsyncGenerator[ModelClass | dict[str, Any], None]:
683
967
  self._limit = batch_size
684
968
  data = self._get_data_dict()
685
969
  page = 1
686
970
 
687
- if self._is_search_query():
688
- url = f"/search{self.path}"
689
- else:
690
- url = self.path
691
-
692
971
  while True:
693
972
  data["page"] = page
694
- if self._is_search_query():
695
- result = await self.client.post(url, json=data)
696
- else:
697
- result = await self.client.get(url, params=data)
973
+ result = await self.client.post(self.path, json=data)
698
974
 
699
975
  result_dict: dict[str, Any] = result.json()
700
976
  result_data: list[dict[str, Any]] = self._parse_data(result_dict)
@@ -703,9 +979,21 @@ class EndpointBase(Generic[ModelClass]):
703
979
  if self.raw:
704
980
  yield entry
705
981
  else:
706
- yield self._parse_response(entry)
982
+ yield self._parse_response(entry, cls=self.model_class)
707
983
 
708
- if len(result_data) >= self._limit:
984
+ if "next" in result_dict.get("links", {}) and len(result_data) > 0:
709
985
  page += 1
710
986
  else:
711
987
  break
988
+
989
+ async def first(self) -> ModelClass | dict[str, Any] | None:
990
+ self._limit = 1
991
+ result = await self.all()
992
+
993
+ self._reset_endpoint()
994
+
995
+ # return None instead of an KeyError, if result is empty
996
+ if len(result) == 0:
997
+ return None
998
+
999
+ return result[0]