From a7e7c1304d6ae80e7b66ac69ac2c336b5ab292df Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Fri, 6 Mar 2026 11:28:22 +0000 Subject: [PATCH] MPT-18701 Fix incorrect casing Replaced box for dataclasses --- mpt_api_client/models/model.py | 231 ++++++++++++------ pyproject.toml | 1 - tests/unit/models/resource/test_resource.py | 150 +++++++++--- .../resource/test_resource_custom_key.py | 2 +- uv.lock | 23 +- 5 files changed, 269 insertions(+), 138 deletions(-) diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 321a63c..7fdeff3 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -1,80 +1,184 @@ -from typing import Any, ClassVar, Self, override - -from box import Box -from box.box import _camel_killer # type: ignore[attr-defined] # noqa: PLC2701 +import re +from collections import UserList +from collections.abc import Iterable +from typing import Any, ClassVar, Self, get_args, get_origin, override from mpt_api_client.http.types import Response from mpt_api_client.models.meta import Meta ResourceData = dict[str, Any] -_box_safe_attributes: list[str] = ["_box_config", "_attribute_mapping"] +_SNAKE_CASE_BOUNDARY = re.compile(r"([a-z0-9])([A-Z])") +_SNAKE_CASE_ACRONYM = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z0-9])") -class MptBox(Box): - """python-box that preserves camelCase keys when converted to json.""" - def __init__(self, *args, attribute_mapping: dict[str, str] | None = None, **_): # type: ignore[no-untyped-def] - attribute_mapping = attribute_mapping or {} - self._attribute_mapping = attribute_mapping - super().__init__( - *args, - camel_killer_box=False, - default_box=False, - default_box_create_on_get=False, - ) +def to_snake_case(key: str) -> str: + """Converts a camelCase string to snake_case.""" + if "_" in key and key.islower(): + return key + # Common pattern for PascalCase/camelCase conversion + snake = _SNAKE_CASE_BOUNDARY.sub(r"\1_\2", key) + snake = _SNAKE_CASE_ACRONYM.sub(r"_", snake) + return snake.lower().replace("__", "_") + + +def to_camel_case(key: str) -> str: + """Converts a snake_case string to camelCase.""" + parts = key.split("_") + return parts[0] + "".join(x.title() for x in parts[1:]) # noqa: WPS111 WPS221 + + +class ModelList(UserList[Any]): + """A list that automatically converts dictionaries to BaseModel objects.""" + + def __init__( + self, + iterable: Iterable[Any] | None = None, + model_class: type["BaseModel"] | None = None, # noqa: WPS221 + ) -> None: + self._model_class = model_class or BaseModel + iterable = iterable or [] + super().__init__([self._process_item(item) for item in iterable]) @override - def __setitem__(self, key, value): # type: ignore[no-untyped-def] - mapped_key = self._prep_key(key) - super().__setitem__(mapped_key, value) # type: ignore[no-untyped-call] + def append(self, item: Any) -> None: + self.data.append(self._process_item(item)) @override - def __setattr__(self, item: str, value: Any) -> None: - if item in _box_safe_attributes: - return object.__setattr__(self, item, value) + def extend(self, iterable: Iterable[Any]) -> None: + self.data.extend(self._process_item(item) for item in iterable) - super().__setattr__(item, value) # type: ignore[no-untyped-call] - return None + @override + def insert(self, index: Any, item: Any) -> None: + self.data.insert(index, self._process_item(item)) @override - def __getattr__(self, item: str) -> Any: - if item in _box_safe_attributes: - return object.__getattribute__(self, item) - return super().__getattr__(item) # type: ignore[no-untyped-call] + def __setitem__(self, index: Any, item: Any) -> None: + self.data[index] = self._process_item(item) + + def _process_item(self, item: Any) -> Any: + if isinstance(item, dict) and not isinstance(item, BaseModel): + return self._model_class(**item) + if isinstance(item, (list, UserList)) and not isinstance(item, ModelList): + return ModelList(item, model_class=self._model_class) + return item + + +class BaseModel: + """Base dataclass for models providing object-only access and case conversion.""" + + def __init__(self, **kwargs: Any) -> None: # noqa: WPS210 + """Processes resource data to convert keys and handle nested structures.""" + # Get type hints for field mapping + hints = getattr(self, "__annotations__", {}) + + for key, value in kwargs.items(): + mapped_key = to_snake_case(key) + + # Check if there's a type hint for this key + target_class = hints.get(mapped_key) + processed_value = self._process_value(value, target_class=target_class) + object.__setattr__(self, mapped_key, processed_value) + + def __getattr__(self, name: str) -> Any: + # 1. Try to find the attribute in __dict__ (includes attributes set in __init__) + if name in self.__dict__: + return self.__dict__[name] # noqa: WPS420 WPS529 + + # 2. Check for methods or properties + try: + return object.__getattribute__(self, name) + except AttributeError: + pass # noqa: WPS420 + + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{name}'", # noqa: WPS237 + ) @override - def to_dict(self) -> dict[str, Any]: # noqa: WPS210 - reverse_mapping = { - mapped_key: original_key for original_key, mapped_key in self._attribute_mapping.items() - } + def __setattr__(self, name: str, value: Any) -> None: + if name.startswith("_"): + object.__setattr__(self, name, value) + return + + snake_name = to_snake_case(name) + + # Get target class for value processing if it's a known attribute + hints = getattr(self, "__annotations__", {}) + target_class = hints.get(snake_name) or hints.get(name) + + processed_value = self._process_value(value, target_class=target_class) + object.__setattr__(self, snake_name, processed_value) + + def to_dict(self) -> dict[str, Any]: + """Returns the resource as a dictionary with original API keys.""" out_dict = {} - for parsed_key, item_value in super().to_dict().items(): - original_key = reverse_mapping[parsed_key] - out_dict[original_key] = item_value - return out_dict - def _prep_key(self, key: str) -> str: - try: - return self._attribute_mapping[key] - except KeyError: - self._attribute_mapping[key] = _camel_killer(key) - return self._attribute_mapping[key] + # Iterate over all attributes in __dict__ that aren't internal + for key, value in self.__dict__.items(): + if key.startswith("_"): + continue + if key == "meta": + continue + original_key = to_camel_case(key) + out_dict[original_key] = self._serialize_value(value) -class Model: # noqa: WPS214 + return out_dict + + def _serialize_value(self, value: Any) -> Any: + """Recursively serializes values back to dicts.""" + if isinstance(value, BaseModel): + return value.to_dict() + if isinstance(value, (list, UserList)): + return [self._serialize_value(item) for item in value] + return value + + def _process_value(self, value: Any, target_class: Any = None) -> Any: # noqa: WPS231 C901 + """Recursively processes values to ensure nested dicts are BaseModels.""" + if isinstance(value, dict) and not isinstance(value, BaseModel): + # If a target class is provided and it's a subclass of BaseModel, use it + if ( + target_class + and isinstance(target_class, type) + and issubclass(target_class, BaseModel) + ): + return target_class(**value) + return BaseModel(**value) + + if isinstance(value, (list, UserList)) and not isinstance(value, ModelList): + # Try to determine the model class for the list elements from type hints + model_class = BaseModel + if target_class: + # Handle list[ModelClass] + + origin = get_origin(target_class) + if origin is list: + args = get_args(target_class) + if args and isinstance(args[0], type) and issubclass(args[0], BaseModel): # noqa: WPS221 + model_class = args[0] # noqa: WPS220 + + return ModelList(value, model_class=model_class) + # Recursively handle BaseModel if it's already one + if isinstance(value, BaseModel): + return value + return value + + +class Model(BaseModel): """Provides a resource to interact with api data using fluent interfaces.""" _data_key: ClassVar[str | None] = None - _safe_attributes: ClassVar[list[str]] = ["meta", "_box"] - _attribute_mapping: ClassVar[dict[str, str]] = {} - - def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None: - self.meta = meta - self._box = MptBox( - resource_data or {}, - attribute_mapping=self._attribute_mapping, - ) + id: str + + def __init__( + self, resource_data: ResourceData | None = None, meta: Meta | None = None, **kwargs: Any + ) -> None: + object.__setattr__(self, "meta", meta) + data = dict(resource_data or {}) + data.update(kwargs) + super().__init__(**data) @override def __repr__(self) -> str: @@ -84,19 +188,7 @@ def __repr__(self) -> str: @classmethod def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: """Creates a new resource from ResourceData and Meta.""" - return cls(resource_data, meta) - - def __getattr__(self, attribute: str) -> Box | Any: - """Returns the resource data.""" - return self._box.__getattr__(attribute) - - @override - def __setattr__(self, attribute: str, attribute_value: Any) -> None: - if attribute in self._safe_attributes: - object.__setattr__(self, attribute, attribute_value) - return - - self._box.__setattr__(attribute, attribute_value) + return cls(resource_data, meta=meta) @classmethod def from_response(cls, response: Response) -> Self: @@ -114,12 +206,3 @@ def from_response(cls, response: Response) -> Self: raise TypeError("Response data must be a dict.") meta = Meta.from_response(response) return cls.new(response_data, meta) - - @property - def id(self) -> str: - """Returns the resource ID.""" - return str(self._box.get("id", "")) # type: ignore[no-untyped-call] - - def to_dict(self) -> dict[str, Any]: - """Returns the resource as a dictionary.""" - return self._box.to_dict() diff --git a/pyproject.toml b/pyproject.toml index b21ec5b..bb278f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ classifiers = [ ] dependencies = [ "httpx==0.28.*", - "python-box==7.4.*", ] [dependency-groups] diff --git a/tests/unit/models/resource/test_resource.py b/tests/unit/models/resource/test_resource.py index 0570d78..ac8f1fb 100644 --- a/tests/unit/models/resource/test_resource.py +++ b/tests/unit/models/resource/test_resource.py @@ -1,11 +1,25 @@ -from typing import ClassVar - import pytest from httpx import Response from mpt_api_client.models import Meta, Model +class AgreementDummy(Model): # noqa: WPS431 + """Dummy class for testing.""" + + +class ContactDummy(Model): + """Dummy class for testing.""" + + name: str + + +class AgreementWithContactDummy(Model): + """Dummy class for testing.""" + + contact: ContactDummy + + @pytest.fixture def meta_data(): return {"pagination": {"limit": 10, "offset": 20, "total": 100}, "ignored": ["one"]} # noqa: WPS226 @@ -29,26 +43,17 @@ def test_from_response(meta_data): assert result.meta == expected_meta -def test_attribute_getter(meta_data): - resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} +def test_attribute_id(meta_data): + resource_data = {"id": "1", "name": {"given": "Albert", "family": "Einstein"}} response_data = resource_data | {"$meta": meta_data} response = Response(200, json=response_data) + resource = Model.from_response(response) - result = Model.from_response(response) - - assert result.id == "1" - assert result.name.given == "Albert" - - -def test_attribute_setter(): # noqa: AAA01 - resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} - resource = Model(resource_data) - - resource.id = "2" - resource.name.given = "John" + resource.id = "R-1" # act - assert resource.id == "2" - assert resource.name.given == "John" + assert resource.id == "R-1" + assert resource.name.given == "Albert" + assert resource.to_dict() == {"id": "R-1", "name": {"given": "Albert", "family": "Einstein"}} def test_wrong_data_type(): @@ -67,22 +72,13 @@ def test_id_property_with_string_id(): assert isinstance(result.id, str) -def test_id_property_with_numeric_id(): - resource_data = {"id": 1024} - - result = Model(resource_data) - - assert result.id == "1024" - assert isinstance(result.id, str) - - def test_case_conversion(): resource_data = {"id": "abc-123", "FullName": "Alice Smith"} result = Model(resource_data) assert result.full_name == "Alice Smith" - assert result.to_dict() == resource_data + assert result.to_dict() == {"id": "abc-123", "fullName": "Alice Smith"} with pytest.raises(AttributeError): _ = result.FullName # noqa: WPS122 @@ -91,7 +87,7 @@ def test_deep_case_conversion(): resource_data = {"id": "ABC-123", "contact": {"id": "ABC-345", "FullName": "Alice Smith"}} expected_resource_data = { "id": "ABC-123", - "contact": {"id": "ABC-345", "FullName": "Alice Smith", "StreetAddress": "123 Main St"}, + "contact": {"id": "ABC-345", "fullName": "Alice Smith", "streetAddress": "123 Main St"}, } resource = Model(resource_data) @@ -116,17 +112,91 @@ def test_repr(): def test_mapping(): - class MappingModel(Model): # noqa: WPS431 - _attribute_mapping: ClassVar[dict[str, str]] = { - "second_id": "resource_id", - "Full_Name": "name", - } - - # BL - resource_data = {"id": "abc-123", "second_id": "resource-abc-123", "Full_Name": "Alice Smith"} + resource_data = {"id": "abc-123", "secondId": "resource-abc-123", "fullName": "Alice Smith"} - result = MappingModel(resource_data) + result = Model(**resource_data) - assert result.name == "Alice Smith" - assert result.resource_id == "resource-abc-123" + assert result.full_name == "Alice Smith" + assert result.second_id == "resource-abc-123" assert result.to_dict() == resource_data + + +def test_overwritting(): + agreement_data = { + "id": "AGR-123", + "parameters": { + "ordering": [ + {"externalId": "contact", "value": "Hommer Simpson"}, + {"externalId": "address", "value": "Springfield"}, + ] + }, + } + agreement = AgreementDummy(agreement_data) + + agreement.parameters.ordering[1] = {"externalId": "address", "value": "Springfield"} # act + + assert agreement.id == "AGR-123" + assert agreement.parameters.ordering[0].external_id == "contact" + assert agreement.to_dict() == agreement_data + + +def test_append(): + agreement_data = { + "id": "AGR-123", + "parameters": { + "ordering": [ + {"externalId": "contact", "value": "Hommer Simpson"}, + {"externalId": "address", "value": "Springfield"}, + ] + }, + } + agreement = AgreementDummy(agreement_data) + new_param = {"externalId": "email", "value": "homer.simpson@example.com"} + + agreement.parameters.ordering.append(new_param) # act + + assert agreement.id == "AGR-123" + assert agreement.parameters.ordering[0].external_id == "contact" + assert agreement.parameters.ordering[1].external_id == "address" + assert agreement.parameters.ordering[2].external_id == "email" + agreement_data["parameters"]["ordering"].append(new_param) + assert agreement.to_dict() == agreement_data + + +def test_overwrite_list(): + ordering_parameters = [ + {"externalId": "contact", "value": "Hommer Simpson"}, + {"externalId": "address", "value": "Springfield"}, + ] + agreement_data = { + "id": "AGR-123", + "parameters": {"ordering": ordering_parameters}, + } + agreement = AgreementDummy(agreement_data) + + agreement.parameters.ordering = ordering_parameters # act + + assert agreement.id == "AGR-123" + assert agreement.parameters.ordering[0].external_id == "contact" + assert agreement.parameters.ordering[1].external_id == "address" + assert agreement.to_dict() == agreement_data + + +def test_advanced_mapping(): + ordering_parameters = [ + {"externalId": "contact", "value": "Hommer Simpson"}, + {"externalId": "address", "value": "Springfield"}, + ] + agreement_data = { + "id": "AGR-123", + "contact": {"name": "Hommer Simpson"}, + "parameters": {"ordering": ordering_parameters}, + } + agreement = AgreementWithContactDummy(agreement_data) + + agreement.parameters.ordering = ordering_parameters # act + + assert isinstance(agreement.contact, ContactDummy) + assert agreement.parameters.ordering[0].external_id == "contact" + assert agreement.parameters.ordering[1].external_id == "address" + assert agreement.to_dict() == agreement_data diff --git a/tests/unit/models/resource/test_resource_custom_key.py b/tests/unit/models/resource/test_resource_custom_key.py index 24142f5..9d4877e 100644 --- a/tests/unit/models/resource/test_resource_custom_key.py +++ b/tests/unit/models/resource/test_resource_custom_key.py @@ -8,7 +8,7 @@ class ChargeResourceMock(Model): def test_custom_data_key(): - record_data = {"id": 1, "amount": 100} + record_data = {"id": "1", "amount": 100} response = Response(200, json={"charge": record_data}) result = ChargeResourceMock.from_response(response) diff --git a/uv.lock b/uv.lock index 976a872..546bad4 100644 --- a/uv.lock +++ b/uv.lock @@ -737,7 +737,6 @@ version = "1.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, - { name = "python-box" }, ] [package.dev-dependencies] @@ -770,10 +769,7 @@ dev = [ ] [package.metadata] -requires-dist = [ - { name = "httpx", specifier = "==0.28.*" }, - { name = "python-box", specifier = "==7.4.*" }, -] +requires-dist = [{ name = "httpx", specifier = "==0.28.*" }] [package.metadata.requires-dev] dev = [ @@ -1297,23 +1293,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] -[[package]] -name = "python-box" -version = "7.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/0f/34e7ee0a72f1464b4c7a2e8bafb389f230477256af586bc82bcfad85295a/python_box-7.4.1.tar.gz", hash = "sha256:e412e36c25fca8223560516d53ef6c7993591c3b0ec8bb4ec582bf7defdd79f0", size = 49859, upload-time = "2026-02-21T16:21:16.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/d9/d05f317b38b42253422d8483f5d7dc16d382c99ddc253e426639a0f2f235/python_box-7.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dfb91effff00d9e23486c4f0db3b19e03d602ebb7c9e20fc6a287c704fad2552", size = 1849441, upload-time = "2026-02-21T16:21:37.314Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a3/383eb3d658f36c6e531c8cf1e348ccb4b5031231df4aeb7742bb159a3166/python_box-7.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f977f00e715b030cee6ffef2322ff8ce100ffbf1dbcc4ef91099c75752d5f8", size = 4485153, upload-time = "2026-02-21T16:26:04.507Z" }, - { url = "https://files.pythonhosted.org/packages/65/f9/5de3c18415dd6f5286f00e6539c0ae3cceb1c6aaf28d1d5f17b0b568c97f/python_box-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:2ca9a18fd15326bc267e9cc7e0e6e3a0cb78d11507940f43f687adf7e156d882", size = 1295520, upload-time = "2026-02-21T16:22:26.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e9/48d1b1eb21efc3f82a31b037b6903c9139018f686d96d251faa4cb0d593a/python_box-7.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:85db37b43094bf6c4884b931fb149a7850db5ce331f6e191edf98b453e6cf2d6", size = 1845195, upload-time = "2026-02-21T16:21:46.235Z" }, - { url = "https://files.pythonhosted.org/packages/da/79/48d38c855f277223caf3aa79518476f95abc07f04386940855b7bd3d95f6/python_box-7.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb204822c7638bd2dbed5c55d6ab264c6903c37d18dee5c45bdbda58b2e1e17a", size = 4468245, upload-time = "2026-02-21T16:26:05.701Z" }, - { url = "https://files.pythonhosted.org/packages/17/1d/7a1e04f37674399e0f3076cfe1fa358f6a51540ae98299a06f2c0424c471/python_box-7.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:615da3fafd41572aec1b905832555c0ea08b6fbc27cc917356e257a9a5721af7", size = 1295564, upload-time = "2026-02-21T16:22:36.547Z" }, - { url = "https://files.pythonhosted.org/packages/94/a2/771b5e526bba2214ac2d30e321209a66680c40788616a45cf01005e95204/python_box-7.4.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:33c6701faa51fd87f0dcc538873c0fad2b3a1cc3750eab85835cd071cadf1948", size = 1875508, upload-time = "2026-02-21T16:21:37.432Z" }, - { url = "https://files.pythonhosted.org/packages/a6/5f/0e7ea7640ba60ff459ce37e340d816ac5e91b7a9a7c3c161f9dabe622be6/python_box-7.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:ae8c540a0457f52350211d24690211251912018e1e0c1857f50792729d6f562c", size = 1314304, upload-time = "2026-02-21T16:22:22.173Z" }, - { url = "https://files.pythonhosted.org/packages/06/a6/5d3f3abf46b37aa44b1f6788d287c8b4f2319b55013191dddf25b9e6d62c/python_box-7.4.1-py3-none-any.whl", hash = "sha256:a3b0d84d003882fb6abe505b1b883b3a5dcbf226b0fe168d24bc5ff75d9826e5", size = 30402, upload-time = "2026-02-21T16:21:14.78Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0"