Skip to content

Commit 052edfa

Browse files
authored
Merge pull request #132 from Exabyte-io/feature/SOF-7767
Feature/SOF-7767 Update: snake_case and camelCase properties
2 parents c8bcac4 + 13ad3c2 commit 052edfa

File tree

4 files changed

+125
-89
lines changed

4 files changed

+125
-89
lines changed

src/py/mat3ra/code/entity.py

Lines changed: 23 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,15 @@
11
from typing import Any, Dict, List, Optional, Type, TypeVar
22

3-
import jsonschema
4-
from mat3ra.utils import object as object_utils
53
from pydantic import BaseModel, ConfigDict
64
from pydantic.alias_generators import to_snake
75
from typing_extensions import Self
86

9-
from . import BaseUnderscoreJsonPropsHandler
107
from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin
118

129
T = TypeVar("T", bound="InMemoryEntityPydantic")
1310
B = TypeVar("B", bound="BaseModel")
1411

1512

16-
# TODO: remove in the next PR
17-
class ValidationErrorCode:
18-
IN_MEMORY_ENTITY_DATA_INVALID = "IN_MEMORY_ENTITY_DATA_INVALID"
19-
20-
21-
# TODO: remove in the next PR
22-
class ErrorDetails:
23-
def __init__(self, error: Optional[Dict[str, Any]], json: Dict[str, Any], schema: Dict):
24-
self.error = error
25-
self.json = json
26-
self.schema = schema
27-
28-
29-
# TODO: remove in the next PR
30-
class EntityError(Exception):
31-
def __init__(self, code: ValidationErrorCode, details: Optional[ErrorDetails] = None):
32-
super().__init__(code)
33-
self.code = code
34-
self.details = details
35-
36-
3713
class InMemoryEntityPydantic(BaseModel):
3814
model_config = {"arbitrary_types_allowed": True}
3915

@@ -90,82 +66,41 @@ def clone(self: T, extra_context: Optional[Dict[str, Any]] = None, deep=True) ->
9066
class InMemoryEntitySnakeCase(InMemoryEntityPydantic):
9167
model_config = ConfigDict(
9268
arbitrary_types_allowed=True,
69+
# Generate snake_case aliases for all fields (e.g. myField -> my_field)
9370
alias_generator=to_snake,
71+
# Allow populating fields using either the original name or the snake_case alias
9472
populate_by_name=True,
9573
)
9674

75+
@staticmethod
76+
def _create_property_from_camel_case(camel_name: str):
77+
def getter(self):
78+
return getattr(self, camel_name)
9779

98-
# TODO: remove in the next PR
99-
class InMemoryEntity(BaseUnderscoreJsonPropsHandler):
100-
jsonSchema: Optional[Dict] = None
101-
102-
@classmethod
103-
def get_cls(cls) -> str:
104-
return cls.__name__
105-
106-
@property
107-
def cls(self) -> str:
108-
return self.__class__.__name__
109-
110-
def get_cls_name(self) -> str:
111-
return self.__class__.__name__
112-
113-
@classmethod
114-
def create(cls, config: Dict[str, Any]) -> Any:
115-
return cls(config)
80+
def setter(self, value: Any):
81+
setattr(self, camel_name, value)
11682

117-
def to_json(self, exclude: List[str] = []) -> Dict[str, Any]:
118-
return self.clean(object_utils.clone_deep(object_utils.omit(self._json, exclude)))
83+
return property(getter, setter)
11984

120-
def clone(self, extra_context: Dict[str, Any] = {}) -> Any:
121-
config = self.to_json()
122-
config.update(extra_context)
123-
# To avoid:
124-
# Argument 1 to "__init__" of "BaseUnderscoreJsonPropsHandler" has incompatible type "Dict[str, Any]";
125-
# expected "BaseUnderscoreJsonPropsHandler"
126-
return self.__class__(config)
85+
def __init_subclass__(cls, **kwargs):
86+
super().__init_subclass__(**kwargs)
87+
if not issubclass(cls, BaseModel):
88+
return
12789

128-
@staticmethod
129-
def validate_data(data: Dict[str, Any], clean: bool = False):
130-
if clean:
131-
print("Error: clean is not supported for InMemoryEntity.validateData")
132-
if InMemoryEntity.jsonSchema:
133-
jsonschema.validate(data, InMemoryEntity.jsonSchema)
134-
135-
def validate(self) -> None:
136-
if self._json:
137-
self.__class__.validate_data(self._json)
138-
139-
def clean(self, config: Dict[str, Any]) -> Dict[str, Any]:
140-
# Not implemented, consider the below for the implementation
141-
# https://stackoverflow.com/questions/44694835/remove-properties-from-json-object-not-present-in-schema
142-
return config
143-
144-
def is_valid(self) -> bool:
14590
try:
146-
self.validate()
147-
return True
148-
except EntityError:
149-
return False
150-
151-
# Properties
152-
@property
153-
def id(self) -> str:
154-
return self.prop("_id", "")
91+
model_fields = cls.model_fields
92+
except Exception:
93+
return
15594

156-
@id.setter
157-
def id(self, id: str) -> None:
158-
self.set_prop("_id", id)
95+
for field_name, field_info in model_fields.items():
96+
if field_name == to_snake(field_name):
97+
continue
15998

160-
@property
161-
def slug(self) -> str:
162-
return self.prop("slug", "")
99+
snake_case_name = to_snake(field_name)
100+
if hasattr(cls, snake_case_name):
101+
continue
163102

164-
def get_as_entity_reference(self, by_id_only: bool = False) -> Dict[str, str]:
165-
if by_id_only:
166-
return {"_id": self.id}
167-
else:
168-
return {"_id": self.id, "slug": self.slug, "cls": self.get_cls_name()}
103+
setattr(cls, snake_case_name, cls._create_property_from_camel_case(field_name))
169104

170105

171106
class HasDescriptionHasMetadataNamedDefaultableInMemoryEntityPydantic(

tests/py/unit/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,14 @@ class SnakeCaseEntity(CamelCaseSchema, InMemoryEntitySnakeCase):
9898
"applicationVersion": "7.2",
9999
"executable_name": "pw.x",
100100
}
101+
102+
103+
class AutoSnakeCaseTestSchema(BaseModel):
104+
contextProviders: list = []
105+
applicationName: str
106+
applicationVersion: Optional[str] = None
107+
executableName: Optional[str] = None
108+
109+
110+
class AutoSnakeCaseTestEntity(AutoSnakeCaseTestSchema, InMemoryEntitySnakeCase):
111+
pass

tests/py/unit/test_entity.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,4 +244,3 @@ def test_create_entity_snake_case(config, expected_output):
244244

245245
entity_from_create = SnakeCaseEntity.create(config)
246246
assert entity_from_create.to_dict() == expected_output
247-
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import pytest
2+
from mat3ra.utils import assertion
3+
from . import AutoSnakeCaseTestEntity
4+
5+
BASE = {
6+
"applicationName": "camelCasedValue",
7+
"applicationVersion": "camelCasedVersion",
8+
"executableName": "camelCasedExecutable",
9+
"contextProviders": [],
10+
}
11+
12+
INSTANTIATION = [
13+
{"application_name": BASE["applicationName"], "application_version": BASE["applicationVersion"],
14+
"executable_name": BASE["executableName"]},
15+
{"applicationName": BASE["applicationName"], "applicationVersion": BASE["applicationVersion"],
16+
"executableName": BASE["executableName"]},
17+
{"application_name": BASE["applicationName"], "applicationVersion": BASE["applicationVersion"],
18+
"executable_name": BASE["executableName"]},
19+
]
20+
21+
UPDATES = [
22+
(
23+
{"application_name": "new_value", "context_providers": ["item_snake"]},
24+
{"applicationName": "new_value", "contextProviders": ["item_snake"]},
25+
{"application_name": "new_value", "context_providers": ["item_snake"]},
26+
),
27+
(
28+
{"applicationName": "newValueCamel", "contextProviders": ["itemCamel"]},
29+
{"applicationName": "newValueCamel", "contextProviders": ["itemCamel"]},
30+
{"application_name": "newValueCamel", "context_providers": ["itemCamel"]},
31+
),
32+
(
33+
{"application_name": "new_value_snake", "applicationVersion": "newVersionCamel"},
34+
{"applicationName": "new_value_snake", "applicationVersion": "newVersionCamel"},
35+
{"application_name": "new_value_snake", "application_version": "newVersionCamel"},
36+
),
37+
(
38+
{"application_name": "new_val", "application_version": "new_version",
39+
"executable_name": "new_exec", "context_providers": ["a", "b"]},
40+
{"applicationName": "new_val", "applicationVersion": "new_version",
41+
"executableName": "new_exec", "contextProviders": ["a", "b"]},
42+
{"application_name": "new_val", "application_version": "new_version",
43+
"executable_name": "new_exec", "context_providers": ["a", "b"]},
44+
),
45+
]
46+
47+
48+
def camel(entity):
49+
return dict(
50+
applicationName=entity.applicationName,
51+
applicationVersion=entity.applicationVersion,
52+
executableName=entity.executableName,
53+
contextProviders=entity.contextProviders,
54+
)
55+
56+
57+
def snake(entity):
58+
return dict(
59+
application_name=entity.application_name,
60+
application_version=entity.application_version,
61+
executable_name=entity.executable_name,
62+
context_providers=entity.context_providers,
63+
)
64+
65+
66+
@pytest.mark.parametrize("cfg", INSTANTIATION)
67+
def test_instantiation(cfg):
68+
entity = AutoSnakeCaseTestEntity(**cfg)
69+
assertion.assert_deep_almost_equal(BASE, camel(entity))
70+
assertion.assert_deep_almost_equal(
71+
dict(application_name=BASE["applicationName"],
72+
application_version=BASE["applicationVersion"],
73+
executable_name=BASE["executableName"],
74+
context_providers=[]),
75+
snake(entity),
76+
)
77+
78+
79+
@pytest.mark.parametrize("updates, exp_camel, exp_snake", UPDATES)
80+
def test_updates(updates, exp_camel, exp_snake):
81+
entity = AutoSnakeCaseTestEntity(**BASE)
82+
for k, v in updates.items():
83+
setattr(entity, k, v)
84+
assertion.assert_deep_almost_equal({**BASE, **exp_camel}, camel(entity))
85+
assertion.assert_deep_almost_equal(
86+
{**snake(AutoSnakeCaseTestEntity(**BASE)), **exp_snake},
87+
snake(entity),
88+
)
89+
out = entity.to_dict()
90+
assertion.assert_deep_almost_equal({**BASE, **exp_camel}, out)
91+
assert "application_name" not in out and "context_providers" not in out

0 commit comments

Comments
 (0)