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.
- shopware_api_client/base.py +490 -202
- shopware_api_client/cache.py +157 -0
- shopware_api_client/client.py +2 -2
- shopware_api_client/config.py +4 -3
- shopware_api_client/endpoints/admin/__init__.py +20 -4
- shopware_api_client/endpoints/admin/commercial/b2b_components_role.py +5 -20
- shopware_api_client/endpoints/admin/commercial/b2b_components_shopping_list.py +22 -0
- shopware_api_client/endpoints/admin/commercial/b2b_components_shopping_list_line_item.py +20 -0
- shopware_api_client/endpoints/admin/commercial/b2b_employee.py +5 -27
- shopware_api_client/endpoints/admin/commercial/dynamic_access.py +9 -19
- shopware_api_client/endpoints/admin/core/acl_role.py +5 -19
- shopware_api_client/endpoints/admin/core/api_info.py +11 -12
- shopware_api_client/endpoints/admin/core/app.py +5 -42
- shopware_api_client/endpoints/admin/core/app_script_condition.py +5 -24
- shopware_api_client/endpoints/admin/core/category.py +5 -56
- shopware_api_client/endpoints/admin/core/cms_block.py +5 -34
- shopware_api_client/endpoints/admin/core/cms_page.py +5 -26
- shopware_api_client/endpoints/admin/core/cms_section.py +5 -35
- shopware_api_client/endpoints/admin/core/cms_slot.py +5 -31
- shopware_api_client/endpoints/admin/core/country.py +5 -37
- shopware_api_client/endpoints/admin/core/country_state.py +5 -23
- shopware_api_client/endpoints/admin/core/currency.py +5 -33
- shopware_api_client/endpoints/admin/core/currency_country_rounding.py +5 -20
- shopware_api_client/endpoints/admin/core/custom_entity.py +4 -26
- shopware_api_client/endpoints/admin/core/custom_field.py +4 -14
- shopware_api_client/endpoints/admin/core/customer.py +5 -57
- shopware_api_client/endpoints/admin/core/customer_address.py +6 -33
- shopware_api_client/endpoints/admin/core/customer_group.py +5 -24
- shopware_api_client/endpoints/admin/core/customer_recovery.py +5 -16
- shopware_api_client/endpoints/admin/core/customer_wishlist.py +5 -19
- shopware_api_client/endpoints/admin/core/customer_wishlist_product.py +5 -19
- shopware_api_client/endpoints/admin/core/delivery_time.py +5 -21
- shopware_api_client/endpoints/admin/core/document.py +5 -28
- shopware_api_client/endpoints/admin/core/document_base_config.py +5 -27
- shopware_api_client/endpoints/admin/core/document_base_config_sales_channel.py +7 -20
- shopware_api_client/endpoints/admin/core/document_type.py +5 -19
- shopware_api_client/endpoints/admin/core/integration.py +5 -25
- shopware_api_client/endpoints/admin/core/landing_page.py +5 -28
- shopware_api_client/endpoints/admin/core/language.py +5 -21
- shopware_api_client/endpoints/admin/core/locale.py +5 -20
- shopware_api_client/endpoints/admin/core/main_category.py +5 -19
- shopware_api_client/endpoints/admin/core/media.py +25 -37
- shopware_api_client/endpoints/admin/core/media_default_folder.py +4 -17
- shopware_api_client/endpoints/admin/core/media_folder.py +5 -26
- shopware_api_client/endpoints/admin/core/media_folder_configuration.py +5 -21
- shopware_api_client/endpoints/admin/core/media_thumbnail.py +5 -24
- shopware_api_client/endpoints/admin/core/media_thumbnail_size.py +7 -18
- shopware_api_client/endpoints/admin/core/order.py +12 -48
- shopware_api_client/endpoints/admin/core/order_address.py +6 -34
- shopware_api_client/endpoints/admin/core/order_customer.py +6 -29
- shopware_api_client/endpoints/admin/core/order_delivery.py +5 -30
- shopware_api_client/endpoints/admin/core/order_delivery_position.py +5 -26
- shopware_api_client/endpoints/admin/core/order_line_item.py +7 -44
- shopware_api_client/endpoints/admin/core/order_line_item_download.py +5 -23
- shopware_api_client/endpoints/admin/core/order_transaction.py +5 -25
- shopware_api_client/endpoints/admin/core/order_transaction_capture.py +5 -27
- shopware_api_client/endpoints/admin/core/order_transaction_capture_refund.py +7 -28
- shopware_api_client/endpoints/admin/core/order_transaction_capture_refund_position.py +8 -29
- shopware_api_client/endpoints/admin/core/payment_method.py +5 -52
- shopware_api_client/endpoints/admin/core/product.py +24 -90
- shopware_api_client/endpoints/admin/core/product_configurator_setting.py +5 -26
- shopware_api_client/endpoints/admin/core/product_cross_selling.py +5 -27
- shopware_api_client/endpoints/admin/core/product_cross_selling_assigned_products.py +7 -21
- shopware_api_client/endpoints/admin/core/product_download.py +5 -22
- shopware_api_client/endpoints/admin/core/product_export.py +5 -34
- shopware_api_client/endpoints/admin/core/product_feature_set.py +5 -19
- shopware_api_client/endpoints/admin/core/product_manufacturer.py +5 -23
- shopware_api_client/endpoints/admin/core/product_media.py +5 -22
- shopware_api_client/endpoints/admin/core/product_price.py +5 -24
- shopware_api_client/endpoints/admin/core/product_review.py +5 -29
- shopware_api_client/endpoints/admin/core/product_search_keyword.py +5 -20
- shopware_api_client/endpoints/admin/core/product_stream.py +5 -23
- shopware_api_client/endpoints/admin/core/product_visibility.py +5 -18
- shopware_api_client/endpoints/admin/core/product_warehouse.py +5 -17
- shopware_api_client/endpoints/admin/core/promotion.py +5 -38
- shopware_api_client/endpoints/admin/core/promotion_discount.py +5 -24
- shopware_api_client/endpoints/admin/core/promotion_discount_prices.py +5 -19
- shopware_api_client/endpoints/admin/core/property_group.py +5 -24
- shopware_api_client/endpoints/admin/core/property_group_option.py +5 -23
- shopware_api_client/endpoints/admin/core/rule.py +5 -24
- shopware_api_client/endpoints/admin/core/rule_condition.py +5 -23
- shopware_api_client/endpoints/admin/core/sales_channel.py +6 -53
- shopware_api_client/endpoints/admin/core/sales_channel_domain.py +5 -23
- shopware_api_client/endpoints/admin/core/salutation.py +5 -20
- shopware_api_client/endpoints/admin/core/seo_url.py +5 -30
- shopware_api_client/endpoints/admin/core/shipping_method.py +8 -30
- shopware_api_client/endpoints/admin/core/shipping_method_price.py +20 -0
- shopware_api_client/endpoints/admin/core/state_machine.py +5 -21
- shopware_api_client/endpoints/admin/core/state_machine_history.py +5 -25
- shopware_api_client/endpoints/admin/core/state_machine_state.py +6 -20
- shopware_api_client/endpoints/admin/core/state_machine_transition.py +5 -23
- shopware_api_client/endpoints/admin/core/system_config.py +5 -19
- shopware_api_client/endpoints/admin/core/tag.py +5 -14
- shopware_api_client/endpoints/admin/core/tax.py +5 -21
- shopware_api_client/endpoints/admin/core/tax_rule.py +5 -22
- shopware_api_client/endpoints/admin/core/tax_rule_type.py +5 -21
- shopware_api_client/endpoints/admin/core/unit.py +5 -19
- shopware_api_client/endpoints/admin/core/user.py +5 -31
- shopware_api_client/endpoints/admin/core/warehouse.py +5 -15
- shopware_api_client/endpoints/admin/core/warehouse_group.py +6 -18
- shopware_api_client/endpoints/admin/core/warehouse_group_warehouse.py +6 -18
- shopware_api_client/endpoints/base_fields.py +13 -22
- shopware_api_client/endpoints/relations.py +36 -24
- shopware_api_client/endpoints/store/__init__.py +37 -4
- shopware_api_client/endpoints/store/core/address.py +51 -68
- shopware_api_client/endpoints/store/core/cart.py +39 -85
- shopware_api_client/endpoints/store/core/category.py +16 -0
- shopware_api_client/endpoints/store/core/cms_block.py +10 -0
- shopware_api_client/endpoints/store/core/cms_page.py +12 -0
- shopware_api_client/endpoints/store/core/cms_section.py +12 -0
- shopware_api_client/endpoints/store/core/cms_slot.py +8 -0
- shopware_api_client/endpoints/store/core/context.py +58 -0
- shopware_api_client/endpoints/store/core/country.py +15 -0
- shopware_api_client/endpoints/store/core/country_state.py +19 -0
- shopware_api_client/endpoints/store/core/currency.py +12 -0
- shopware_api_client/endpoints/store/core/customer.py +34 -0
- shopware_api_client/endpoints/store/core/customer_group.py +5 -0
- shopware_api_client/endpoints/store/core/delivery_time.py +5 -0
- shopware_api_client/endpoints/store/core/document.py +15 -0
- shopware_api_client/endpoints/store/core/document_type.py +5 -0
- shopware_api_client/endpoints/store/core/landing_page.py +10 -0
- shopware_api_client/endpoints/store/core/language.py +18 -0
- shopware_api_client/endpoints/store/core/locale.py +5 -0
- shopware_api_client/endpoints/store/core/main_category.py +5 -0
- shopware_api_client/endpoints/store/core/media.py +8 -0
- shopware_api_client/endpoints/store/core/media_thumbnail.py +5 -0
- shopware_api_client/endpoints/store/core/order.py +67 -0
- shopware_api_client/endpoints/store/core/order_address.py +12 -0
- shopware_api_client/endpoints/store/core/order_customer.py +8 -0
- shopware_api_client/endpoints/store/core/order_delivery.py +14 -0
- shopware_api_client/endpoints/store/core/order_delivery_position.py +5 -0
- shopware_api_client/endpoints/store/core/order_line_item.py +14 -0
- shopware_api_client/endpoints/store/core/order_transaction.py +12 -0
- shopware_api_client/endpoints/store/core/order_transaction_capture.py +12 -0
- shopware_api_client/endpoints/store/core/order_transaction_capture_refund.py +12 -0
- shopware_api_client/endpoints/store/core/order_transaction_capture_refund_position.py +11 -0
- shopware_api_client/endpoints/store/core/payment_method.py +8 -0
- shopware_api_client/endpoints/store/core/product.py +60 -0
- shopware_api_client/endpoints/store/core/product_configurator_setting.py +10 -0
- shopware_api_client/endpoints/store/core/product_cross_selling.py +5 -0
- shopware_api_client/endpoints/store/core/product_download.py +10 -0
- shopware_api_client/endpoints/store/core/product_manufacturer.py +8 -0
- shopware_api_client/endpoints/store/core/product_media.py +10 -0
- shopware_api_client/endpoints/store/core/product_review.py +5 -0
- shopware_api_client/endpoints/store/core/product_stream.py +5 -0
- shopware_api_client/endpoints/store/core/property_group.py +8 -0
- shopware_api_client/endpoints/store/core/property_group_option.py +10 -0
- shopware_api_client/endpoints/store/core/rule.py +5 -0
- shopware_api_client/endpoints/store/core/sales_channel.py +23 -0
- shopware_api_client/endpoints/store/core/sales_channel_domain.py +12 -0
- shopware_api_client/endpoints/store/core/salutation.py +12 -0
- shopware_api_client/endpoints/store/core/seo_url.py +5 -0
- shopware_api_client/endpoints/store/core/shipping_method.py +18 -0
- shopware_api_client/endpoints/store/core/shipping_method_price.py +5 -0
- shopware_api_client/endpoints/store/core/state_machine_state.py +5 -0
- shopware_api_client/endpoints/store/core/tag.py +5 -0
- shopware_api_client/endpoints/store/core/tax.py +5 -0
- shopware_api_client/endpoints/store/core/unit.py +5 -0
- shopware_api_client/exceptions.py +21 -1
- shopware_api_client/fieldsets.py +12 -0
- shopware_api_client/models/__init__.py +0 -0
- shopware_api_client/models/acl_role.py +11 -0
- shopware_api_client/models/app.py +33 -0
- shopware_api_client/models/app_script_condition.py +16 -0
- shopware_api_client/models/b2b_components_role.py +12 -0
- shopware_api_client/models/b2b_components_shopping_list.py +13 -0
- shopware_api_client/models/b2b_components_shopping_list_line_item.py +14 -0
- shopware_api_client/models/b2b_employee.py +17 -0
- shopware_api_client/models/category.py +44 -0
- shopware_api_client/models/cms_block.py +23 -0
- shopware_api_client/models/cms_page.py +15 -0
- shopware_api_client/models/cms_section.py +20 -0
- shopware_api_client/models/cms_slot.py +17 -0
- shopware_api_client/models/country.py +27 -0
- shopware_api_client/models/country_state.py +12 -0
- shopware_api_client/models/currency.py +20 -0
- shopware_api_client/models/currency_country_rounding.py +11 -0
- shopware_api_client/models/custom_entity.py +19 -0
- shopware_api_client/models/custom_field.py +8 -0
- shopware_api_client/models/customer.py +47 -0
- shopware_api_client/models/customer_address.py +22 -0
- shopware_api_client/models/customer_group.py +13 -0
- shopware_api_client/models/customer_recovery.py +9 -0
- shopware_api_client/models/customer_wishlist.py +9 -0
- shopware_api_client/models/customer_wishlist_product.py +10 -0
- shopware_api_client/models/delivery_time.py +12 -0
- shopware_api_client/models/document.py +20 -0
- shopware_api_client/models/document_base_config.py +19 -0
- shopware_api_client/models/document_base_config_sales_channel.py +10 -0
- shopware_api_client/models/document_type.py +8 -0
- shopware_api_client/models/dynamic_access.py +9 -0
- shopware_api_client/models/integration.py +15 -0
- shopware_api_client/models/landing_page.py +15 -0
- shopware_api_client/models/language.py +11 -0
- shopware_api_client/models/locale.py +9 -0
- shopware_api_client/models/main_category.py +12 -0
- shopware_api_client/models/media.py +26 -0
- shopware_api_client/models/media_default_folder.py +7 -0
- shopware_api_client/models/media_folder.py +16 -0
- shopware_api_client/models/media_folder_configuration.py +11 -0
- shopware_api_client/models/media_thumbnail.py +15 -0
- shopware_api_client/models/media_thumbnail_size.py +8 -0
- shopware_api_client/models/order.py +36 -0
- shopware_api_client/models/order_address.py +23 -0
- shopware_api_client/models/order_customer.py +18 -0
- shopware_api_client/models/order_delivery.py +19 -0
- shopware_api_client/models/order_delivery_position.py +15 -0
- shopware_api_client/models/order_line_item.py +34 -0
- shopware_api_client/models/order_line_item_download.py +12 -0
- shopware_api_client/models/order_transaction.py +15 -0
- shopware_api_client/models/order_transaction_capture.py +15 -0
- shopware_api_client/models/order_transaction_capture_refund.py +16 -0
- shopware_api_client/models/order_transaction_capture_refund_position.py +15 -0
- shopware_api_client/models/payment_method.py +29 -0
- shopware_api_client/models/product.py +72 -0
- shopware_api_client/models/product_configurator_setting.py +14 -0
- shopware_api_client/models/product_cross_selling.py +17 -0
- shopware_api_client/models/product_cross_selling_assigned_products.py +11 -0
- shopware_api_client/models/product_download.py +11 -0
- shopware_api_client/models/product_export.py +27 -0
- shopware_api_client/models/product_feature_set.py +11 -0
- shopware_api_client/models/product_manufacturer.py +11 -0
- shopware_api_client/models/product_media.py +11 -0
- shopware_api_client/models/product_price.py +15 -0
- shopware_api_client/models/product_review.py +19 -0
- shopware_api_client/models/product_search_keyword.py +13 -0
- shopware_api_client/models/product_stream.py +14 -0
- shopware_api_client/models/product_visibility.py +11 -0
- shopware_api_client/models/product_warehouse.py +10 -0
- shopware_api_client/models/promotion.py +29 -0
- shopware_api_client/models/promotion_discount.py +17 -0
- shopware_api_client/models/promotion_discount_prices.py +10 -0
- shopware_api_client/models/property_group.py +13 -0
- shopware_api_client/models/property_group_option.py +12 -0
- shopware_api_client/models/rule.py +16 -0
- shopware_api_client/models/rule_condition.py +15 -0
- shopware_api_client/models/sales_channel.py +44 -0
- shopware_api_client/models/sales_channel_domain.py +13 -0
- shopware_api_client/models/salutation.py +9 -0
- shopware_api_client/models/seo_url.py +19 -0
- shopware_api_client/models/shipping_method.py +18 -0
- shopware_api_client/models/shipping_method_price.py +15 -0
- shopware_api_client/models/state_machine.py +10 -0
- shopware_api_client/models/state_machine_history.py +18 -0
- shopware_api_client/models/state_machine_state.py +8 -0
- shopware_api_client/models/state_machine_transition.py +11 -0
- shopware_api_client/models/system_config.py +12 -0
- shopware_api_client/models/tag.py +7 -0
- shopware_api_client/models/tax.py +9 -0
- shopware_api_client/models/tax_rule.py +15 -0
- shopware_api_client/models/tax_rule_type.py +11 -0
- shopware_api_client/models/unit.py +8 -0
- shopware_api_client/models/user.py +21 -0
- shopware_api_client/models/warehouse.py +8 -0
- shopware_api_client/models/warehouse_group.py +11 -0
- shopware_api_client/models/warehouse_group_warehouse.py +11 -0
- shopware_api_client/structs/__init__.py +0 -0
- shopware_api_client/structs/absolute_price_definition.py +6 -0
- shopware_api_client/structs/calculated_cheapest_price.py +6 -0
- shopware_api_client/structs/calculated_price.py +17 -0
- shopware_api_client/structs/calculated_tax.py +8 -0
- shopware_api_client/structs/cart.py +21 -0
- shopware_api_client/structs/cart_price.py +15 -0
- shopware_api_client/structs/cash_rounding_config.py +7 -0
- shopware_api_client/structs/context.py +15 -0
- shopware_api_client/structs/delivery.py +16 -0
- shopware_api_client/structs/delivery_date.py +8 -0
- shopware_api_client/structs/delivery_information.py +13 -0
- shopware_api_client/structs/delivery_position.py +12 -0
- shopware_api_client/structs/delivery_time.py +8 -0
- shopware_api_client/structs/language_info.py +6 -0
- shopware_api_client/structs/line_item.py +39 -0
- shopware_api_client/structs/list_price.py +7 -0
- shopware_api_client/structs/measurement_units.py +8 -0
- shopware_api_client/structs/percentage_price_definition.py +6 -0
- shopware_api_client/structs/price.py +14 -0
- shopware_api_client/structs/quantity_information.py +7 -0
- shopware_api_client/structs/quantity_price_definition.py +14 -0
- shopware_api_client/structs/reference_price.py +5 -0
- shopware_api_client/structs/reference_price_definition.py +7 -0
- shopware_api_client/structs/regulation_price.py +5 -0
- shopware_api_client/structs/sales_channel_context.py +33 -0
- shopware_api_client/structs/shipping_location.py +12 -0
- shopware_api_client/structs/tax_free_config.py +8 -0
- shopware_api_client/structs/tax_rule.py +6 -0
- shopware_api_client/structs/transaction.py +8 -0
- shopware_api_client/structs/variant_listing_config.py +8 -0
- {shopware_api_client-1.0.101.dist-info → shopware_api_client-1.1.1.dist-info}/METADATA +89 -275
- shopware_api_client-1.1.1.dist-info/RECORD +298 -0
- {shopware_api_client-1.0.101.dist-info → shopware_api_client-1.1.1.dist-info}/WHEEL +1 -1
- shopware_api_client-1.0.101.dist-info/RECORD +0 -114
- {shopware_api_client-1.0.101.dist-info → shopware_api_client-1.1.1.dist-info/licenses}/LICENSE +0 -0
shopware_api_client/base.py
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
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__(
|
|
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.
|
|
124
|
+
return self._get_http_client()
|
|
90
125
|
|
|
91
|
-
def
|
|
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":
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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", [
|
|
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
|
|
146
|
-
retry_count += 1
|
|
241
|
+
retry_count = await self.sleep_and_increment(retry_wait_base, retry_count)
|
|
147
242
|
continue
|
|
148
243
|
|
|
149
|
-
if
|
|
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
|
|
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(
|
|
294
|
+
if any(isinstance(err, no_retry_errors) for err in error.errors):
|
|
165
295
|
raise error
|
|
166
296
|
|
|
167
|
-
if not any(
|
|
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
|
|
303
|
+
if retry_count >= retries:
|
|
174
304
|
raise error
|
|
175
305
|
|
|
176
|
-
await self.
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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(
|
|
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
|
|
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[
|
|
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]) ->
|
|
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[
|
|
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[
|
|
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
|
|
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"
|
|
769
|
+
result = await self.client.post(f"{self.search_prefix}{self.path}", json=data)
|
|
484
770
|
else:
|
|
485
|
-
result = await self.client.get(
|
|
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) ->
|
|
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.
|
|
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
|
-
|
|
507
|
-
) ->
|
|
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.
|
|
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) ->
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
874
|
+
if self._is_search_query():
|
|
875
|
+
url = f"/search{self.path}"
|
|
876
|
+
else:
|
|
877
|
+
url = self.path
|
|
590
878
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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(
|
|
622
|
-
|
|
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
|
-
|
|
898
|
+
break
|
|
628
899
|
|
|
629
|
-
parameters = {}
|
|
630
900
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
657
|
-
self._page = num
|
|
658
|
-
return self
|
|
940
|
+
result_list.append(obj)
|
|
659
941
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
fields = (fields,)
|
|
942
|
+
if errors:
|
|
943
|
+
raise SWAPIDataValidationError(errors=errors)
|
|
663
944
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
field = field[1:]
|
|
667
|
-
order = "DESC"
|
|
668
|
-
else:
|
|
669
|
-
order = "ASC"
|
|
945
|
+
if single:
|
|
946
|
+
return result_list[0]
|
|
670
947
|
|
|
671
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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]
|