shopware-api-client 1.0.0__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.
Files changed (91) hide show
  1. shopware_api_client-1.0.0/LICENSE +21 -0
  2. shopware_api_client-1.0.0/PKG-INFO +28 -0
  3. shopware_api_client-1.0.0/README.md +3 -0
  4. shopware_api_client-1.0.0/pyproject.toml +60 -0
  5. shopware_api_client-1.0.0/src/shopware_api_client/__init__.py +0 -0
  6. shopware_api_client-1.0.0/src/shopware_api_client/base.py +464 -0
  7. shopware_api_client-1.0.0/src/shopware_api_client/client.py +103 -0
  8. shopware_api_client-1.0.0/src/shopware_api_client/config.py +39 -0
  9. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/__init__.py +0 -0
  10. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/__init__.py +149 -0
  11. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/__init__.py +0 -0
  12. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/category.py +168 -0
  13. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/cms_block.py +98 -0
  14. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/cms_page.py +67 -0
  15. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/cms_section.py +87 -0
  16. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/cms_slot.py +63 -0
  17. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/country.py +123 -0
  18. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/country_state.py +57 -0
  19. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/currency.py +89 -0
  20. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/currency_country_rounding.py +57 -0
  21. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer.py +218 -0
  22. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_address.py +85 -0
  23. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_group.py +74 -0
  24. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_recovery.py +46 -0
  25. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_wishlist.py +53 -0
  26. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_wishlist_product.py +56 -0
  27. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/delivery_time.py +50 -0
  28. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/document.py +80 -0
  29. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/document_base_config.py +73 -0
  30. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/document_base_config_sales_channel.py +64 -0
  31. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/document_type.py +55 -0
  32. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/landing_page.py +75 -0
  33. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/language.py +79 -0
  34. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/locale.py +49 -0
  35. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/main_category.py +63 -0
  36. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/media.py +156 -0
  37. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/media_thumbnail.py +50 -0
  38. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order.py +185 -0
  39. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_address.py +95 -0
  40. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_customer.py +82 -0
  41. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_delivery.py +94 -0
  42. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_delivery_position.py +77 -0
  43. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_line_item.py +125 -0
  44. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_line_item_download.py +64 -0
  45. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_transaction.py +64 -0
  46. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_transaction_capture.py +73 -0
  47. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_transaction_capture_refund.py +72 -0
  48. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_transaction_capture_refund_position.py +80 -0
  49. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/payment_method.py +123 -0
  50. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product.py +326 -0
  51. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_configurator_setting.py +68 -0
  52. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_cross_selling.py +73 -0
  53. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_cross_selling_assigned_products.py +60 -0
  54. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_download.py +59 -0
  55. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_export.py +114 -0
  56. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_feature_set.py +45 -0
  57. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_manufacturer.py +56 -0
  58. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_media.py +60 -0
  59. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_price.py +65 -0
  60. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_review.py +78 -0
  61. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_search_keyword.py +59 -0
  62. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_stream.py +63 -0
  63. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_visibility.py +55 -0
  64. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/promotion.py +114 -0
  65. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/promotion_discount.py +72 -0
  66. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/promotion_discount_prices.py +52 -0
  67. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/property_group.py +60 -0
  68. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/property_group_option.py +63 -0
  69. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/rule.py +68 -0
  70. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/sales_channel.py +246 -0
  71. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/sales_channel_domain.py +74 -0
  72. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/salutation.py +62 -0
  73. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/seo_url.py +79 -0
  74. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/shipping_method.py +88 -0
  75. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/state_machine.py +61 -0
  76. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/state_machine_history.py +76 -0
  77. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/state_machine_state.py +85 -0
  78. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/state_machine_transition.py +61 -0
  79. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/tag.py +54 -0
  80. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/tax.py +49 -0
  81. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/tax_rule.py +56 -0
  82. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/tax_rule_type.py +50 -0
  83. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/unit.py +49 -0
  84. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/user.py +81 -0
  85. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/base_fields.py +115 -0
  86. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/relations.py +39 -0
  87. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/store/__init__.py +3 -0
  88. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/store/core/__init__.py +0 -0
  89. shopware_api_client-1.0.0/src/shopware_api_client/endpoints/store/core/address.py +89 -0
  90. shopware_api_client-1.0.0/src/shopware_api_client/exceptions.py +30 -0
  91. shopware_api_client-1.0.0/src/shopware_api_client/logging.py +7 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 GWS Gesellschaft für Warenwirtschafts-Systeme mbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.1
2
+ Name: shopware-api-client
3
+ Version: 1.0.0
4
+ Summary: An api client for the Shopware API
5
+ Home-page: https://github.com/GWS-mbH
6
+ License: MIT
7
+ Keywords: shopware,api,client
8
+ Author: GWS Gesellschaft für Warenwirtschafts-Systeme mbH
9
+ Author-email: ebusiness@gws.ms
10
+ Requires-Python: >=3.12,<4.0
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Dist: httpx (>=0.26,<0.27)
16
+ Requires-Dist: httpx-auth (>=0.21,<0.22)
17
+ Requires-Dist: pydantic (>=2.6,<3.0)
18
+ Requires-Dist: pytest-random-order (>=1.1.1,<2.0.0)
19
+ Project-URL: Bugtracker, https://github.com/GWS-mbH/shopware-api-client/issues
20
+ Project-URL: Changelog, https://github.com/GWS-mbH/shopware-api-client
21
+ Project-URL: Documentation, https://github.com/GWS-mbH/shopware-api-client/wiki
22
+ Project-URL: Repository, https://github.com/GWS-mbH/shopware-api-client
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Shopware API Client
26
+
27
+ An api client for the Shopware API
28
+
@@ -0,0 +1,3 @@
1
+ # Shopware API Client
2
+
3
+ An api client for the Shopware API
@@ -0,0 +1,60 @@
1
+ [tool.poetry]
2
+ name = "shopware-api-client"
3
+ description = " An api client for the Shopware API"
4
+ version = "1.0.0"
5
+ license = "MIT"
6
+ authors = ["GWS Gesellschaft für Warenwirtschafts-Systeme mbH <ebusiness@gws.ms>"]
7
+ readme = "README.md"
8
+ homepage = "https://github.com/GWS-mbH"
9
+ repository = "https://github.com/GWS-mbH/shopware-api-client"
10
+ documentation = "https://github.com/GWS-mbH/shopware-api-client/wiki"
11
+ keywords = ["shopware", "api", "client"]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+
18
+ [tool.poetry.urls]
19
+ Changelog = "https://github.com/GWS-mbH/shopware-api-client"
20
+ Bugtracker = "https://github.com/GWS-mbH/shopware-api-client/issues"
21
+
22
+ [tool.poetry.dependencies]
23
+ python = "^3.12"
24
+ httpx = "^0.26"
25
+ httpx-auth = "^0.21"
26
+ pydantic = "^2.6"
27
+ pytest-random-order = "^1.1.1"
28
+
29
+ [tool.poetry.group.dev.dependencies]
30
+ mypy = "^1.8.0"
31
+ pytest = "^8.0.2"
32
+ coverage = "^7.4.0"
33
+ pytest-cov = "^4.1.0"
34
+ ruff = "^0.2.0"
35
+ pytest-asyncio = "^0.23.3"
36
+ pytest-recording = "^0.13.1"
37
+
38
+ [build-system]
39
+ requires = ["poetry-core"]
40
+ build-backend = "poetry.core.masonry.api"
41
+
42
+ [tool.mypy]
43
+ python_version = "3.12"
44
+ warn_return_any = true
45
+ warn_unused_configs = true
46
+ disallow_untyped_defs = true
47
+ strict_optional = true
48
+
49
+ [tool.pytest.ini_options]
50
+ python_files = ["test_*.py", "*_test.py"]
51
+ filterwarnings = ["ignore::DeprecationWarning"]
52
+ asyncio_mode = "auto"
53
+ addopts = [
54
+ "--rootdir=./test",
55
+ "--cov-branch",
56
+ "--cov=src",
57
+ "--cov-report=xml",
58
+ "--import-mode=importlib",
59
+ "--random-order",
60
+ ]
@@ -0,0 +1,464 @@
1
+ import json
2
+ from typing import Any, AsyncGenerator, Generic, Self, Type, TypeVar, overload
3
+
4
+ import httpx
5
+ from pydantic import BaseModel
6
+
7
+ from .endpoints.base_fields import IdField
8
+ from .exceptions import SWAPIError, SWFilterException, SWNoClientProvided
9
+ from .logging import logger
10
+
11
+ EndpointClass = TypeVar("EndpointClass", bound="EndpointBase[Any]")
12
+ ModelClass = TypeVar("ModelClass", bound="ApiModelBase[Any]")
13
+
14
+
15
+ class ConfigBase:
16
+ def __init__(self, url: str):
17
+ self.url = url.rstrip("/")
18
+
19
+
20
+ class ClientBase:
21
+ api_url: str
22
+ raw: bool
23
+
24
+ def __init__(self, config: ConfigBase, raw: bool = False):
25
+ self.api_url = config.url
26
+ self.raw = raw
27
+
28
+ async def __aenter__(self) -> "Self":
29
+ self._get_client()
30
+ return self
31
+
32
+ async def __aexit__(self, *args: Any) -> None:
33
+ await self._get_client().aclose()
34
+
35
+ async def log_request(self, request: httpx.Request) -> None:
36
+ logger.debug(f"Request: {request.method} {request.url} - {request.content!r} <headers: {request.headers}>")
37
+
38
+ async def log_response(self, response: httpx.Response) -> None:
39
+ await response.aread()
40
+ logger.debug(
41
+ f"Response: {response.status_code} - {response.status_code} - {response.content!r} "
42
+ f"<headers: {response.headers}>"
43
+ )
44
+
45
+ def _get_client(self) -> httpx.AsyncClient:
46
+ raise NotImplementedError()
47
+
48
+ @staticmethod
49
+ def _get_headers() -> dict[str, str]:
50
+ return {"Content-Type": "application/json", "Accept": "application/vnd.api+json"}
51
+
52
+ async def _make_request(self, method: str, relative_url: str, **kwargs: Any) -> httpx.Response:
53
+ if relative_url.startswith("http://") or relative_url.startswith("https://"):
54
+ url = relative_url
55
+ else:
56
+ url = f"{self.api_url}{relative_url}"
57
+ client = self._get_client()
58
+
59
+ headers = self._get_headers()
60
+ headers.update(kwargs.pop("headers", {}))
61
+
62
+ response = await client.request(method, url, headers=headers, **kwargs)
63
+
64
+ if response.status_code >= 400:
65
+ try:
66
+ raise SWAPIError(response.json().get("errors"))
67
+ except json.JSONDecodeError:
68
+ raise SWAPIError(f"Status: {response.status_code}: {response.text}")
69
+
70
+ return response
71
+
72
+ async def get(self, relative_url: str, **kwargs: Any) -> httpx.Response:
73
+ return await self._make_request(method="GET", relative_url=relative_url, **kwargs)
74
+
75
+ async def post(self, relative_url: str, **kwargs: Any) -> httpx.Response:
76
+ # we need to set a response type, otherwise we don't get one
77
+ relative_url += "?_response=basic"
78
+ return await self._make_request(method="POST", relative_url=relative_url, **kwargs)
79
+
80
+ async def patch(self, relative_url: str, **kwargs: Any) -> httpx.Response:
81
+ # we need to set a reponse type, otherwise we don't get one
82
+ relative_url += "?_response=basic"
83
+ return await self._make_request(method="PATCH", relative_url=relative_url, **kwargs)
84
+
85
+ async def delete(self, relative_url: str, **kwargs: Any) -> httpx.Response:
86
+ return await self._make_request(method="DELETE", relative_url=relative_url, **kwargs)
87
+
88
+ async def close(self) -> None:
89
+ await self._get_client().aclose()
90
+
91
+
92
+ class ApiModelBase(BaseModel, Generic[EndpointClass]):
93
+ _identifier: str
94
+
95
+ id: IdField | None
96
+
97
+ def __init__(self, client: ClientBase | None = None, **kwargs: dict[str, Any]) -> None:
98
+ super().__init__(**kwargs)
99
+ self._client = client
100
+
101
+ @classmethod
102
+ def using(cls: type[Self], client: ClientBase) -> EndpointClass:
103
+ # we want a fresh endpoint
104
+ endpoint: EndpointClass = getattr(client, cls._identifier.get_default()).__class__(client) # type: ignore
105
+ return endpoint
106
+
107
+ def _get_client(self) -> ClientBase:
108
+ if self._client is None:
109
+ raise SWNoClientProvided("Model has no api client set. Use `using` to set a client.")
110
+ return self._client
111
+
112
+ def _get_endpoint(self) -> EndpointClass:
113
+ # we want a fresh endpoint
114
+ endpoint: EndpointClass = getattr(self._get_client(), self._identifier).__class__(self._get_client()) # type: ignore
115
+ return endpoint
116
+
117
+ async def save(self) -> Self | dict:
118
+ endpoint = self._get_endpoint()
119
+
120
+ if self.id is None:
121
+ result = await endpoint.create(obj=self)
122
+ else:
123
+ result = await endpoint.update(pk=self.id, obj=self)
124
+
125
+ return result
126
+
127
+ async def delete(self) -> bool:
128
+ endpoint = self._get_endpoint()
129
+
130
+ # without id we can't delete
131
+ if self.id is None:
132
+ return False
133
+
134
+ return await endpoint.delete(pk=self.id)
135
+
136
+
137
+ class EndpointBase(Generic[ModelClass]):
138
+ name: str
139
+ path: str
140
+ model_class: Type[ModelClass]
141
+ raw: bool
142
+
143
+ def __init__(self, client: ClientBase):
144
+ self.client = client
145
+ self.raw = client.raw
146
+ self._filter: list[dict[str, Any]] = []
147
+ self._limit: int | None = None
148
+ self._sort: list[dict[str, Any]] = []
149
+
150
+ def _is_search_query(self) -> bool:
151
+ return len(self._filter) > 0 or len(self._sort) > 0
152
+
153
+ def _get_data_dict(self) -> dict[str, Any]:
154
+ data: dict[str, Any] = {}
155
+
156
+ if len(self._filter) > 0:
157
+ data["filter"] = self._filter
158
+
159
+ if len(self._sort) > 0:
160
+ data["sort"] = self._sort
161
+
162
+ if self._limit is not None:
163
+ data["limit"] = self._limit
164
+
165
+ return data
166
+
167
+ def _reset_endpoint(self) -> None:
168
+ self._filter = []
169
+ self._limit = None
170
+ self._sort = []
171
+
172
+ @overload
173
+ def _parse_response(self, data: list[dict[str, Any]]) -> list[ModelClass]:
174
+ pass
175
+
176
+ @overload
177
+ def _parse_response(self, data: dict[str, Any]) -> ModelClass:
178
+ pass
179
+
180
+ def _parse_response(self, data: list[dict[str, Any]] | dict[str, Any]) -> list[ModelClass] | ModelClass:
181
+ single = False
182
+
183
+ if isinstance(data, dict):
184
+ single = True
185
+ data = [data]
186
+
187
+ result_list: list[ModelClass] = []
188
+
189
+ for entry in data:
190
+ api_type = entry.get("type", None)
191
+
192
+ if api_type is None:
193
+ model_class = self.model_class
194
+ else:
195
+ model_class = getattr(self.client, api_type).model_class
196
+
197
+ if "attributes" in entry:
198
+ obj = model_class(client=self.client, id=entry["id"], **entry["attributes"])
199
+ else:
200
+ obj = model_class(client=self.client, **entry)
201
+
202
+ result_list.append(obj)
203
+
204
+ if single:
205
+ return result_list[0]
206
+
207
+ return result_list
208
+
209
+ def _parse_data(self, response_dict: dict[str, Any]) -> list[dict[str, Any]]:
210
+ if "data" in response_dict:
211
+ key = "data"
212
+ elif "elements" in response_dict:
213
+ key = "elements"
214
+ else:
215
+ key = None
216
+
217
+ data: list[dict[str, Any]] | dict[str, Any] = response_dict[key] if key else response_dict
218
+
219
+ if isinstance(data, dict):
220
+ return [data]
221
+
222
+ return data
223
+
224
+ def _prase_data_single(self, reponse_dict: dict[str, Any]) -> dict[str, Any]:
225
+ return self._parse_data(reponse_dict)[0]
226
+
227
+ async def all(self) -> list[ModelClass] | list[dict[str, Any]]:
228
+ data = self._get_data_dict()
229
+
230
+ if self._is_search_query():
231
+ result = await self.client.post(f"/search{self.path}", json=data)
232
+ else:
233
+ result = await self.client.get(f"{self.path}", params=data)
234
+
235
+ result_data: list[dict[str, Any]] = self._parse_data(result.json())
236
+
237
+ self._reset_endpoint()
238
+
239
+ if self.raw:
240
+ return result_data
241
+
242
+ return self._parse_response(result_data)
243
+
244
+ async def get(self, pk: str) -> ModelClass | dict[str, Any]:
245
+ result = await self.client.get(f"{self.path}/{pk}")
246
+ result_data: dict[str, Any] = self._prase_data_single(result.json())
247
+
248
+ if self.raw:
249
+ return result_data
250
+
251
+ return self._parse_response(result_data)
252
+
253
+ async def update(self, pk: str, obj: ModelClass | dict[str, Any]) -> ModelClass | dict[str, Any]:
254
+ if isinstance(obj, ApiModelBase):
255
+ data = obj.model_dump_json(by_alias=True)
256
+ else:
257
+ data = json.dumps(obj)
258
+
259
+ result = await self.client.patch(f"{self.path}/{pk}", data=data)
260
+ result_data: dict[str, Any] = self._prase_data_single(result.json())
261
+
262
+ if self.raw:
263
+ return result_data
264
+
265
+ return self._parse_response(result_data)
266
+
267
+ async def first(self) -> ModelClass | dict[str, Any] | None:
268
+ self._limit = 1
269
+ result = await self.all()
270
+
271
+ self._reset_endpoint()
272
+
273
+ # return None instead of an KeyError, if result is empty
274
+ if len(result) == 0:
275
+ return None
276
+
277
+ return result[0]
278
+
279
+ async def create(self, obj: ModelClass | dict[str, Any]) -> ModelClass | dict[str, Any]:
280
+ if isinstance(obj, ApiModelBase):
281
+ data = obj.model_dump_json(by_alias=True)
282
+ else:
283
+ data = json.dumps(obj)
284
+
285
+ result = await self.client.post(f"{self.path}", data=data)
286
+ result_data: dict[str, Any] = self._prase_data_single(result.json())
287
+
288
+ if self.raw:
289
+ return result_data
290
+
291
+ return self._parse_response(result_data)
292
+
293
+ async def delete(self, pk: str) -> bool:
294
+ response = await self.client.delete(f"{self.path}/{pk}")
295
+
296
+ if response.status_code == 204:
297
+ return True
298
+
299
+ return False
300
+
301
+ async def get_related(self, parent: ModelClass, relation: str) -> list[ModelClass] | list[dict[str, Any]]:
302
+ parent_endpoint = parent._get_endpoint()
303
+ result = await self.client.get(f"{parent_endpoint.path}/{parent.id}/{relation}")
304
+ result_data: list[dict[str, Any]] = self._parse_data(result.json())
305
+
306
+ if self.raw:
307
+ return result_data
308
+
309
+ return self._parse_response(result_data)
310
+
311
+ def filter(self, **kwargs: str) -> Self:
312
+ for key, value in kwargs.items():
313
+ filter_term = ""
314
+ filter_type = "equals"
315
+
316
+ field_parts = key.split("__")
317
+
318
+ if len(field_parts) > 1:
319
+ filter_term = field_parts[-1]
320
+
321
+ match filter_term:
322
+ case "in":
323
+ filter_type = "equalsAny"
324
+ case "contains":
325
+ filter_type = "contains"
326
+ case "gt":
327
+ filter_type = "range"
328
+ case "gte":
329
+ filter_type = "range"
330
+ case "lt":
331
+ filter_type = "range"
332
+ case "lte":
333
+ filter_type = "range"
334
+ case "range":
335
+ filter_type = "range"
336
+ case "startswith":
337
+ filter_type = "prefix"
338
+ case "endswith":
339
+ filter_type = "suffix"
340
+ case _:
341
+ filter_term = ""
342
+
343
+ if field_parts[0] not in self.model_class.model_fields:
344
+ raise SWFilterException(
345
+ f"Unknown Field: {field_parts[0]}. Available fields: {self.model_class.model_fields.keys()}"
346
+ )
347
+
348
+ if filter_term != "":
349
+ field_parts = field_parts[:-1]
350
+
351
+ # Todo: Maybe add filter over related attributes
352
+ if len(field_parts) >= 2:
353
+ field = "%s.%s" % (
354
+ self.model_class.model_fields[field_parts[0]].alias or field_parts[0],
355
+ ".".join(field_parts[1:]),
356
+ )
357
+ else:
358
+ field = self.model_class.model_fields[field_parts[0]].alias or field_parts[0]
359
+
360
+ parameters = {}
361
+
362
+ # range has additional parameters
363
+ if filter_type == "range":
364
+ if filter_term == "range":
365
+ parameters = {"gte": value[0], "lte": value[1]}
366
+ else:
367
+ parameters = {filter_term: value}
368
+
369
+ self._filter.append({"type": filter_type, "field": field, "value": value, "parameters": parameters})
370
+
371
+ return self
372
+
373
+ async def bulk_upsert(
374
+ self, objs: list[ModelClass] | list[dict[str, Any]], **request_kwargs: dict[str, Any]
375
+ ) -> dict[str, Any] | None:
376
+ obj_list: list[dict] = []
377
+
378
+ for obj in objs:
379
+ if isinstance(obj, ApiModelBase):
380
+ obj_list.append(obj.model_dump(by_alias=True, mode="json"))
381
+ else:
382
+ obj_list.append(obj)
383
+
384
+ data = {f"write-{self.name}": {"entity": self.name, "action": "upsert", "payload": obj_list}}
385
+
386
+ response = await self.client.post("/_action/sync", json=data, timeout=600, **request_kwargs)
387
+ result: dict[str, Any] = response.json()
388
+
389
+ return result
390
+
391
+ async def bulk_delete(
392
+ self, objs: list[ModelClass] | list[dict[str, Any]], **request_kwargs: dict[str, Any]
393
+ ) -> dict[str, Any]:
394
+ obj_list: list[dict] = []
395
+
396
+ for obj in objs:
397
+ if isinstance(obj, ApiModelBase):
398
+ obj_list.append(obj.model_dump(by_alias=True, mode="json"))
399
+ else:
400
+ obj_list.append(obj)
401
+
402
+ data = {f"delete-{self.name}": {"entity": self.name, "action": "delete", "payload": obj_list}}
403
+
404
+ response = await self.client.post("/_action/sync", json=data, timeout=600, **request_kwargs)
405
+ result: dict[str, Any] = response.json()
406
+
407
+ return result
408
+
409
+ def limit(self, count: int | None) -> "Self":
410
+ self._limit = count
411
+ return self
412
+
413
+ def order_by(self, fields: str | tuple[str]) -> "Self":
414
+ if isinstance(fields, str):
415
+ fields = (fields,)
416
+
417
+ for field in fields:
418
+ if field.startswith("-"):
419
+ field = field[1:]
420
+ order = "DESC"
421
+ else:
422
+ order = "ASC"
423
+
424
+ if field not in self.model_class.model_fields:
425
+ raise SWFilterException(
426
+ f"Unknown Field: {field}. Available fields: " f"{self.model_class.model_fields.keys()}"
427
+ )
428
+ else:
429
+ field = self.model_class.model_fields[field].alias or field
430
+
431
+ self._sort.append({"field": field, "order": order})
432
+
433
+ return self
434
+
435
+ async def iter(self, batch_size: int = 100) -> AsyncGenerator[ModelClass | dict[str, Any], None]:
436
+ self._limit = batch_size
437
+ data = self._get_data_dict()
438
+ page = 1
439
+
440
+ if self._is_search_query():
441
+ url = f"/search{self.path}"
442
+ else:
443
+ url = self.path
444
+
445
+ while True:
446
+ data["page"] = page
447
+ if self._is_search_query():
448
+ result = await self.client.post(url, json=data)
449
+ else:
450
+ result = await self.client.get(url, params=data)
451
+
452
+ result_dict: dict[str, Any] = result.json()
453
+ result_data: list[dict[str, Any]] = self._parse_data(result_dict)
454
+
455
+ for entry in result_data:
456
+ if self.raw:
457
+ yield entry
458
+ else:
459
+ yield self._parse_response(entry)
460
+
461
+ if "next" in result_dict.get("links", {}) and len(result_data) > 0:
462
+ page += 1
463
+ else:
464
+ break
@@ -0,0 +1,103 @@
1
+ import httpx
2
+ from httpx_auth import OAuth2ClientCredentials, OAuth2ResourceOwnerPasswordCredentials
3
+
4
+ from .base import ApiModelBase, ClientBase, EndpointBase
5
+ from .config import AdminConfig, StoreConfig
6
+ from .exceptions import SWAPIConfigException
7
+
8
+
9
+ class AdminClient(ClientBase):
10
+ def __init__(self, config: AdminConfig, raw: bool = False):
11
+ super().__init__(config, raw=raw)
12
+ self.config = config
13
+ self.api_url = f"{config.url}/api"
14
+ self._client: httpx.AsyncClient | None = None
15
+ registry.add_admin_endpoints(self)
16
+
17
+ def _get_client(self) -> httpx.AsyncClient:
18
+ if self._client is None:
19
+ self._client = httpx.AsyncClient(
20
+ event_hooks={"request": [self.log_request], "response": [self.log_response]}
21
+ )
22
+
23
+ auth_url = f"{self.api_url}/oauth/token"
24
+
25
+ if self.config.grant_type == "client_credentials":
26
+ if self.config.client_id is not None and self.config.client_secret is not None:
27
+ self._client.auth = OAuth2ClientCredentials(
28
+ token_url=auth_url, client_id=self.config.client_id, client_secret=self.config.client_secret
29
+ )
30
+ else:
31
+ raise SWAPIConfigException("Missing Client Credentials.")
32
+ elif self.config.grant_type == "password":
33
+ if self.config.username is not None and self.config.password is not None:
34
+ self._client.auth = OAuth2ResourceOwnerPasswordCredentials(
35
+ token_url=auth_url, username=self.config.username, password=self.config.password
36
+ )
37
+ else:
38
+ raise SWAPIConfigException("Missing Client Credentials.")
39
+ else:
40
+ raise SWAPIConfigException("Invalid grant_type for AdminClient.")
41
+
42
+ return self._client
43
+
44
+
45
+ class StoreClient(ClientBase):
46
+ def __init__(self, config: StoreConfig, raw: bool = False):
47
+ super().__init__(config, raw=raw)
48
+ self.config = config
49
+ self.api_url = f"{config.url}/store-api"
50
+ self._client: httpx.AsyncClient | None = None
51
+ registry.add_store_endpoints(self)
52
+
53
+ def _get_client(self) -> httpx.AsyncClient:
54
+ if self._client is None:
55
+ self._client = httpx.AsyncClient(
56
+ event_hooks={"request": [self.log_request], "response": [self.log_response]},
57
+ )
58
+
59
+ headers = {
60
+ "sw-access-key": self.config.access_key,
61
+ "Accept": "application/json",
62
+ "Content-Type": "application/json",
63
+ }
64
+
65
+ if self.config.context_token is not None:
66
+ headers["sw-context-token"] = self.config.context_token
67
+
68
+ self._client.headers = httpx.Headers(headers)
69
+
70
+ return self._client
71
+
72
+
73
+ class EndpointRegistry:
74
+ def __init__(self) -> None:
75
+ self.admin_endpoints: dict[str, type[EndpointBase]] = {}
76
+ self.store_endpoints: dict[str, type[EndpointBase]] = {}
77
+ self.admin_models: dict[str, type[ApiModelBase]] = {}
78
+ self.store_models: dict[str, type[ApiModelBase]] = {}
79
+
80
+ def register_admin(self, endpoint_class: type[EndpointBase]) -> None:
81
+ self.admin_endpoints[endpoint_class.name] = endpoint_class
82
+ self.admin_models[endpoint_class.model_class.__name__] = endpoint_class.model_class
83
+
84
+ def register_store(self, endpoint_class: type[EndpointBase]) -> None:
85
+ self.store_endpoints[endpoint_class.name] = endpoint_class
86
+ self.store_models[endpoint_class.model_class.__name__] = endpoint_class.model_class
87
+
88
+ def add_admin_endpoints(self, client: AdminClient) -> None:
89
+ for name, cls in self.admin_endpoints.items():
90
+ setattr(client, name, cls(client))
91
+
92
+ def get_admin_model(self, name: str) -> type[ApiModelBase]:
93
+ return self.admin_models[name]
94
+
95
+ def add_store_endpoints(self, client: StoreClient) -> None:
96
+ for name, cls in self.store_endpoints.items():
97
+ setattr(client, name, cls(client))
98
+
99
+ def get_store_model(self, name: str) -> type[ApiModelBase]:
100
+ return self.store_models[name]
101
+
102
+
103
+ registry = EndpointRegistry()