From d13d35cb936839496b7860087f6fe0194a707ff9 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Thu, 6 Nov 2025 14:43:59 +0000 Subject: [PATCH 1/5] MPT-15233 Fix for snake-case to camel-case conversion - Disabled camel_killer_box - Renamed Model._resource_data to Model._box for better naming clarity - Added models representation --- mpt_api_client/models/model.py | 18 +++++++++++------- tests/unit/models/resource/test_resource.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 60b82e14..3382bba5 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -8,15 +8,15 @@ ResourceData = dict[str, Any] -class Model: +class Model: # noqa: WPS214 """Provides a resource to interact with api data using fluent interfaces.""" _data_key: ClassVar[str | None] = None - _safe_attributes: ClassVar[list[str]] = ["meta", "_resource_data"] + _safe_attributes: ClassVar[list[str]] = ["meta", "_box"] def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None: self.meta = meta - self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False) + self._box = Box(resource_data or {}, camel_killer_box=False, default_box=False) @classmethod def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: @@ -25,7 +25,7 @@ def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None def __getattr__(self, attribute: str) -> Box | Any: """Returns the resource data.""" - return self._resource_data.__getattr__(attribute) # type: ignore[no-untyped-call] + return self._box.__getattr__(attribute) # type: ignore[no-untyped-call] @override def __setattr__(self, attribute: str, attribute_value: Any) -> None: @@ -33,7 +33,7 @@ def __setattr__(self, attribute: str, attribute_value: Any) -> None: object.__setattr__(self, attribute, attribute_value) return - self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] + self._box.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] @classmethod def from_response(cls, response: Response) -> Self: @@ -55,8 +55,12 @@ def from_response(cls, response: Response) -> Self: @property def id(self) -> str: """Returns the resource ID.""" - return str(self._resource_data.get("id", "")) # type: ignore[no-untyped-call] + 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._resource_data.to_dict() + return self._box.to_dict() + + @override + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.id}>" # noqa: WPS237 diff --git a/tests/unit/models/resource/test_resource.py b/tests/unit/models/resource/test_resource.py index 314999bf..22a80136 100644 --- a/tests/unit/models/resource/test_resource.py +++ b/tests/unit/models/resource/test_resource.py @@ -70,3 +70,23 @@ def test_id_property_with_numeric_id(): assert resource.id == "1024" assert isinstance(resource.id, str) + + +def test_case_conversion(): + resource_data = {"id": "abc-123", "FullName": "Alice Smith"} + + resource = Model(resource_data) + + assert resource.FullName == "Alice Smith" + assert resource.to_dict() == resource_data + with pytest.raises(AttributeError): + resource.full_name # noqa: B018 + + +def test_repr(): + resource_data = {"id": "abc-123", "FullName": "Alice Smith"} + + resource = Model(resource_data) + + assert repr(resource) == "" + assert str(resource) == "" From b1cb96d32ae2cf2a2b85942af927798a4748e71b Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Fri, 7 Nov 2025 13:08:54 +0000 Subject: [PATCH 2/5] WIP Expanding python-box --- mpt_api_client/models/model.py | 48 ++++++++++++++++++++- tests/unit/models/resource/test_resource.py | 25 ++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 3382bba5..14a9c958 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -1,6 +1,7 @@ from typing import Any, ClassVar, Self, override from box import Box +from box.box import _camel_killer # type: ignore[attr-defined] # noqa: PLC2701 from mpt_api_client.http.types import Response from mpt_api_client.models.meta import Meta @@ -8,15 +9,60 @@ ResourceData = dict[str, Any] +class MptBox(Box): + """python-box that preserves camelCase keys when converted to json.""" + + def __init__(self, *args, key_mapping: dict[str, str] | None, **kwargs): # type: ignore[no-untyped-def] + super().__init__(*args, **kwargs) + key_mapping = key_mapping or {} + if self._box_config.get("key_mapping") is None: + self._box_config["key_mapping"] = key_mapping + else: + self._box_config.get("key_mapping").update(key_mapping) + + @override + def __setitem__(self, key, value): # type: ignore[no-untyped-def] # noqa: WPS110 + try: + mapped_key = self._box_config["key_mapping"][key] + except KeyError as error: + if key == "key_mapping" and "key_mapping" in self._box_config: + return + if error.args[0] == "key_mapping" and "key_mapping" not in self._box_config: + self._box_config["key_mapping"] = self._box_config.get("default_key_mappings", {}) + + mapped_key = _camel_killer(key) + self._box_config["key_mapping"][key] = mapped_key + super().__setitem__(mapped_key, value) # type: ignore[no-untyped-call] + + @override + def to_dict(self) -> dict[str, Any]: # noqa: WPS210 + reverse_mapping = { + mapped_key: original_key + for original_key, mapped_key in self._box_config.get("key_mapping", {}).items() + } + 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 + + class Model: # noqa: WPS214 """Provides a resource to interact with api data using fluent interfaces.""" _data_key: ClassVar[str | None] = None _safe_attributes: ClassVar[list[str]] = ["meta", "_box"] + _case_mappings: ClassVar[dict[str, str]] = {} def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None: self.meta = meta - self._box = Box(resource_data or {}, camel_killer_box=False, default_box=False) + self._box = MptBox( + resource_data or {}, + camel_killer_box=False, + default_box=False, + default_box_create_on_get=False, + key_mapping=self._case_mappings, + ) @classmethod def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: diff --git a/tests/unit/models/resource/test_resource.py b/tests/unit/models/resource/test_resource.py index 22a80136..3abcc418 100644 --- a/tests/unit/models/resource/test_resource.py +++ b/tests/unit/models/resource/test_resource.py @@ -77,10 +77,31 @@ def test_case_conversion(): resource = Model(resource_data) - assert resource.FullName == "Alice Smith" + assert resource.full_name == "Alice Smith" assert resource.to_dict() == resource_data with pytest.raises(AttributeError): - resource.full_name # noqa: B018 + _ = resource.FullName # noqa: WPS122 + + +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"}, + } + + resource = Model(resource_data) + resource.contact.StreetAddress = "123 Main St" + + assert resource.contact.full_name == "Alice Smith" + assert resource.contact.street_address == "123 Main St" + assert resource.to_dict() == expected_resource_data + + with pytest.raises(AttributeError): + _ = resource.contact.FullName # noqa: WPS122 + + with pytest.raises(AttributeError): + _ = resource.contact.StreetAddress # noqa: WPS122 def test_repr(): From d2cfacc053d493a6bfea9c021627da5e1658b02a Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Thu, 6 Nov 2025 14:43:59 +0000 Subject: [PATCH 3/5] MPT-15233 Fix for snake-case to camel-case conversion - Disabled camel_killer_box - Renamed Model._resource_data to Model._box for better naming clarity - Added models representation --- mpt_api_client/models/model.py | 18 +++++++++++------- tests/unit/models/resource/test_resource.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 60b82e14..3382bba5 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -8,15 +8,15 @@ ResourceData = dict[str, Any] -class Model: +class Model: # noqa: WPS214 """Provides a resource to interact with api data using fluent interfaces.""" _data_key: ClassVar[str | None] = None - _safe_attributes: ClassVar[list[str]] = ["meta", "_resource_data"] + _safe_attributes: ClassVar[list[str]] = ["meta", "_box"] def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None: self.meta = meta - self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False) + self._box = Box(resource_data or {}, camel_killer_box=False, default_box=False) @classmethod def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: @@ -25,7 +25,7 @@ def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None def __getattr__(self, attribute: str) -> Box | Any: """Returns the resource data.""" - return self._resource_data.__getattr__(attribute) # type: ignore[no-untyped-call] + return self._box.__getattr__(attribute) # type: ignore[no-untyped-call] @override def __setattr__(self, attribute: str, attribute_value: Any) -> None: @@ -33,7 +33,7 @@ def __setattr__(self, attribute: str, attribute_value: Any) -> None: object.__setattr__(self, attribute, attribute_value) return - self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] + self._box.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] @classmethod def from_response(cls, response: Response) -> Self: @@ -55,8 +55,12 @@ def from_response(cls, response: Response) -> Self: @property def id(self) -> str: """Returns the resource ID.""" - return str(self._resource_data.get("id", "")) # type: ignore[no-untyped-call] + 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._resource_data.to_dict() + return self._box.to_dict() + + @override + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.id}>" # noqa: WPS237 diff --git a/tests/unit/models/resource/test_resource.py b/tests/unit/models/resource/test_resource.py index 314999bf..22a80136 100644 --- a/tests/unit/models/resource/test_resource.py +++ b/tests/unit/models/resource/test_resource.py @@ -70,3 +70,23 @@ def test_id_property_with_numeric_id(): assert resource.id == "1024" assert isinstance(resource.id, str) + + +def test_case_conversion(): + resource_data = {"id": "abc-123", "FullName": "Alice Smith"} + + resource = Model(resource_data) + + assert resource.FullName == "Alice Smith" + assert resource.to_dict() == resource_data + with pytest.raises(AttributeError): + resource.full_name # noqa: B018 + + +def test_repr(): + resource_data = {"id": "abc-123", "FullName": "Alice Smith"} + + resource = Model(resource_data) + + assert repr(resource) == "" + assert str(resource) == "" From c0f6b5f4b6e02dd7ac8b1253b037addcee016fac Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Fri, 7 Nov 2025 13:08:54 +0000 Subject: [PATCH 4/5] WIP Expanding python-box --- mpt_api_client/models/model.py | 48 ++++++++++++++++++++- tests/unit/models/resource/test_resource.py | 25 ++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 3382bba5..14a9c958 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -1,6 +1,7 @@ from typing import Any, ClassVar, Self, override from box import Box +from box.box import _camel_killer # type: ignore[attr-defined] # noqa: PLC2701 from mpt_api_client.http.types import Response from mpt_api_client.models.meta import Meta @@ -8,15 +9,60 @@ ResourceData = dict[str, Any] +class MptBox(Box): + """python-box that preserves camelCase keys when converted to json.""" + + def __init__(self, *args, key_mapping: dict[str, str] | None, **kwargs): # type: ignore[no-untyped-def] + super().__init__(*args, **kwargs) + key_mapping = key_mapping or {} + if self._box_config.get("key_mapping") is None: + self._box_config["key_mapping"] = key_mapping + else: + self._box_config.get("key_mapping").update(key_mapping) + + @override + def __setitem__(self, key, value): # type: ignore[no-untyped-def] # noqa: WPS110 + try: + mapped_key = self._box_config["key_mapping"][key] + except KeyError as error: + if key == "key_mapping" and "key_mapping" in self._box_config: + return + if error.args[0] == "key_mapping" and "key_mapping" not in self._box_config: + self._box_config["key_mapping"] = self._box_config.get("default_key_mappings", {}) + + mapped_key = _camel_killer(key) + self._box_config["key_mapping"][key] = mapped_key + super().__setitem__(mapped_key, value) # type: ignore[no-untyped-call] + + @override + def to_dict(self) -> dict[str, Any]: # noqa: WPS210 + reverse_mapping = { + mapped_key: original_key + for original_key, mapped_key in self._box_config.get("key_mapping", {}).items() + } + 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 + + class Model: # noqa: WPS214 """Provides a resource to interact with api data using fluent interfaces.""" _data_key: ClassVar[str | None] = None _safe_attributes: ClassVar[list[str]] = ["meta", "_box"] + _case_mappings: ClassVar[dict[str, str]] = {} def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None = None) -> None: self.meta = meta - self._box = Box(resource_data or {}, camel_killer_box=False, default_box=False) + self._box = MptBox( + resource_data or {}, + camel_killer_box=False, + default_box=False, + default_box_create_on_get=False, + key_mapping=self._case_mappings, + ) @classmethod def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: diff --git a/tests/unit/models/resource/test_resource.py b/tests/unit/models/resource/test_resource.py index 22a80136..3abcc418 100644 --- a/tests/unit/models/resource/test_resource.py +++ b/tests/unit/models/resource/test_resource.py @@ -77,10 +77,31 @@ def test_case_conversion(): resource = Model(resource_data) - assert resource.FullName == "Alice Smith" + assert resource.full_name == "Alice Smith" assert resource.to_dict() == resource_data with pytest.raises(AttributeError): - resource.full_name # noqa: B018 + _ = resource.FullName # noqa: WPS122 + + +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"}, + } + + resource = Model(resource_data) + resource.contact.StreetAddress = "123 Main St" + + assert resource.contact.full_name == "Alice Smith" + assert resource.contact.street_address == "123 Main St" + assert resource.to_dict() == expected_resource_data + + with pytest.raises(AttributeError): + _ = resource.contact.FullName # noqa: WPS122 + + with pytest.raises(AttributeError): + _ = resource.contact.StreetAddress # noqa: WPS122 def test_repr(): From af25f9c3f751a284a35fa5be53b285e8ca05dfdb Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Mon, 10 Nov 2025 11:16:33 +0000 Subject: [PATCH 5/5] Expanding python-box --- mpt_api_client/models/model.py | 75 +++++++++++-------- setup.cfg | 1 + .../{test_resource.py => test_model.py} | 18 +++++ ...custom_key.py => test_model_custom_key.py} | 0 4 files changed, 63 insertions(+), 31 deletions(-) rename tests/unit/models/resource/{test_resource.py => test_model.py} (85%) rename tests/unit/models/resource/{test_resource_custom_key.py => test_model_custom_key.py} (100%) diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 14a9c958..321a63c0 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -8,37 +8,45 @@ ResourceData = dict[str, Any] +_box_safe_attributes: list[str] = ["_box_config", "_attribute_mapping"] + class MptBox(Box): """python-box that preserves camelCase keys when converted to json.""" - def __init__(self, *args, key_mapping: dict[str, str] | None, **kwargs): # type: ignore[no-untyped-def] - super().__init__(*args, **kwargs) - key_mapping = key_mapping or {} - if self._box_config.get("key_mapping") is None: - self._box_config["key_mapping"] = key_mapping - else: - self._box_config.get("key_mapping").update(key_mapping) + 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, + ) @override - def __setitem__(self, key, value): # type: ignore[no-untyped-def] # noqa: WPS110 - try: - mapped_key = self._box_config["key_mapping"][key] - except KeyError as error: - if key == "key_mapping" and "key_mapping" in self._box_config: - return - if error.args[0] == "key_mapping" and "key_mapping" not in self._box_config: - self._box_config["key_mapping"] = self._box_config.get("default_key_mappings", {}) - - mapped_key = _camel_killer(key) - self._box_config["key_mapping"][key] = mapped_key + 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] + @override + def __setattr__(self, item: str, value: Any) -> None: + if item in _box_safe_attributes: + return object.__setattr__(self, item, value) + + super().__setattr__(item, value) # type: ignore[no-untyped-call] + return None + + @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] + @override def to_dict(self) -> dict[str, Any]: # noqa: WPS210 reverse_mapping = { - mapped_key: original_key - for original_key, mapped_key in self._box_config.get("key_mapping", {}).items() + mapped_key: original_key for original_key, mapped_key in self._attribute_mapping.items() } out_dict = {} for parsed_key, item_value in super().to_dict().items(): @@ -46,24 +54,33 @@ def to_dict(self) -> dict[str, Any]: # noqa: WPS210 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] + class Model: # noqa: WPS214 """Provides a resource to interact with api data using fluent interfaces.""" _data_key: ClassVar[str | None] = None _safe_attributes: ClassVar[list[str]] = ["meta", "_box"] - _case_mappings: ClassVar[dict[str, str]] = {} + _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 {}, - camel_killer_box=False, - default_box=False, - default_box_create_on_get=False, - key_mapping=self._case_mappings, + attribute_mapping=self._attribute_mapping, ) + @override + def __repr__(self) -> str: + class_name = self.__class__.__name__ + return f"<{class_name} {self.id}>" + @classmethod def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: """Creates a new resource from ResourceData and Meta.""" @@ -71,7 +88,7 @@ def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None def __getattr__(self, attribute: str) -> Box | Any: """Returns the resource data.""" - return self._box.__getattr__(attribute) # type: ignore[no-untyped-call] + return self._box.__getattr__(attribute) @override def __setattr__(self, attribute: str, attribute_value: Any) -> None: @@ -79,7 +96,7 @@ def __setattr__(self, attribute: str, attribute_value: Any) -> None: object.__setattr__(self, attribute, attribute_value) return - self._box.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] + self._box.__setattr__(attribute, attribute_value) @classmethod def from_response(cls, response: Response) -> Self: @@ -106,7 +123,3 @@ def id(self) -> str: def to_dict(self) -> dict[str, Any]: """Returns the resource as a dictionary.""" return self._box.to_dict() - - @override - def __repr__(self) -> str: - return f"<{self.__class__.__name__} {self.id}>" # noqa: WPS237 diff --git a/setup.cfg b/setup.cfg index 25dcf8b0..766345c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,6 +35,7 @@ per-file-ignores = mpt_api_client/mpt_client.py: WPS214 WPS235 mpt_api_client/http/mixins.py: WPS202 mpt_api_client/resources/*: WPS215 + mpt_api_client/models/model.py: WPS215 WPS110 mpt_api_client/resources/accounts/*.py: WPS202 WPS215 WPS214 mpt_api_client/resources/billing/*.py: WPS202 WPS204 WPS214 WPS215 mpt_api_client/resources/catalog/*.py: WPS110 WPS214 WPS215 diff --git a/tests/unit/models/resource/test_resource.py b/tests/unit/models/resource/test_model.py similarity index 85% rename from tests/unit/models/resource/test_resource.py rename to tests/unit/models/resource/test_model.py index 3abcc418..52ace063 100644 --- a/tests/unit/models/resource/test_resource.py +++ b/tests/unit/models/resource/test_model.py @@ -1,3 +1,5 @@ +from typing import ClassVar + import pytest from httpx import Response @@ -111,3 +113,19 @@ def test_repr(): assert repr(resource) == "" assert str(resource) == "" + + +def test_mapping(): + class MappingModel(Model): # noqa: WPS431 + _attribute_mapping: ClassVar[dict[str, str]] = { + "second_id": "resource_id", + "Full_Name": "name", + } + + resource_data = {"id": "abc-123", "second_id": "resource-abc-123", "Full_Name": "Alice Smith"} + + resource = MappingModel(resource_data) + + assert resource.name == "Alice Smith" + assert resource.resource_id == "resource-abc-123" + assert resource.to_dict() == resource_data diff --git a/tests/unit/models/resource/test_resource_custom_key.py b/tests/unit/models/resource/test_model_custom_key.py similarity index 100% rename from tests/unit/models/resource/test_resource_custom_key.py rename to tests/unit/models/resource/test_model_custom_key.py