From df2eb0f57d892a67eb23ea1bfba287cb60699327 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 3 Dec 2025 17:57:50 -0800 Subject: [PATCH 1/5] update: automatic camel -> snake properties --- src/py/mat3ra/code/entity.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 61eb6eab..0ed5a2aa 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -94,6 +94,35 @@ class InMemoryEntitySnakeCase(InMemoryEntityPydantic): populate_by_name=True, ) + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not issubclass(cls, BaseModel): + return + + try: + model_fields = cls.model_fields + except Exception: + return + + for field_name, field_info in model_fields.items(): + if field_name == to_snake(field_name): + continue + + snake_case_name = to_snake(field_name) + if hasattr(cls, snake_case_name): + continue + + def create_property(camel_name: str): + def getter(self): + return getattr(self, camel_name) + + def setter(self, value: Any): + setattr(self, camel_name, value) + + return property(getter, setter) + + setattr(cls, snake_case_name, create_property(field_name)) + # TODO: remove in the next PR class InMemoryEntity(BaseUnderscoreJsonPropsHandler): From 743010e90edd5fe1847bdf419d51a202b14e6f53 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 3 Dec 2025 18:11:59 -0800 Subject: [PATCH 2/5] update: test automatic camel -> snake properties --- tests/py/unit/__init__.py | 11 +++ tests/py/unit/test_entity.py | 1 - tests/py/unit/test_entity_snake_case.py | 91 +++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/py/unit/test_entity_snake_case.py diff --git a/tests/py/unit/__init__.py b/tests/py/unit/__init__.py index db5a3a21..cd12f9e5 100644 --- a/tests/py/unit/__init__.py +++ b/tests/py/unit/__init__.py @@ -98,3 +98,14 @@ class SnakeCaseEntity(CamelCaseSchema, InMemoryEntitySnakeCase): "applicationVersion": "7.2", "executable_name": "pw.x", } + + +class AutoSnakeCaseTestSchema(BaseModel): + contextProviders: list = [] + applicationName: str + applicationVersion: Optional[str] = None + executableName: Optional[str] = None + + +class AutoSnakeCaseTestEntity(AutoSnakeCaseTestSchema, InMemoryEntitySnakeCase): + pass diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index 1d9d73f9..9d6adab9 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -244,4 +244,3 @@ def test_create_entity_snake_case(config, expected_output): entity_from_create = SnakeCaseEntity.create(config) assert entity_from_create.to_dict() == expected_output - diff --git a/tests/py/unit/test_entity_snake_case.py b/tests/py/unit/test_entity_snake_case.py new file mode 100644 index 00000000..0a3249c2 --- /dev/null +++ b/tests/py/unit/test_entity_snake_case.py @@ -0,0 +1,91 @@ +import pytest +from mat3ra.utils import assertion +from . import AutoSnakeCaseTestEntity + +BASE = { + "applicationName": "camelCasedValue", + "applicationVersion": "camelCasedVersion", + "executableName": "camelCasedExecutable", + "contextProviders": [], +} + +INSTANTIATION = [ + {"application_name": BASE["applicationName"], "application_version": BASE["applicationVersion"], + "executable_name": BASE["executableName"]}, + {"applicationName": BASE["applicationName"], "applicationVersion": BASE["applicationVersion"], + "executableName": BASE["executableName"]}, + {"application_name": BASE["applicationName"], "applicationVersion": BASE["applicationVersion"], + "executable_name": BASE["executableName"]}, +] + +UPDATES = [ + ( + {"application_name": "new_value", "context_providers": ["item_snake"]}, + {"applicationName": "new_value", "contextProviders": ["item_snake"]}, + {"application_name": "new_value", "context_providers": ["item_snake"]}, + ), + ( + {"applicationName": "newValueCamel", "contextProviders": ["itemCamel"]}, + {"applicationName": "newValueCamel", "contextProviders": ["itemCamel"]}, + {"application_name": "newValueCamel", "context_providers": ["itemCamel"]}, + ), + ( + {"application_name": "new_value_snake", "applicationVersion": "newVersionCamel"}, + {"applicationName": "new_value_snake", "applicationVersion": "newVersionCamel"}, + {"application_name": "new_value_snake", "application_version": "newVersionCamel"}, + ), + ( + {"application_name": "new_val", "application_version": "new_version", + "executable_name": "new_exec", "context_providers": ["a", "b"]}, + {"applicationName": "new_val", "applicationVersion": "new_version", + "executableName": "new_exec", "contextProviders": ["a", "b"]}, + {"application_name": "new_val", "application_version": "new_version", + "executable_name": "new_exec", "context_providers": ["a", "b"]}, + ), +] + + +def camel(entity): + return dict( + applicationName=entity.applicationName, + applicationVersion=entity.applicationVersion, + executableName=entity.executableName, + contextProviders=entity.contextProviders, + ) + + +def snake(entity): + return dict( + application_name=entity.application_name, + application_version=entity.application_version, + executable_name=entity.executable_name, + context_providers=entity.context_providers, + ) + + +@pytest.mark.parametrize("cfg", INSTANTIATION) +def test_instantiation(cfg): + entity = AutoSnakeCaseTestEntity(**cfg) + assertion.assert_deep_almost_equal(BASE, camel(entity)) + assertion.assert_deep_almost_equal( + dict(application_name=BASE["applicationName"], + application_version=BASE["applicationVersion"], + executable_name=BASE["executableName"], + context_providers=[]), + snake(entity), + ) + + +@pytest.mark.parametrize("updates, exp_camel, exp_snake", UPDATES) +def test_updates(updates, exp_camel, exp_snake): + entity = AutoSnakeCaseTestEntity(**BASE) + for k, v in updates.items(): + setattr(entity, k, v) + assertion.assert_deep_almost_equal({**BASE, **exp_camel}, camel(entity)) + assertion.assert_deep_almost_equal( + {**snake(AutoSnakeCaseTestEntity(**BASE)), **exp_snake}, + snake(entity), + ) + out = entity.to_dict() + assertion.assert_deep_almost_equal({**BASE, **exp_camel}, out) + assert "application_name" not in out and "context_providers" not in out From 4628bc093427f0ba475916f1d3231256f5ae9e78 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 3 Dec 2025 18:12:13 -0800 Subject: [PATCH 3/5] update: cleanup --- src/py/mat3ra/code/entity.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 0ed5a2aa..5fb6eccf 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -94,6 +94,16 @@ class InMemoryEntitySnakeCase(InMemoryEntityPydantic): populate_by_name=True, ) + @staticmethod + def _create_property(camel_name: str): + def getter(self): + return getattr(self, camel_name) + + def setter(self, value: Any): + setattr(self, camel_name, value) + + return property(getter, setter) + def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if not issubclass(cls, BaseModel): @@ -112,16 +122,7 @@ def __init_subclass__(cls, **kwargs): if hasattr(cls, snake_case_name): continue - def create_property(camel_name: str): - def getter(self): - return getattr(self, camel_name) - - def setter(self, value: Any): - setattr(self, camel_name, value) - - return property(getter, setter) - - setattr(cls, snake_case_name, create_property(field_name)) + setattr(cls, snake_case_name, cls._create_property(field_name)) # TODO: remove in the next PR From f1f05f4e2f2ff9ca0195f77029e4303a0d7853cb Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 3 Dec 2025 18:28:36 -0800 Subject: [PATCH 4/5] update: execute cleanup todos --- src/py/mat3ra/code/entity.py | 97 ------------------------------------ 1 file changed, 97 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 5fb6eccf..47dbdcc8 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -1,39 +1,15 @@ from typing import Any, Dict, List, Optional, Type, TypeVar -import jsonschema -from mat3ra.utils import object as object_utils from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_snake from typing_extensions import Self -from . import BaseUnderscoreJsonPropsHandler from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin T = TypeVar("T", bound="InMemoryEntityPydantic") B = TypeVar("B", bound="BaseModel") -# TODO: remove in the next PR -class ValidationErrorCode: - IN_MEMORY_ENTITY_DATA_INVALID = "IN_MEMORY_ENTITY_DATA_INVALID" - - -# TODO: remove in the next PR -class ErrorDetails: - def __init__(self, error: Optional[Dict[str, Any]], json: Dict[str, Any], schema: Dict): - self.error = error - self.json = json - self.schema = schema - - -# TODO: remove in the next PR -class EntityError(Exception): - def __init__(self, code: ValidationErrorCode, details: Optional[ErrorDetails] = None): - super().__init__(code) - self.code = code - self.details = details - - class InMemoryEntityPydantic(BaseModel): model_config = {"arbitrary_types_allowed": True} @@ -125,79 +101,6 @@ def __init_subclass__(cls, **kwargs): setattr(cls, snake_case_name, cls._create_property(field_name)) -# TODO: remove in the next PR -class InMemoryEntity(BaseUnderscoreJsonPropsHandler): - jsonSchema: Optional[Dict] = None - - @classmethod - def get_cls(cls) -> str: - return cls.__name__ - - @property - def cls(self) -> str: - return self.__class__.__name__ - - def get_cls_name(self) -> str: - return self.__class__.__name__ - - @classmethod - def create(cls, config: Dict[str, Any]) -> Any: - return cls(config) - - def to_json(self, exclude: List[str] = []) -> Dict[str, Any]: - return self.clean(object_utils.clone_deep(object_utils.omit(self._json, exclude))) - - def clone(self, extra_context: Dict[str, Any] = {}) -> Any: - config = self.to_json() - config.update(extra_context) - # To avoid: - # Argument 1 to "__init__" of "BaseUnderscoreJsonPropsHandler" has incompatible type "Dict[str, Any]"; - # expected "BaseUnderscoreJsonPropsHandler" - return self.__class__(config) - - @staticmethod - def validate_data(data: Dict[str, Any], clean: bool = False): - if clean: - print("Error: clean is not supported for InMemoryEntity.validateData") - if InMemoryEntity.jsonSchema: - jsonschema.validate(data, InMemoryEntity.jsonSchema) - - def validate(self) -> None: - if self._json: - self.__class__.validate_data(self._json) - - def clean(self, config: Dict[str, Any]) -> Dict[str, Any]: - # Not implemented, consider the below for the implementation - # https://stackoverflow.com/questions/44694835/remove-properties-from-json-object-not-present-in-schema - return config - - def is_valid(self) -> bool: - try: - self.validate() - return True - except EntityError: - return False - - # Properties - @property - def id(self) -> str: - return self.prop("_id", "") - - @id.setter - def id(self, id: str) -> None: - self.set_prop("_id", id) - - @property - def slug(self) -> str: - return self.prop("slug", "") - - def get_as_entity_reference(self, by_id_only: bool = False) -> Dict[str, str]: - if by_id_only: - return {"_id": self.id} - else: - return {"_id": self.id, "slug": self.slug, "cls": self.get_cls_name()} - - class HasDescriptionHasMetadataNamedDefaultableInMemoryEntityPydantic( InMemoryEntityPydantic, DefaultableMixin, NamedMixin, HasMetadataMixin, HasDescriptionMixin ): From 13ad3c2dbce6f766defcb3a4da28933ba5d4a06a Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 3 Dec 2025 18:34:04 -0800 Subject: [PATCH 5/5] chore: add explanation --- src/py/mat3ra/code/entity.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 47dbdcc8..116668bf 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -66,12 +66,14 @@ def clone(self: T, extra_context: Optional[Dict[str, Any]] = None, deep=True) -> class InMemoryEntitySnakeCase(InMemoryEntityPydantic): model_config = ConfigDict( arbitrary_types_allowed=True, + # Generate snake_case aliases for all fields (e.g. myField -> my_field) alias_generator=to_snake, + # Allow populating fields using either the original name or the snake_case alias populate_by_name=True, ) @staticmethod - def _create_property(camel_name: str): + def _create_property_from_camel_case(camel_name: str): def getter(self): return getattr(self, camel_name) @@ -98,7 +100,7 @@ def __init_subclass__(cls, **kwargs): if hasattr(cls, snake_case_name): continue - setattr(cls, snake_case_name, cls._create_property(field_name)) + setattr(cls, snake_case_name, cls._create_property_from_camel_case(field_name)) class HasDescriptionHasMetadataNamedDefaultableInMemoryEntityPydantic(