diff --git a/pyproject.toml b/pyproject.toml index a4f6448..d83e9e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ include = ["src/ikea_api/py.typed"] python = "^3.8" typing-extensions = {version = "^4.0.0", python = "<3.10"} requests = {version = "*", optional = true} -pydantic = {version = "^1.8.0", optional = true} +pydantic = {version = ">=2.0,<3.0", optional = true} httpx = {version = "^0.23", optional = true} [tool.poetry.dev-dependencies] diff --git a/src/ikea_api/wrappers/parsers/ingka_items.py b/src/ikea_api/wrappers/parsers/ingka_items.py index aeea13b..ee19f37 100644 --- a/src/ikea_api/wrappers/parsers/ingka_items.py +++ b/src/ikea_api/wrappers/parsers/ingka_items.py @@ -48,17 +48,17 @@ class ReferenceMeasurements(BaseModel): class Measurements(BaseModel): - referenceMeasurements: Optional[List[ReferenceMeasurements]] + referenceMeasurements: Optional[List[ReferenceMeasurements]] = None class LocalisedCommunication(BaseModel): languageCode: str - packageMeasurements: Optional[List[PackageMeasurement]] - media: Optional[List[Media]] + packageMeasurements: Optional[List[PackageMeasurement]] = None + media: Optional[List[Media]] = None productName: str productType: ProductType - validDesign: Optional[ValidDesign] - measurements: Optional[Measurements] + validDesign: Optional[ValidDesign] = None + measurements: Optional[Measurements] = None class ChildItem(BaseModel): @@ -69,7 +69,7 @@ class ChildItem(BaseModel): class ResponseIngkaItem(BaseModel): itemKey: ItemKey localisedCommunications: List[LocalisedCommunication] - childItems: Optional[List[ChildItem]] + childItems: Optional[List[ChildItem]] = None class ResponseIngkaItems(BaseModel): @@ -178,6 +178,6 @@ def parse_item(constants: Constants, item: ResponseIngkaItem) -> types.IngkaItem def parse_ingka_items( constants: Constants, response: dict[str, Any] ) -> Iterable[types.IngkaItem]: - parsed_resp = ResponseIngkaItems(**response) + parsed_resp = ResponseIngkaItems.model_validate(response) for item in parsed_resp.data: yield parse_item(constants, item) diff --git a/src/ikea_api/wrappers/parsers/item_base.py b/src/ikea_api/wrappers/parsers/item_base.py index 89757f1..897cd87 100644 --- a/src/ikea_api/wrappers/parsers/item_base.py +++ b/src/ikea_api/wrappers/parsers/item_base.py @@ -1,25 +1,23 @@ from typing import Any, Literal -from ikea_api.utils import parse_item_codes +from pydantic import BeforeValidator +from typing_extensions import Annotated +from ikea_api.utils import parse_item_codes -class ItemCode(str): - @classmethod - def __get_validators__(cls): - yield cls.validate - @classmethod - def validate(cls, v: Any): - if isinstance(v, int): - v = str(v) - if isinstance(v, str): - parsed_item_codes = parse_item_codes(v) - if len(parsed_item_codes) != 1: - raise ValueError("invalid item code format") - return parsed_item_codes[0] - raise TypeError("string required") +def validate_item_code(value: Any) -> str: + if isinstance(value, int): + value = str(value) + if isinstance(value, str): + parsed_item_codes = parse_item_codes(value) + if len(parsed_item_codes) != 1: + raise ValueError("invalid item code format") + return parsed_item_codes[0] + raise TypeError("string required") +ItemCode = Annotated[str, BeforeValidator(validate_item_code)] ItemType = Literal["ART", "SPR"] diff --git a/src/ikea_api/wrappers/parsers/order_capture.py b/src/ikea_api/wrappers/parsers/order_capture.py index d6b6ac1..b37ab92 100644 --- a/src/ikea_api/wrappers/parsers/order_capture.py +++ b/src/ikea_api/wrappers/parsers/order_capture.py @@ -1,9 +1,10 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime from typing import Any, List, Optional -from pydantic import BaseModel, validator # pyright: ignore[reportUnknownVariableType] +from pydantic import BaseModel, BeforeValidator +from typing_extensions import Annotated from ikea_api.constants import Constants from ikea_api.utils import translate_from_dict @@ -53,17 +54,11 @@ class EarliestPossibleSlot(BaseModel): class TimeWindows(BaseModel): - earliestPossibleSlot: Optional[EarliestPossibleSlot] + earliestPossibleSlot: Optional[EarliestPossibleSlot] = None class SelectableInfo(BaseModel): - selectable: bool - - @validator( - "selectable", pre=True - ) # pyright: ignore[reportUntypedFunctionDecorator] - def validate_selectable(cls, v: Any) -> Any: - return v == "YES" + selectable: Annotated[bool, BeforeValidator(lambda value: value == "YES")] class Metadata(BaseModel): @@ -75,13 +70,13 @@ class UnavailableItem(BaseModel): availableQuantity: int -def get_date(deliveries: list[HomeDelivery] | None) -> datetime | None: +def get_date(deliveries: list[HomeDelivery] | None) -> date | None: if not deliveries: return for delivery in deliveries: if delivery.timeWindows and delivery.timeWindows.earliestPossibleSlot: - return delivery.timeWindows.earliestPossibleSlot.fromDateTime + return delivery.timeWindows.earliestPossibleSlot.fromDateTime.date() def get_type( @@ -121,7 +116,7 @@ def get_unavailable_items( class HomeDelivery(BaseModel): type: str - timeWindows: Optional[TimeWindows] + timeWindows: Optional[TimeWindows] = None class HomePossibleDeliveries(BaseModel): @@ -131,10 +126,10 @@ class HomePossibleDeliveries(BaseModel): class HomeDeliveryService(BaseModel): metadata: Metadata fulfillmentMethodType: str - solution: Optional[str] - solutionPrice: Optional[SolutionPrice] - possibleDeliveries: Optional[HomePossibleDeliveries] - unavailableItems: Optional[List[UnavailableItem]] + solution: Optional[str] = None + solutionPrice: Optional[SolutionPrice] = None + possibleDeliveries: Optional[HomePossibleDeliveries] = None + unavailableItems: Optional[List[UnavailableItem]] = None class HomePossibleDeliveryServices(BaseModel): @@ -142,13 +137,13 @@ class HomePossibleDeliveryServices(BaseModel): class HomeDeliveryServicesResponse(BaseModel): - possibleDeliveryServices: Optional[HomePossibleDeliveryServices] + possibleDeliveryServices: Optional[HomePossibleDeliveryServices] = None def parse_home_delivery_services( constants: Constants, response: dict[str, Any] ) -> list[types.DeliveryService]: - parsed_response = HomeDeliveryServicesResponse(**response) + parsed_response = HomeDeliveryServicesResponse.model_validate(response) res: list[types.DeliveryService] = [] if not parsed_response.possibleDeliveryServices: @@ -184,8 +179,8 @@ def parse_home_delivery_services( class PickUpPoint(BaseModel): metadata: Metadata - timeWindows: Optional[TimeWindows] - identifier: Optional[str] + timeWindows: Optional[TimeWindows] = None + identifier: Optional[str] = None class PossiblePickUpPoints(BaseModel): @@ -203,10 +198,10 @@ class CollectPossibleDeliveries(BaseModel): class CollectDeliveryService(BaseModel): fulfillmentMethodType: str - solution: Optional[str] - solutionPrice: Optional[SolutionPrice] - possibleDeliveries: Optional[CollectPossibleDeliveries] - unavailableItems: Optional[List[UnavailableItem]] + solution: Optional[str] = None + solutionPrice: Optional[SolutionPrice] = None + possibleDeliveries: Optional[CollectPossibleDeliveries] = None + unavailableItems: Optional[List[UnavailableItem]] = None class CollectPossibleDeliveryServices(BaseModel): @@ -214,7 +209,7 @@ class CollectPossibleDeliveryServices(BaseModel): class CollectDeliveryServicesResponse(BaseModel): - possibleDeliveryServices: Optional[CollectPossibleDeliveryServices] + possibleDeliveryServices: Optional[CollectPossibleDeliveryServices] = None def get_service_provider(constants: Constants, pickup_point: PickUpPoint) -> str | None: @@ -230,7 +225,7 @@ def get_service_provider(constants: Constants, pickup_point: PickUpPoint) -> str def parse_collect_delivery_services( constants: Constants, response: dict[str, Any] ) -> list[types.DeliveryService]: - parsed_response = CollectDeliveryServicesResponse(**response) + parsed_response = CollectDeliveryServicesResponse.model_validate(response) res: list[types.DeliveryService] = [] if not parsed_response.possibleDeliveryServices: @@ -247,7 +242,7 @@ def parse_collect_delivery_services( for delivery in service.possibleDeliveries.deliveries: for point in delivery.possiblePickUpPoints.pickUpPoints: date = ( - point.timeWindows.earliestPossibleSlot.fromDateTime + point.timeWindows.earliestPossibleSlot.fromDateTime.date() if point.timeWindows and point.timeWindows.earliestPossibleSlot else None ) diff --git a/src/ikea_api/wrappers/parsers/pip_item.py b/src/ikea_api/wrappers/parsers/pip_item.py index 894e752..206c767 100644 --- a/src/ikea_api/wrappers/parsers/pip_item.py +++ b/src/ikea_api/wrappers/parsers/pip_item.py @@ -18,7 +18,7 @@ class CatalogRef(BaseModel): class CatalogRefs(BaseModel): - products: Optional[CatalogRef] + products: Optional[CatalogRef] = None class ResponsePipItem(BaseModel): @@ -37,12 +37,12 @@ def get_category_name_and_url(catalog_refs: CatalogRefs): def parse_pip_item(response: dict[str, Any]) -> types.PipItem | None: if not response: return - parsed_item = ResponsePipItem(**response) + parsed_item = ResponsePipItem.model_validate(response) category_name, category_url = get_category_name_and_url(parsed_item.catalogRefs) return types.PipItem( item_code=parsed_item.id, price=parsed_item.priceNumeral, - url=parsed_item.pipUrl, + url=str(parsed_item.pipUrl), category_name=category_name, category_url=category_url, ) diff --git a/src/ikea_api/wrappers/parsers/purchases.py b/src/ikea_api/wrappers/parsers/purchases.py index 21841cc..fee93cb 100644 --- a/src/ikea_api/wrappers/parsers/purchases.py +++ b/src/ikea_api/wrappers/parsers/purchases.py @@ -65,7 +65,7 @@ class HistoryDateAndTime(BaseModel): class HistoryTotalCost(BaseModel): - value: Optional[int] + value: Optional[int] = None class HistoryItem(BaseModel): @@ -85,7 +85,7 @@ class ResponseHistory(BaseModel): def parse_status_banner_order(response: dict[str, Any]) -> types.StatusBannerOrder: - order = ResponseStatusBanner(**response) + order = ResponseStatusBanner.model_validate(response) return types.StatusBannerOrder( purchase_date=order.data.order.dateAndTime.date, delivery_date=order.data.order.deliveryMethods[ @@ -95,7 +95,7 @@ def parse_status_banner_order(response: dict[str, Any]) -> types.StatusBannerOrd def parse_costs_order(response: dict[str, Any]) -> types.CostsOrder: - order = ResponseCosts(**response) + order = ResponseCosts.model_validate(response) costs = order.data.order.costs return types.CostsOrder( delivery_cost=costs.delivery.value, total_cost=costs.total.value @@ -109,7 +109,7 @@ def get_history_datetime(item: HistoryItem) -> str: def parse_history( constants: Constants, response: dict[str, Any] ) -> list[types.PurchaseHistoryItem]: - history = ResponseHistory(**response) + history = ResponseHistory.model_validate(response) return [ types.PurchaseHistoryItem( id=i.id, diff --git a/src/ikea_api/wrappers/types.py b/src/ikea_api/wrappers/types.py index a01065a..577d0f2 100644 --- a/src/ikea_api/wrappers/types.py +++ b/src/ikea_api/wrappers/types.py @@ -7,7 +7,7 @@ class ChildItem(BaseModel): - name: Optional[str] + name: Optional[str] = None item_code: str weight: float qty: int @@ -17,20 +17,20 @@ class ParsedItem(BaseModel): is_combination: bool item_code: str name: str - image_url: Optional[str] + image_url: Optional[str] = None weight: float child_items: List[ChildItem] price: int url: str - category_name: Optional[str] - category_url: Optional[HttpUrl] + category_name: Optional[str] = None + category_url: Optional[HttpUrl] = None class IngkaItem(BaseModel): is_combination: bool item_code: str name: str - image_url: Optional[str] + image_url: Optional[str] = None weight: float child_items: List[ChildItem] @@ -39,8 +39,8 @@ class PipItem(BaseModel): item_code: str price: int url: str - category_name: Optional[str] - category_url: Optional[HttpUrl] + category_name: Optional[str] = None + category_url: Optional[HttpUrl] = None class UnavailableItem(BaseModel): @@ -50,10 +50,10 @@ class UnavailableItem(BaseModel): class DeliveryService(BaseModel): is_available: bool - date: Optional[datetime.date] + date: Optional[datetime.date] = None type: str price: int - service_provider: Optional[str] + service_provider: Optional[str] = None unavailable_items: List[UnavailableItem] diff --git a/src/ikea_api/wrappers/wrappers.py b/src/ikea_api/wrappers/wrappers.py index ded90e5..d97dd8f 100644 --- a/src/ikea_api/wrappers/wrappers.py +++ b/src/ikea_api/wrappers/wrappers.py @@ -39,8 +39,8 @@ def get_purchase_info( ) status_banner, costs = run_with_requests(endpoint) return types.PurchaseInfo( - **parse_status_banner_order(status_banner).dict(), - **parse_costs_order(costs).dict(), + **parse_status_banner_order(status_banner).model_dump(), + **parse_costs_order(costs).model_dump(), ) @@ -50,7 +50,7 @@ class _ExtensionsData(BaseModel): class _Extensions(BaseModel): code: str - data: Optional[_ExtensionsData] + data: Optional[_ExtensionsData] = None class _CartErrorRef(BaseModel): @@ -68,7 +68,7 @@ def add_items_to_cart(cart: Cart, items: dict[str, int]) -> types.CannotAddItems break except GraphQLError as exc: for error_dict in exc.errors: - error = _CartErrorRef(**error_dict) + error = _CartErrorRef.model_validate(error_dict) if error.extensions.code != "INVALID_ITEM_NUMBER": continue if not error.extensions.data: diff --git a/tests/wrappers/parsers/test_item_base.py b/tests/wrappers/parsers/test_item_base.py index dddf3c1..8831c6d 100644 --- a/tests/wrappers/parsers/test_item_base.py +++ b/tests/wrappers/parsers/test_item_base.py @@ -3,25 +3,25 @@ import pytest from ikea_api.wrappers.parsers.item_base import ( - ItemCode, ItemType, get_is_combination_from_item_type, + validate_item_code, ) def test_item_code_validator_value_error(): with pytest.raises(ValueError, match="invalid item code format"): - assert ItemCode.validate("11111.11") == "11111111" + assert validate_item_code("11111.11") == "11111111" def test_item_code_validator_type_error(): with pytest.raises(TypeError, match="string required"): - ItemCode.validate({}) + validate_item_code({}) @pytest.mark.parametrize("v", ("11111111", "111.111.11", "111-111-11", 11111111)) def test_item_code_validator_passes(v: str | int): - assert ItemCode.validate(v) == "11111111" + assert validate_item_code(v) == "11111111" @pytest.mark.parametrize( diff --git a/tests/wrappers/parsers/test_item_ingka.py b/tests/wrappers/parsers/test_item_ingka.py index 62fbab6..4077006 100644 --- a/tests/wrappers/parsers/test_item_ingka.py +++ b/tests/wrappers/parsers/test_item_ingka.py @@ -95,11 +95,13 @@ def test_get_name( productName=product_name, productType=SimpleNamespace(name=product_type), validDesign=SimpleNamespace(text=design) if design else None, - measurements=SimpleNamespace( - referenceMeasurements=[SimpleNamespace(metric=measurements)] - ) - if measurements - else None, + measurements=( + SimpleNamespace( + referenceMeasurements=[SimpleNamespace(metric=measurements)] + ) + if measurements + else None + ), ) assert get_name(comm) == exp_result diff --git a/tests/wrappers/parsers/test_item_pip.py b/tests/wrappers/parsers/test_item_pip.py index 393b8e8..45dd71c 100644 --- a/tests/wrappers/parsers/test_item_pip.py +++ b/tests/wrappers/parsers/test_item_pip.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from pydantic import ValidationError +from pydantic import HttpUrl, ValidationError from ikea_api.wrappers.parsers.pip_item import ( Catalog, @@ -21,7 +21,10 @@ def generate_catalog_refs(name: str, url: str): def test_get_category_name_and_url_passes(): name, url = "Книжные шкафы", "https://www.ikea.com/ru/ru/cat/knizhnye-shkafy-10382/" - assert get_category_name_and_url(generate_catalog_refs(name, url)) == (name, url) + assert get_category_name_and_url(generate_catalog_refs(name, url)) == ( + name, + HttpUrl(url), # pyright: ignore[reportCallIssue] + ) def test_get_category_name_and_url_raises(): diff --git a/tests/wrappers/parsers/test_order_capture.py b/tests/wrappers/parsers/test_order_capture.py index c4638f0..8444418 100644 --- a/tests/wrappers/parsers/test_order_capture.py +++ b/tests/wrappers/parsers/test_order_capture.py @@ -54,7 +54,7 @@ def test_get_date_with_value_first(): ) ), ] - assert get_date(deliveries) == exp_datetime + assert get_date(deliveries) == exp_datetime.date() def test_get_date_with_value_not_first(): @@ -67,7 +67,7 @@ def test_get_date_with_value_not_first(): ) ), ] - assert get_date(deliveries) == exp_datetime + assert get_date(deliveries) == exp_datetime.date() def test_get_type_no_service_type(constants: Constants):