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.
- shopware_api_client-1.0.0/LICENSE +21 -0
- shopware_api_client-1.0.0/PKG-INFO +28 -0
- shopware_api_client-1.0.0/README.md +3 -0
- shopware_api_client-1.0.0/pyproject.toml +60 -0
- shopware_api_client-1.0.0/src/shopware_api_client/__init__.py +0 -0
- shopware_api_client-1.0.0/src/shopware_api_client/base.py +464 -0
- shopware_api_client-1.0.0/src/shopware_api_client/client.py +103 -0
- shopware_api_client-1.0.0/src/shopware_api_client/config.py +39 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/__init__.py +0 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/__init__.py +149 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/__init__.py +0 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/category.py +168 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/cms_block.py +98 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/cms_page.py +67 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/cms_section.py +87 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/cms_slot.py +63 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/country.py +123 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/country_state.py +57 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/currency.py +89 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/currency_country_rounding.py +57 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer.py +218 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_address.py +85 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_group.py +74 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_recovery.py +46 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_wishlist.py +53 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/customer_wishlist_product.py +56 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/delivery_time.py +50 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/document.py +80 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/document_base_config.py +73 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/document_base_config_sales_channel.py +64 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/document_type.py +55 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/landing_page.py +75 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/language.py +79 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/locale.py +49 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/main_category.py +63 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/media.py +156 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/media_thumbnail.py +50 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order.py +185 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_address.py +95 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_customer.py +82 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_delivery.py +94 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_delivery_position.py +77 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_line_item.py +125 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_line_item_download.py +64 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_transaction.py +64 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_transaction_capture.py +73 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_transaction_capture_refund.py +72 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/order_transaction_capture_refund_position.py +80 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/payment_method.py +123 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product.py +326 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_configurator_setting.py +68 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_cross_selling.py +73 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_cross_selling_assigned_products.py +60 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_download.py +59 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_export.py +114 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_feature_set.py +45 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_manufacturer.py +56 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_media.py +60 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_price.py +65 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_review.py +78 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_search_keyword.py +59 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_stream.py +63 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/product_visibility.py +55 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/promotion.py +114 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/promotion_discount.py +72 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/promotion_discount_prices.py +52 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/property_group.py +60 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/property_group_option.py +63 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/rule.py +68 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/sales_channel.py +246 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/sales_channel_domain.py +74 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/salutation.py +62 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/seo_url.py +79 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/shipping_method.py +88 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/state_machine.py +61 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/state_machine_history.py +76 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/state_machine_state.py +85 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/state_machine_transition.py +61 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/tag.py +54 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/tax.py +49 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/tax_rule.py +56 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/tax_rule_type.py +50 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/unit.py +49 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/admin/core/user.py +81 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/base_fields.py +115 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/relations.py +39 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/store/__init__.py +3 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/store/core/__init__.py +0 -0
- shopware_api_client-1.0.0/src/shopware_api_client/endpoints/store/core/address.py +89 -0
- shopware_api_client-1.0.0/src/shopware_api_client/exceptions.py +30 -0
- 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,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
|
+
]
|
|
File without changes
|
|
@@ -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()
|