From 090f03ea5045aa6e11685469a837ed184b03ad4d Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 26 Mar 2025 20:49:39 -0700 Subject: [PATCH 01/41] update: start using pydantic --- pyproject.toml | 1 + src/py/mat3ra/code/entity.py | 104 +++++++++++++++++++---------------- 2 files changed, 59 insertions(+), 46 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a3ca1048..a053dac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ # add requirements here "numpy", "jsonschema>=2.6.0", + "pydantic>=2.10.5", "mat3ra-utils>=2024.5.15.post0", ] diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 20fd0601..7d60443a 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -1,11 +1,11 @@ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, TypeVar import jsonschema -from mat3ra.utils import object as object_utils +from pydantic import BaseModel, Field -from . import BaseUnderscoreJsonPropsHandler from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin +T = TypeVar("T", bound="InMemoryEntity") class ValidationErrorCode: IN_MEMORY_ENTITY_DATA_INVALID = "IN_MEMORY_ENTITY_DATA_INVALID" @@ -25,51 +25,78 @@ def __init__(self, code: ValidationErrorCode, details: Optional[ErrorDetails] = self.details = details -class InMemoryEntity(BaseUnderscoreJsonPropsHandler): - jsonSchema: Optional[Dict] = None +class InMemoryEntity(BaseModel): + jsonSchema: Optional[Dict[str, Any]] = Field(default=None, exclude=True) + + class Config: + arbitrary_types_allowed = True + + # --- Identity and Meta --- @classmethod def get_cls(cls) -> str: return cls.__name__ + def get_cls_name(self) -> str: + return self.__class__.__name__ + @property def cls(self) -> str: return self.__class__.__name__ - def get_cls_name(self) -> str: - return self.__class__.__name__ + @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]: + base = {"_id": self.id} + if not by_id_only: + base.update({"slug": self.slug, "cls": self.get_cls_name()}) + return base + + # --- Serialization --- + def to_dict(self, exclude: Optional[List[str]] = None) -> Dict[str, Any]: + return self.model_dump(exclude=set(exclude) if exclude else None) + + def to_json(self, exclude: Optional[List[str]] = None) -> str: + return self.model_dump_json(exclude=set(exclude) if exclude else None) + # --- Instantiation --- @classmethod - def create(cls, config: Dict[str, Any]) -> Any: - return cls(config) + def create(cls: type[T], config: Dict[str, Any]) -> T: + 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: T, extra_context: Optional[Dict[str, Any]] = None) -> T: + """Return a copy of this entity with optional updated fields.""" + extra_context = extra_context or {} + return self.model_copy(update=extra_context) - 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) + # --- Validation --- + def validate(self) -> None: + if self._json and self.jsonSchema: + try: + jsonschema.validate(self._json, self.jsonSchema) + except jsonschema.exceptions.ValidationError as err: + raise EntityError( + ValidationErrorCode.IN_MEMORY_ENTITY_DATA_INVALID, + ErrorDetails(error=err, json=self._json, schema=self.jsonSchema), + ) @staticmethod def validate_data(data: Dict[str, Any], clean: bool = False): if clean: - print("Error: clean is not supported for InMemoryEntity.validateData") + raise NotImplementedError("clean=True not supported yet.") 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() @@ -77,24 +104,9 @@ def is_valid(self) -> bool: 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()} + def clean(self, config: Dict[str, Any]) -> Dict[str, Any]: + # TODO: implement if needed, or use model_rebuild if schema evolves + return config class HasDescriptionHasMetadataNamedDefaultableInMemoryEntity( From 9eea9862cfb17a4e9ec853924180b4ca4d49253c Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 27 Mar 2025 13:33:28 -0700 Subject: [PATCH 02/41] update: purge unnessesary + adjust tests --- src/py/mat3ra/code/entity.py | 84 ++++++------------------------------ tests/py/unit/test_entity.py | 22 ++++++---- 2 files changed, 25 insertions(+), 81 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 7d60443a..a083779a 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -1,38 +1,16 @@ from typing import Any, Dict, List, Optional, TypeVar import jsonschema -from pydantic import BaseModel, Field +from pydantic import BaseModel from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin T = TypeVar("T", bound="InMemoryEntity") -class ValidationErrorCode: - IN_MEMORY_ENTITY_DATA_INVALID = "IN_MEMORY_ENTITY_DATA_INVALID" - - -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 - - -class EntityError(Exception): - def __init__(self, code: ValidationErrorCode, details: Optional[ErrorDetails] = None): - super().__init__(code) - self.code = code - self.details = details - - - class InMemoryEntity(BaseModel): - jsonSchema: Optional[Dict[str, Any]] = Field(default=None, exclude=True) - class Config: arbitrary_types_allowed = True - # --- Identity and Meta --- @classmethod def get_cls(cls) -> str: return cls.__name__ @@ -40,73 +18,35 @@ def get_cls(cls) -> str: def get_cls_name(self) -> str: return self.__class__.__name__ - @property - def cls(self) -> str: - return self.__class__.__name__ - @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", "") + return self.model_fields.get("_id", {}).get("default", "") def get_as_entity_reference(self, by_id_only: bool = False) -> Dict[str, str]: base = {"_id": self.id} if not by_id_only: - base.update({"slug": self.slug, "cls": self.get_cls_name()}) + base.update({"cls": self.get_cls_name()}) return base - # --- Serialization --- def to_dict(self, exclude: Optional[List[str]] = None) -> Dict[str, Any]: return self.model_dump(exclude=set(exclude) if exclude else None) def to_json(self, exclude: Optional[List[str]] = None) -> str: return self.model_dump_json(exclude=set(exclude) if exclude else None) - # --- Instantiation --- @classmethod def create(cls: type[T], config: Dict[str, Any]) -> T: - return cls(**config) + return cls.model_validate(config) + + @classmethod + def from_json(cls: type[T], json_str: str) -> T: + return cls.model_validate_json(json_str) def clone(self: T, extra_context: Optional[Dict[str, Any]] = None) -> T: - """Return a copy of this entity with optional updated fields.""" - extra_context = extra_context or {} - return self.model_copy(update=extra_context) - - # --- Validation --- - def validate(self) -> None: - if self._json and self.jsonSchema: - try: - jsonschema.validate(self._json, self.jsonSchema) - except jsonschema.exceptions.ValidationError as err: - raise EntityError( - ValidationErrorCode.IN_MEMORY_ENTITY_DATA_INVALID, - ErrorDetails(error=err, json=self._json, schema=self.jsonSchema), - ) - - @staticmethod - def validate_data(data: Dict[str, Any], clean: bool = False): - if clean: - raise NotImplementedError("clean=True not supported yet.") - if InMemoryEntity.jsonSchema: - jsonschema.validate(data, InMemoryEntity.jsonSchema) - - def is_valid(self) -> bool: - try: - self.validate() - return True - except EntityError: - return False - - def clean(self, config: Dict[str, Any]) -> Dict[str, Any]: - # TODO: implement if needed, or use model_rebuild if schema evolves - return config + return self.model_copy(update=extra_context or {}) + + def validate_against_schema(self, schema: Dict[str, Any]) -> None: + jsonschema.validate(self.to_dict(), schema) class HasDescriptionHasMetadataNamedDefaultableInMemoryEntity( diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index 30d19740..eda1a2e0 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -2,23 +2,27 @@ REFERENCE_OBJECT_1 = {"key1": "value1", "key2": "value2"} +class ChildClass(InMemoryEntity): + key1: str + key2: str + +child_object = ChildClass(**REFERENCE_OBJECT_1) def test_create(): in_memory_entity = InMemoryEntity.create({}) assert isinstance(in_memory_entity, InMemoryEntity) -def test_get_prop(): - in_memory_entity = InMemoryEntity.create(REFERENCE_OBJECT_1) - assert in_memory_entity.get_prop("key1") == "value1" +def test_subclass_fields(): + assert child_object.key1 == "value1" + assert child_object.key2 == "value2" -def test_set_prop(): - in_memory_entity = InMemoryEntity.create(REFERENCE_OBJECT_1) - in_memory_entity.set_prop("key3", "value3") - assert in_memory_entity.get_prop("key3") == "value3" +def test_to_dict(): + entity = child_object.create(REFERENCE_OBJECT_1) + assert entity.to_dict() == REFERENCE_OBJECT_1 def test_to_json(): - in_memory_entity = InMemoryEntity.create({}) - assert in_memory_entity.to_json() == {} + entity = child_object.create(REFERENCE_OBJECT_1) + assert entity.to_json() == '{"key1":"value1","key2":"value2"}' From 9bba746ff20400f3fdf81792149fea78aaf9d9cd Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 27 Mar 2025 14:03:02 -0700 Subject: [PATCH 03/41] update: bump python to 3.10+ --- .github/workflows/cicd.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index bdefa79d..59c136e7 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: [3.8.6] + python-version: [3.10.13] steps: - name: Checkout this repository @@ -37,10 +37,9 @@ jobs: strategy: matrix: python-version: - - 3.8.x - - 3.9.x - 3.10.x - 3.11.x + - 3.12.x steps: - name: Checkout this repository From 5087b446d83236f54d0e81c5ebcd9fec781eb592 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 27 Mar 2025 15:44:26 -0700 Subject: [PATCH 04/41] chore: use v2 syntax --- src/py/mat3ra/code/entity.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index a083779a..aaf67aca 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -7,9 +7,11 @@ T = TypeVar("T", bound="InMemoryEntity") + class InMemoryEntity(BaseModel): - class Config: - arbitrary_types_allowed = True + model_config = { + "arbitrary_types_allowed": True + } @classmethod def get_cls(cls) -> str: @@ -22,12 +24,6 @@ def get_cls_name(self) -> str: def id(self) -> str: return self.model_fields.get("_id", {}).get("default", "") - def get_as_entity_reference(self, by_id_only: bool = False) -> Dict[str, str]: - base = {"_id": self.id} - if not by_id_only: - base.update({"cls": self.get_cls_name()}) - return base - def to_dict(self, exclude: Optional[List[str]] = None) -> Dict[str, Any]: return self.model_dump(exclude=set(exclude) if exclude else None) From e65f91dcb0668cd7fb27ae431fb5da3713f1fa02 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 27 Mar 2025 20:15:06 -0700 Subject: [PATCH 05/41] update: use esse and pydantic --- src/py/mat3ra/code/entity.py | 115 +++++++++++++++++++++++--- src/py/mat3ra/code/mixins/__init__.py | 59 ++++--------- 2 files changed, 117 insertions(+), 57 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index aaf67aca..744d8b90 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -1,14 +1,39 @@ from typing import Any, Dict, List, Optional, TypeVar -import jsonschema from pydantic import BaseModel from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin -T = TypeVar("T", bound="InMemoryEntity") +import jsonschema +from mat3ra.utils import object as object_utils + +from . import BaseUnderscoreJsonPropsHandler + +T = TypeVar("T", bound="InMemoryEntityPydantic") + + +# 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 -class InMemoryEntity(BaseModel): +# 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 } @@ -20,10 +45,6 @@ def get_cls(cls) -> str: def get_cls_name(self) -> str: return self.__class__.__name__ - @property - def id(self) -> str: - return self.model_fields.get("_id", {}).get("default", "") - def to_dict(self, exclude: Optional[List[str]] = None) -> Dict[str, Any]: return self.model_dump(exclude=set(exclude) if exclude else None) @@ -32,7 +53,7 @@ def to_json(self, exclude: Optional[List[str]] = None) -> str: @classmethod def create(cls: type[T], config: Dict[str, Any]) -> T: - return cls.model_validate(config) + return cls(**config) @classmethod def from_json(cls: type[T], json_str: str) -> T: @@ -41,11 +62,81 @@ def from_json(cls: type[T], json_str: str) -> T: def clone(self: T, extra_context: Optional[Dict[str, Any]] = None) -> T: return self.model_copy(update=extra_context or {}) - def validate_against_schema(self, schema: Dict[str, Any]) -> None: - jsonschema.validate(self.to_dict(), schema) + +# 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 HasDescriptionHasMetadataNamedDefaultableInMemoryEntity( - InMemoryEntity, DefaultableMixin, NamedMixin, HasMetadataMixin, HasDescriptionMixin +class HasDescriptionHasMetadataNamedDefaultableInMemoryEntityPydantic( + InMemoryEntityPydantic, DefaultableMixin, NamedMixin, HasMetadataMixin, HasDescriptionMixin ): pass diff --git a/src/py/mat3ra/code/mixins/__init__.py b/src/py/mat3ra/code/mixins/__init__.py index fb07844e..2b737730 100644 --- a/src/py/mat3ra/code/mixins/__init__.py +++ b/src/py/mat3ra/code/mixins/__init__.py @@ -1,57 +1,26 @@ -from typing import Any, Dict +from typing import Any, Dict, Optional -from .. import BaseUnderscoreJsonPropsHandler +from mat3ra.esse.models.system.description import DescriptionSchema +from mat3ra.esse.models.system.metadata import MetadataSchema +from pydantic import BaseModel +from mat3ra.esse.models.system.defaultable import DefaultableEntitySchema -class DefaultableMixin(BaseUnderscoreJsonPropsHandler): - __default_config__: Dict[str, Any] - - @property - def is_default(self) -> bool: - return self.get_prop("isDefault", False) - - @is_default.setter - def is_default(self, is_default: bool = False) -> None: - self.set_prop("isDefault", is_default) +class DefaultableMixin(DefaultableEntitySchema): + __default_config__: [Dict[str, Any]] = {} @classmethod def create_default(cls) -> "DefaultableMixin": - return cls(cls.__default_config__) - - -class NamedMixin(BaseUnderscoreJsonPropsHandler): - @property - def name(self) -> str: - return self.get_prop("name", False) - - @name.setter - def name(self, name: str = "") -> None: - self.set_prop("name", name) - - -class HasMetadataMixin(BaseUnderscoreJsonPropsHandler): - @property - def metadata(self) -> Dict: - return self.get_prop("metadata", False) + return cls.model_validate(cls.__default_config__) - @metadata.setter - def metadata(self, metadata: Dict = {}) -> None: - self.set_prop("metadata", metadata) +class NamedMixin(BaseModel): + name: Optional[str] = None -class HasDescriptionMixin(BaseUnderscoreJsonPropsHandler): - @property - def description(self) -> str: - return self.get_prop("description", "") - @description.setter - def description(self, description: str = "") -> None: - self.set_prop("description", description) +class HasMetadataMixin(MetadataSchema): + pass - @property - def description_object(self) -> str: - return self.get_prop("descriptionObject", "") - @description_object.setter - def description_object(self, description_object: str = "") -> None: - self.set_prop("descriptionObject", description_object) +class HasDescriptionMixin(DescriptionSchema): + pass From e18809e757b7e4ba8399b513db8d7984f195a8d0 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 27 Mar 2025 20:15:24 -0700 Subject: [PATCH 06/41] chore: import esse from github --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a053dac5..cbd4978c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ dynamic = ["version"] description = "COre DEfinitions." readme = "README.md" requires-python = ">=3.8" -license = {file = "LICENSE.md"} +license = { file = "LICENSE.md" } authors = [ - {name = "Exabyte Inc.", email = "info@mat3ra.com"} + { name = "Exabyte Inc.", email = "info@mat3ra.com" } ] classifiers = [ "Programming Language :: Python", @@ -19,6 +19,7 @@ dependencies = [ "numpy", "jsonschema>=2.6.0", "pydantic>=2.10.5", + "mat3ra-esse @ git+https://github.com/Exabyte-io/esse.git@refs/pull/325/head", "mat3ra-utils>=2024.5.15.post0", ] From c253b0bad84c5ad1a03c49855b118586a609b110 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 27 Mar 2025 20:15:39 -0700 Subject: [PATCH 07/41] chore: adjsut test import --- tests/py/unit/test_entity.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index eda1a2e0..60622385 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -1,16 +1,19 @@ -from mat3ra.code.entity import InMemoryEntity +from mat3ra.code.entity import InMemoryEntityPydantic REFERENCE_OBJECT_1 = {"key1": "value1", "key2": "value2"} -class ChildClass(InMemoryEntity): + +class ChildClass(InMemoryEntityPydantic): key1: str key2: str + child_object = ChildClass(**REFERENCE_OBJECT_1) + def test_create(): - in_memory_entity = InMemoryEntity.create({}) - assert isinstance(in_memory_entity, InMemoryEntity) + in_memory_entity = InMemoryEntityPydantic.create({}) + assert isinstance(in_memory_entity, InMemoryEntityPydantic) def test_subclass_fields(): From 775df4bba7f2266bb44b9dd8b5e87db46f568127 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 27 Mar 2025 22:50:56 -0700 Subject: [PATCH 08/41] update: use property --- src/py/mat3ra/code/mixins/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/mat3ra/code/mixins/__init__.py b/src/py/mat3ra/code/mixins/__init__.py index 2b737730..c19d89b8 100644 --- a/src/py/mat3ra/code/mixins/__init__.py +++ b/src/py/mat3ra/code/mixins/__init__.py @@ -7,11 +7,11 @@ class DefaultableMixin(DefaultableEntitySchema): - __default_config__: [Dict[str, Any]] = {} + default_config: [Dict[str, Any]] = {} @classmethod def create_default(cls) -> "DefaultableMixin": - return cls.model_validate(cls.__default_config__) + return cls(**cls.default_config) class NamedMixin(BaseModel): From 2d3dda5f018172d0953401f223433eec8702c49f Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 10:46:59 -0700 Subject: [PATCH 09/41] chore: fix a typo --- src/py/mat3ra/code/mixins/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/mat3ra/code/mixins/__init__.py b/src/py/mat3ra/code/mixins/__init__.py index c19d89b8..437d5683 100644 --- a/src/py/mat3ra/code/mixins/__init__.py +++ b/src/py/mat3ra/code/mixins/__init__.py @@ -7,7 +7,7 @@ class DefaultableMixin(DefaultableEntitySchema): - default_config: [Dict[str, Any]] = {} + default_config: Dict[str, Any] = {} @classmethod def create_default(cls) -> "DefaultableMixin": From 91e04bc830a1b72b53af87ab1a5d29c05eb5e101 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 12:37:50 -0700 Subject: [PATCH 10/41] chore: fix typing --- src/py/mat3ra/code/mixins/__init__.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/py/mat3ra/code/mixins/__init__.py b/src/py/mat3ra/code/mixins/__init__.py index 437d5683..94c51715 100644 --- a/src/py/mat3ra/code/mixins/__init__.py +++ b/src/py/mat3ra/code/mixins/__init__.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, ClassVar from mat3ra.esse.models.system.description import DescriptionSchema from mat3ra.esse.models.system.metadata import MetadataSchema @@ -7,11 +7,17 @@ class DefaultableMixin(DefaultableEntitySchema): - default_config: Dict[str, Any] = {} + __default_config__: ClassVar[Dict[str, Any]] = {} + + @property + def default_config(self) -> Dict[str, Any]: + return self.__default_config__ @classmethod - def create_default(cls) -> "DefaultableMixin": - return cls(**cls.default_config) + def create_default(cls) -> "DefaultablePydanticMixin": + instance = cls(**cls.__default_config__) + instance.isDefault = True + return instance class NamedMixin(BaseModel): From c7bb81ff699ea5f70f1af6506292d58d5082cff4 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 12:41:13 -0700 Subject: [PATCH 11/41] chore: fix typing 2 --- src/py/mat3ra/code/entity.py | 16 ++++++---------- src/py/mat3ra/code/mixins/__init__.py | 6 +++--- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 744d8b90..e824904a 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -1,13 +1,11 @@ -from typing import Any, Dict, List, Optional, TypeVar - -from pydantic import BaseModel - -from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin +from typing import Any, Dict, List, Optional, Type, TypeVar import jsonschema from mat3ra.utils import object as object_utils +from pydantic import BaseModel from . import BaseUnderscoreJsonPropsHandler +from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin T = TypeVar("T", bound="InMemoryEntityPydantic") @@ -34,9 +32,7 @@ def __init__(self, code: ValidationErrorCode, details: Optional[ErrorDetails] = class InMemoryEntityPydantic(BaseModel): - model_config = { - "arbitrary_types_allowed": True - } + model_config = {"arbitrary_types_allowed": True} @classmethod def get_cls(cls) -> str: @@ -52,11 +48,11 @@ def to_json(self, exclude: Optional[List[str]] = None) -> str: return self.model_dump_json(exclude=set(exclude) if exclude else None) @classmethod - def create(cls: type[T], config: Dict[str, Any]) -> T: + def create(cls: Type[T], config: Dict[str, Any]) -> T: return cls(**config) @classmethod - def from_json(cls: type[T], json_str: str) -> T: + def from_json(cls: Type[T], json_str: str) -> T: return cls.model_validate_json(json_str) def clone(self: T, extra_context: Optional[Dict[str, Any]] = None) -> T: diff --git a/src/py/mat3ra/code/mixins/__init__.py b/src/py/mat3ra/code/mixins/__init__.py index 94c51715..82efe59e 100644 --- a/src/py/mat3ra/code/mixins/__init__.py +++ b/src/py/mat3ra/code/mixins/__init__.py @@ -1,9 +1,9 @@ -from typing import Any, Dict, Optional, ClassVar +from typing import Any, ClassVar, Dict, Optional +from mat3ra.esse.models.system.defaultable import DefaultableEntitySchema from mat3ra.esse.models.system.description import DescriptionSchema from mat3ra.esse.models.system.metadata import MetadataSchema from pydantic import BaseModel -from mat3ra.esse.models.system.defaultable import DefaultableEntitySchema class DefaultableMixin(DefaultableEntitySchema): @@ -14,7 +14,7 @@ def default_config(self) -> Dict[str, Any]: return self.__default_config__ @classmethod - def create_default(cls) -> "DefaultablePydanticMixin": + def create_default(cls) -> "DefaultableMixin": instance = cls(**cls.__default_config__) instance.isDefault = True return instance From 35434f0c621a9534af6e886f0605f6340c57d8e8 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 13:55:54 -0700 Subject: [PATCH 12/41] update: restructure methods + tests --- src/py/mat3ra/code/entity.py | 27 +++++++++++------- tests/py/unit/test_entity.py | 53 +++++++++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index e824904a..9fe63b8b 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -1,4 +1,5 @@ from typing import Any, Dict, List, Optional, Type, TypeVar +from typing_extensions import Self import jsonschema from mat3ra.utils import object as object_utils @@ -35,8 +36,22 @@ class InMemoryEntityPydantic(BaseModel): model_config = {"arbitrary_types_allowed": True} @classmethod - def get_cls(cls) -> str: - return cls.__name__ + def create(cls: Type[T], config: Dict[str, Any]) -> T: + validated_data = cls.clean(config) + return cls(**validated_data) + + @classmethod + def validate(cls, value: Any) -> Self: + return cls.model_validate(value) + + @classmethod + def from_json(cls: Type[T], json_str: str) -> T: + return cls.model_validate_json(json_str) + + @classmethod + def clean(cls: Type[T], config: Dict[str, Any]) -> Dict[str, Any]: + validated_model = cls.validated_model_validate(config) + return validated_model.model_dump() def get_cls_name(self) -> str: return self.__class__.__name__ @@ -47,14 +62,6 @@ def to_dict(self, exclude: Optional[List[str]] = None) -> Dict[str, Any]: def to_json(self, exclude: Optional[List[str]] = None) -> str: return self.model_dump_json(exclude=set(exclude) if exclude else None) - @classmethod - def create(cls: Type[T], config: Dict[str, Any]) -> T: - return cls(**config) - - @classmethod - def from_json(cls: Type[T], json_str: str) -> T: - return cls.model_validate_json(json_str) - def clone(self: T, extra_context: Optional[Dict[str, Any]] = None) -> T: return self.model_copy(update=extra_context or {}) diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index 60622385..fa96a5e6 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -1,31 +1,58 @@ +import json + from mat3ra.code.entity import InMemoryEntityPydantic +from pydantic import BaseModel -REFERENCE_OBJECT_1 = {"key1": "value1", "key2": "value2"} +REFERENCE_OBJECT_VALID = {"key1": "value1", "key2": 1} +REFERENCE_OBJECT_INVALID = {"key1": "value1", "key2": "value2"} +REFERENCE_OBJECT_VALID_JSON = json.dumps(REFERENCE_OBJECT_VALID) -class ChildClass(InMemoryEntityPydantic): +class ExampleSchema(BaseModel): key1: str - key2: str + key2: int + +class ExampleClass(ExampleSchema, InMemoryEntityPydantic): + pass -child_object = ChildClass(**REFERENCE_OBJECT_1) + +example_class_instance_valid = ExampleClass(**REFERENCE_OBJECT_VALID) def test_create(): - in_memory_entity = InMemoryEntityPydantic.create({}) - assert isinstance(in_memory_entity, InMemoryEntityPydantic) + in_memory_entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + assert isinstance(in_memory_entity, ExampleClass) + assert in_memory_entity.key1 == "value1" + assert in_memory_entity.key2 == 1 -def test_subclass_fields(): - assert child_object.key1 == "value1" - assert child_object.key2 == "value2" +def test_validate(): + # Test valid case + in_memory_entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + assert isinstance(in_memory_entity, ExampleClass) + # Test invalid case + try: + _ = ExampleClass.create(REFERENCE_OBJECT_INVALID) + assert False, "Invalid input did not raise an exception" + except Exception as e: + assert True # Expecting an exception for invalid input def test_to_dict(): - entity = child_object.create(REFERENCE_OBJECT_1) - assert entity.to_dict() == REFERENCE_OBJECT_1 + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + # Test to_dict method + result = entity.to_dict() + assert isinstance(result, dict) + assert result == {"key1": "value1", "key2": 1} + # Test with exclude + result_exclude = entity.to_dict(exclude=["key2"]) + assert result_exclude == {"key1": "value1"} def test_to_json(): - entity = child_object.create(REFERENCE_OBJECT_1) - assert entity.to_json() == '{"key1":"value1","key2":"value2"}' + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + + result = entity.to_json() + assert isinstance(result, str) + assert json.loads(result) == json.loads(REFERENCE_OBJECT_VALID_JSON) From e4a5c17a567c0bba6978f0601fdb5df5145044e3 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 13:56:32 -0700 Subject: [PATCH 13/41] chore: fix lint: --- src/py/mat3ra/code/entity.py | 4 ++-- tests/py/unit/test_entity.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 9fe63b8b..5dd723e1 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -1,9 +1,9 @@ from typing import Any, Dict, List, Optional, Type, TypeVar -from typing_extensions import Self import jsonschema from mat3ra.utils import object as object_utils from pydantic import BaseModel +from typing_extensions import Self from . import BaseUnderscoreJsonPropsHandler from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin @@ -39,7 +39,7 @@ class InMemoryEntityPydantic(BaseModel): def create(cls: Type[T], config: Dict[str, Any]) -> T: validated_data = cls.clean(config) return cls(**validated_data) - + @classmethod def validate(cls, value: Any) -> Self: return cls.model_validate(value) diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index fa96a5e6..50335e98 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -35,7 +35,7 @@ def test_validate(): try: _ = ExampleClass.create(REFERENCE_OBJECT_INVALID) assert False, "Invalid input did not raise an exception" - except Exception as e: + except Exception: assert True # Expecting an exception for invalid input From 3548425f77166efefa416bfde96cc6333fe3c419 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 14:06:53 -0700 Subject: [PATCH 14/41] chore: typo --- src/py/mat3ra/code/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 5dd723e1..84d7bc95 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -50,7 +50,7 @@ def from_json(cls: Type[T], json_str: str) -> T: @classmethod def clean(cls: Type[T], config: Dict[str, Any]) -> Dict[str, Any]: - validated_model = cls.validated_model_validate(config) + validated_model = cls.model_validate(config) return validated_model.model_dump() def get_cls_name(self) -> str: From 811c20cfdd5c96156f1ba7f88a2bd6f5dffd5b49 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 17:25:42 -0700 Subject: [PATCH 15/41] update: more tests --- tests/py/unit/test_entity.py | 60 +++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index 50335e98..8915559b 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -39,6 +39,49 @@ def test_validate(): assert True # Expecting an exception for invalid input +def test_is_valid(): + assert ExampleClass.is_valid(REFERENCE_OBJECT_VALID) is True + assert ExampleClass.is_valid(REFERENCE_OBJECT_INVALID) is False + + +def test_from_json(): + # Test from_json method with valid JSON + in_memory_entity = ExampleClass.from_json(REFERENCE_OBJECT_VALID_JSON) + assert isinstance(in_memory_entity, ExampleClass) + assert in_memory_entity.key1 == "value1" + assert in_memory_entity.key2 == 1 + + # Test from_json with invalid JSON + try: + _ = ExampleClass.from_json(json.dumps(REFERENCE_OBJECT_INVALID)) + assert False, "Invalid JSON did not raise an exception" + except Exception: + assert True # Expecting an exception for invalid JSON + + +def test_clean(): + # Test clean method with valid input + cleaned_data = ExampleClass.clean(REFERENCE_OBJECT_VALID) + assert isinstance(cleaned_data, dict) + assert cleaned_data == REFERENCE_OBJECT_VALID + + # Test clean method with invalid input + try: + _ = ExampleClass.clean(REFERENCE_OBJECT_INVALID) + assert False, "Invalid input did not raise an exception" + except Exception: + assert True # Expecting an exception for invalid input + + +def test_get_cls_name(): + # Test get_cls_name method + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + cls_name = entity.get_cls_name() + assert cls_name == "ExampleClass", f"Expected 'ExampleClass', got '{cls_name}'" + # Ensure it works for the class itself + assert ExampleClass.__name__ == "ExampleClass" + + def test_to_dict(): entity = ExampleClass.create(REFERENCE_OBJECT_VALID) # Test to_dict method @@ -52,7 +95,22 @@ def test_to_dict(): def test_to_json(): entity = ExampleClass.create(REFERENCE_OBJECT_VALID) - result = entity.to_json() assert isinstance(result, str) assert json.loads(result) == json.loads(REFERENCE_OBJECT_VALID_JSON) + + +def test_clone(): + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + # Test clone method + cloned_entity = entity.clone() + assert isinstance(cloned_entity, ExampleClass) + assert cloned_entity.key1 == entity.key1 + assert cloned_entity.key2 == entity.key2 + + # Test clone with extra context + extra_context = {"key2": 2} + cloned_entity_with_extra = entity.clone(extra_context=extra_context) + assert isinstance(cloned_entity_with_extra, ExampleClass) + assert cloned_entity_with_extra.key1 == entity.key1 + assert cloned_entity_with_extra.key2 == 2 # Should override to 2 From 848faca8a08ed2ad9f93cb3ffdc561b009508db5 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 17:26:19 -0700 Subject: [PATCH 16/41] update: add validation --- src/py/mat3ra/code/entity.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 84d7bc95..e9d1ee61 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -37,13 +37,21 @@ class InMemoryEntityPydantic(BaseModel): @classmethod def create(cls: Type[T], config: Dict[str, Any]) -> T: - validated_data = cls.clean(config) - return cls(**validated_data) + return cls.validate(**config) @classmethod def validate(cls, value: Any) -> Self: + # this will clean and validate data return cls.model_validate(value) + @classmethod + def is_valid(cls, value: Any) -> bool: + try: + cls.validate(value) + return True + except Exception: + return False + @classmethod def from_json(cls: Type[T], json_str: str) -> T: return cls.model_validate_json(json_str) From cee6ae30e6930149a162dfd3917b7991774bd548 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 17:31:34 -0700 Subject: [PATCH 17/41] chore: fix typo --- src/py/mat3ra/code/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index e9d1ee61..0936a1e5 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -37,7 +37,7 @@ class InMemoryEntityPydantic(BaseModel): @classmethod def create(cls: Type[T], config: Dict[str, Any]) -> T: - return cls.validate(**config) + return cls.validate(config) @classmethod def validate(cls, value: Any) -> Self: From 141b499b903a34830d24ba4c8c15c03a5a900b1c Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 18:39:14 -0700 Subject: [PATCH 18/41] wip: allow editable install locally --- pyproject.toml | 8 ++++++++ src/py/mat3ra/__init__.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index cbd4978c..dca6df37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,3 +81,11 @@ target-version = "py38" profile = "black" multi_line_output = 3 include_trailing_comma = true + +[tool.pytest.ini_options] +pythonpath = [ + "src/py", +] +testpaths = [ + "tests/py" +] diff --git a/src/py/mat3ra/__init__.py b/src/py/mat3ra/__init__.py index e69de29b..29908f8c 100644 --- a/src/py/mat3ra/__init__.py +++ b/src/py/mat3ra/__init__.py @@ -0,0 +1,2 @@ +__path__ = __import__('pkgutil').extend_path(__path__, __name__) +# otherwise, `mat3ra.utils` path leads to an empty __init__.py file in the code.py package From 508346027127145248aef4619af05e12da1ef364 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 18:53:06 -0700 Subject: [PATCH 19/41] update: tests for mixins --- src/py/mat3ra/code/mixins/__init__.py | 6 +++-- tests/py/unit/test_mixins.py | 34 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/py/unit/test_mixins.py diff --git a/src/py/mat3ra/code/mixins/__init__.py b/src/py/mat3ra/code/mixins/__init__.py index 82efe59e..76e83180 100644 --- a/src/py/mat3ra/code/mixins/__init__.py +++ b/src/py/mat3ra/code/mixins/__init__.py @@ -3,6 +3,8 @@ from mat3ra.esse.models.system.defaultable import DefaultableEntitySchema from mat3ra.esse.models.system.description import DescriptionSchema from mat3ra.esse.models.system.metadata import MetadataSchema +from mat3ra.esse.models.system.name import NameEntitySchema + from pydantic import BaseModel @@ -20,8 +22,8 @@ def create_default(cls) -> "DefaultableMixin": return instance -class NamedMixin(BaseModel): - name: Optional[str] = None +class NamedMixin(NameEntitySchema): + pass class HasMetadataMixin(MetadataSchema): diff --git a/tests/py/unit/test_mixins.py b/tests/py/unit/test_mixins.py new file mode 100644 index 00000000..09df25cd --- /dev/null +++ b/tests/py/unit/test_mixins.py @@ -0,0 +1,34 @@ +from mat3ra.code.mixins import DefaultableMixin, NamedMixin + + +def test_defaultable_mixin(): + # Test the DefaultableMixin functionality + default_config = {"key": "value", "number": 42} + + class ExampleDefaultable(DefaultableMixin): + key: str + number: int + + __default_config__ = default_config + + # Create a default instance + instance = ExampleDefaultable.create_default() + + assert instance.key == "value" + assert instance.number == 42 + assert hasattr(instance, 'isDefault') and instance.isDefault is True + + +def test_named_mixin(): + # Test the NamedMixin functionality + class ExampleNamed(NamedMixin): + pass + + # Create an instance with a name + instance = ExampleNamed(name="TestName") + + assert instance.name == "TestName" + + # Test with None + instance_none = ExampleNamed() + assert instance_none.name is None From cd3890a49739dc016ed59872f3340ee73bee3e98 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 18:53:20 -0700 Subject: [PATCH 20/41] update: tests for mixins --- src/py/mat3ra/__init__.py | 2 +- src/py/mat3ra/code/mixins/__init__.py | 1 - tests/py/unit/test_mixins.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/py/mat3ra/__init__.py b/src/py/mat3ra/__init__.py index 29908f8c..41e7db52 100644 --- a/src/py/mat3ra/__init__.py +++ b/src/py/mat3ra/__init__.py @@ -1,2 +1,2 @@ -__path__ = __import__('pkgutil').extend_path(__path__, __name__) +__path__ = __import__("pkgutil").extend_path(__path__, __name__) # otherwise, `mat3ra.utils` path leads to an empty __init__.py file in the code.py package diff --git a/src/py/mat3ra/code/mixins/__init__.py b/src/py/mat3ra/code/mixins/__init__.py index 76e83180..1a82f5b9 100644 --- a/src/py/mat3ra/code/mixins/__init__.py +++ b/src/py/mat3ra/code/mixins/__init__.py @@ -4,7 +4,6 @@ from mat3ra.esse.models.system.description import DescriptionSchema from mat3ra.esse.models.system.metadata import MetadataSchema from mat3ra.esse.models.system.name import NameEntitySchema - from pydantic import BaseModel diff --git a/tests/py/unit/test_mixins.py b/tests/py/unit/test_mixins.py index 09df25cd..b0941f6e 100644 --- a/tests/py/unit/test_mixins.py +++ b/tests/py/unit/test_mixins.py @@ -16,7 +16,7 @@ class ExampleDefaultable(DefaultableMixin): assert instance.key == "value" assert instance.number == 42 - assert hasattr(instance, 'isDefault') and instance.isDefault is True + assert hasattr(instance, "isDefault") and instance.isDefault is True def test_named_mixin(): From d99be06f0edd6622dd1ff72f36caa69da10f5b97 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 20:05:08 -0700 Subject: [PATCH 21/41] update: generate classes --- src/py/mat3ra/code/entity.py | 17 ++++++++++++++++- tests/py/unit/test_entity.py | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 0936a1e5..cbbd4852 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -33,7 +33,10 @@ def __init__(self, code: ValidationErrorCode, details: Optional[ErrorDetails] = class InMemoryEntityPydantic(BaseModel): - model_config = {"arbitrary_types_allowed": True} + model_config = {"arbitrary_types_allowed": True, "extra": "allow"} + + # Factory helper field mapping field names to class names + _class_factory: Dict = {} @classmethod def create(cls: Type[T], config: Dict[str, Any]) -> T: @@ -61,6 +64,18 @@ def clean(cls: Type[T], config: Dict[str, Any]) -> Dict[str, Any]: validated_model = cls.model_validate(config) return validated_model.model_dump() + def model_post_init(self, __context: Any) -> None: + for field_name, field_value in self.__dict__.items(): + if isinstance(field_value, BaseModel): + class_reference = self._class_factory.get(field_name) + if not class_reference: + continue + else: + instance_field_name = field_name + "_instance" + config = field_value.model_dump() # convert from BaseModel to dict + class_instance = class_reference(**config) + setattr(self, instance_field_name, class_instance) + def get_cls_name(self) -> str: return self.__class__.__name__ diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index 8915559b..ebc90b7a 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -6,6 +6,7 @@ REFERENCE_OBJECT_VALID = {"key1": "value1", "key2": 1} REFERENCE_OBJECT_INVALID = {"key1": "value1", "key2": "value2"} REFERENCE_OBJECT_VALID_JSON = json.dumps(REFERENCE_OBJECT_VALID) +REFERENCE_OBJECT_NESTED_VALID = {"nested_key1": {**REFERENCE_OBJECT_VALID}} class ExampleSchema(BaseModel): @@ -13,10 +14,18 @@ class ExampleSchema(BaseModel): key2: int +class ExampleNestedSchema(BaseModel): + nested_key1: ExampleSchema + + class ExampleClass(ExampleSchema, InMemoryEntityPydantic): pass +class ExampleNestedClass(ExampleNestedSchema, InMemoryEntityPydantic): + _class_factory = {"nested_key1": ExampleClass} + + example_class_instance_valid = ExampleClass(**REFERENCE_OBJECT_VALID) @@ -27,6 +36,16 @@ def test_create(): assert in_memory_entity.key2 == 1 +def test_create_nested(): + # Test creating an instance with nested valid data + in_memory_entity = ExampleNestedClass.create(REFERENCE_OBJECT_NESTED_VALID) + assert isinstance(in_memory_entity, ExampleNestedClass) + assert isinstance(in_memory_entity.nested_key1, ExampleSchema) + assert in_memory_entity.nested_key1.key1 == "value1" + assert in_memory_entity.nested_key1.key2 == 1 + assert isinstance(in_memory_entity.nested_key1_instance, ExampleClass) + + def test_validate(): # Test valid case in_memory_entity = ExampleClass.create(REFERENCE_OBJECT_VALID) From 93699863fe5e6be8fca665681743443ac8079e44 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 20:05:52 -0700 Subject: [PATCH 22/41] chore: update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bc455f40..ac246a98 100644 --- a/.gitignore +++ b/.gitignore @@ -126,7 +126,7 @@ celerybeat.pid # Environments .env -.venv +.venv* env/ venv/ ENV/ @@ -176,3 +176,4 @@ node_modules/ *.DS_Store tsconfig.tsbuildinfo +.python-version From 1a9053f16c5158a12e9ce0c1f371c0482ac2b00b Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 20:21:49 -0700 Subject: [PATCH 23/41] chore: update nested class init example --- src/py/mat3ra/code/entity.py | 17 +---------------- tests/py/unit/test_entity.py | 6 +++++- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index cbbd4852..0936a1e5 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -33,10 +33,7 @@ def __init__(self, code: ValidationErrorCode, details: Optional[ErrorDetails] = class InMemoryEntityPydantic(BaseModel): - model_config = {"arbitrary_types_allowed": True, "extra": "allow"} - - # Factory helper field mapping field names to class names - _class_factory: Dict = {} + model_config = {"arbitrary_types_allowed": True} @classmethod def create(cls: Type[T], config: Dict[str, Any]) -> T: @@ -64,18 +61,6 @@ def clean(cls: Type[T], config: Dict[str, Any]) -> Dict[str, Any]: validated_model = cls.model_validate(config) return validated_model.model_dump() - def model_post_init(self, __context: Any) -> None: - for field_name, field_value in self.__dict__.items(): - if isinstance(field_value, BaseModel): - class_reference = self._class_factory.get(field_name) - if not class_reference: - continue - else: - instance_field_name = field_name + "_instance" - config = field_value.model_dump() # convert from BaseModel to dict - class_instance = class_reference(**config) - setattr(self, instance_field_name, class_instance) - def get_cls_name(self) -> str: return self.__class__.__name__ diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index ebc90b7a..ae414d21 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -1,4 +1,5 @@ import json +from typing import Any from mat3ra.code.entity import InMemoryEntityPydantic from pydantic import BaseModel @@ -23,7 +24,10 @@ class ExampleClass(ExampleSchema, InMemoryEntityPydantic): class ExampleNestedClass(ExampleNestedSchema, InMemoryEntityPydantic): - _class_factory = {"nested_key1": ExampleClass} + + @property + def nested_key1_instance(self) -> ExampleClass: + return ExampleClass.create(self.nested_key1.model_dump()) example_class_instance_valid = ExampleClass(**REFERENCE_OBJECT_VALID) From 0a088ed5a96be50b2e713f05020ec45f983eea76 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 20:22:39 -0700 Subject: [PATCH 24/41] chore: update nested class init example --- tests/py/unit/test_entity.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index ae414d21..bacc9779 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -1,5 +1,4 @@ import json -from typing import Any from mat3ra.code.entity import InMemoryEntityPydantic from pydantic import BaseModel @@ -24,7 +23,6 @@ class ExampleClass(ExampleSchema, InMemoryEntityPydantic): class ExampleNestedClass(ExampleNestedSchema, InMemoryEntityPydantic): - @property def nested_key1_instance(self) -> ExampleClass: return ExampleClass.create(self.nested_key1.model_dump()) From 86bb16648a0cfd5fe56dfc73dece26bb17c8db98 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 21:17:13 -0700 Subject: [PATCH 25/41] wip: add an example of overriding the field --- tests/py/unit/test_entity.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index bacc9779..962f7e41 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -7,6 +7,7 @@ REFERENCE_OBJECT_INVALID = {"key1": "value1", "key2": "value2"} REFERENCE_OBJECT_VALID_JSON = json.dumps(REFERENCE_OBJECT_VALID) REFERENCE_OBJECT_NESTED_VALID = {"nested_key1": {**REFERENCE_OBJECT_VALID}} +REFERENCE_OBJECT_OVERRIDE_VALID = {"as_class_instance": {**REFERENCE_OBJECT_VALID}} class ExampleSchema(BaseModel): @@ -18,6 +19,10 @@ class ExampleNestedSchema(BaseModel): nested_key1: ExampleSchema +class ExampleOverrideSchema(BaseModel): + as_class_instance: ExampleSchema + + class ExampleClass(ExampleSchema, InMemoryEntityPydantic): pass @@ -28,7 +33,12 @@ def nested_key1_instance(self) -> ExampleClass: return ExampleClass.create(self.nested_key1.model_dump()) -example_class_instance_valid = ExampleClass(**REFERENCE_OBJECT_VALID) +class ExampleFullClass(ExampleOverrideSchema, InMemoryEntityPydantic): + __default_config__ = REFERENCE_OBJECT_OVERRIDE_VALID + __schema__ = ExampleOverrideSchema + + # We override the as_class_instance field to be an instance of ExampleClass + as_class_instance: ExampleClass = ExampleClass(**REFERENCE_OBJECT_VALID) def test_create(): @@ -48,6 +58,15 @@ def test_create_nested(): assert isinstance(in_memory_entity.nested_key1_instance, ExampleClass) +def test_full_class(): + in_memory_entity = ExampleFullClass.create(REFERENCE_OBJECT_OVERRIDE_VALID) + assert isinstance(in_memory_entity, ExampleFullClass) + assert isinstance(in_memory_entity.as_class_instance, ExampleClass) + assert in_memory_entity.as_class_instance.key1 == "value1" + assert in_memory_entity.as_class_instance.key2 == 1 + assert in_memory_entity.__schema__ == ExampleOverrideSchema + + def test_validate(): # Test valid case in_memory_entity = ExampleClass.create(REFERENCE_OBJECT_VALID) From 6cb6230cdcba1bc2d42cd624ba4482256eb36151 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 28 Mar 2025 22:04:25 -0700 Subject: [PATCH 26/41] update: get datamodel --- src/py/mat3ra/code/entity.py | 10 ++++++++++ tests/py/unit/test_entity.py | 3 +-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 0936a1e5..f21c58e3 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -9,6 +9,7 @@ from .mixins import DefaultableMixin, HasDescriptionMixin, HasMetadataMixin, NamedMixin T = TypeVar("T", bound="InMemoryEntityPydantic") +B = TypeVar("B", bound="BaseModel") # TODO: remove in the next PR @@ -61,6 +62,15 @@ def clean(cls: Type[T], config: Dict[str, Any]) -> Dict[str, Any]: validated_model = cls.model_validate(config) return validated_model.model_dump() + def get_schema(self) -> Dict[str, Any]: + return self.model_json_schema() + + def get_data_model(self) -> Type[B]: + for base in self.__class__.__bases__: + if issubclass(base, BaseModel) and base is not self.__class__: + return base # this will be MaterialSchema + raise ValueError(f"No schema base model found for {self.__class__.__name__}") + def get_cls_name(self) -> str: return self.__class__.__name__ diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index 962f7e41..37a72286 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -35,7 +35,6 @@ def nested_key1_instance(self) -> ExampleClass: class ExampleFullClass(ExampleOverrideSchema, InMemoryEntityPydantic): __default_config__ = REFERENCE_OBJECT_OVERRIDE_VALID - __schema__ = ExampleOverrideSchema # We override the as_class_instance field to be an instance of ExampleClass as_class_instance: ExampleClass = ExampleClass(**REFERENCE_OBJECT_VALID) @@ -64,7 +63,7 @@ def test_full_class(): assert isinstance(in_memory_entity.as_class_instance, ExampleClass) assert in_memory_entity.as_class_instance.key1 == "value1" assert in_memory_entity.as_class_instance.key2 == 1 - assert in_memory_entity.__schema__ == ExampleOverrideSchema + assert in_memory_entity.get_data_model() == ExampleOverrideSchema def test_validate(): From 0398aa6737f2fe5333e44dc275821d93f1ac5961 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Sat, 29 Mar 2025 15:02:34 -0700 Subject: [PATCH 27/41] update: tests with property oveeride --- tests/py/unit/test_entity.py | 75 ++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index 37a72286..17705f48 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -4,10 +4,11 @@ from pydantic import BaseModel REFERENCE_OBJECT_VALID = {"key1": "value1", "key2": 1} +REFERENCE_OBJECT_VALID_UPDATED = {"key1": "value1-updated", "key2": 2} REFERENCE_OBJECT_INVALID = {"key1": "value1", "key2": "value2"} REFERENCE_OBJECT_VALID_JSON = json.dumps(REFERENCE_OBJECT_VALID) REFERENCE_OBJECT_NESTED_VALID = {"nested_key1": {**REFERENCE_OBJECT_VALID}} -REFERENCE_OBJECT_OVERRIDE_VALID = {"as_class_instance": {**REFERENCE_OBJECT_VALID}} +REFERENCE_OBJECT_NESTED_VALID_UPDATED = {"nested_key1": {**REFERENCE_OBJECT_VALID_UPDATED}} class ExampleSchema(BaseModel): @@ -19,10 +20,6 @@ class ExampleNestedSchema(BaseModel): nested_key1: ExampleSchema -class ExampleOverrideSchema(BaseModel): - as_class_instance: ExampleSchema - - class ExampleClass(ExampleSchema, InMemoryEntityPydantic): pass @@ -33,43 +30,53 @@ def nested_key1_instance(self) -> ExampleClass: return ExampleClass.create(self.nested_key1.model_dump()) -class ExampleFullClass(ExampleOverrideSchema, InMemoryEntityPydantic): - __default_config__ = REFERENCE_OBJECT_OVERRIDE_VALID +class ExampleNestedKeyAsClassInstanceClass(ExampleNestedSchema, InMemoryEntityPydantic): + __default_config__ = REFERENCE_OBJECT_NESTED_VALID - # We override the as_class_instance field to be an instance of ExampleClass - as_class_instance: ExampleClass = ExampleClass(**REFERENCE_OBJECT_VALID) + nested_key1: ExampleClass = ExampleClass(**REFERENCE_OBJECT_VALID) def test_create(): - in_memory_entity = ExampleClass.create(REFERENCE_OBJECT_VALID) - assert isinstance(in_memory_entity, ExampleClass) - assert in_memory_entity.key1 == "value1" - assert in_memory_entity.key2 == 1 + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + assert isinstance(entity, ExampleClass) + assert entity.key1 == "value1" + assert entity.key2 == 1 def test_create_nested(): # Test creating an instance with nested valid data - in_memory_entity = ExampleNestedClass.create(REFERENCE_OBJECT_NESTED_VALID) - assert isinstance(in_memory_entity, ExampleNestedClass) - assert isinstance(in_memory_entity.nested_key1, ExampleSchema) - assert in_memory_entity.nested_key1.key1 == "value1" - assert in_memory_entity.nested_key1.key2 == 1 - assert isinstance(in_memory_entity.nested_key1_instance, ExampleClass) - - -def test_full_class(): - in_memory_entity = ExampleFullClass.create(REFERENCE_OBJECT_OVERRIDE_VALID) - assert isinstance(in_memory_entity, ExampleFullClass) - assert isinstance(in_memory_entity.as_class_instance, ExampleClass) - assert in_memory_entity.as_class_instance.key1 == "value1" - assert in_memory_entity.as_class_instance.key2 == 1 - assert in_memory_entity.get_data_model() == ExampleOverrideSchema + entity = ExampleNestedClass.create(REFERENCE_OBJECT_NESTED_VALID) + assert isinstance(entity, ExampleNestedClass) + assert isinstance(entity.nested_key1, ExampleSchema) + assert entity.nested_key1.key1 == "value1" + assert entity.nested_key1.key2 == 1 + assert isinstance(entity.nested_key1_instance, ExampleClass) + + +def test_create_nested_as_class_instance(): + entity = ExampleNestedKeyAsClassInstanceClass.create(REFERENCE_OBJECT_NESTED_VALID) + assert isinstance(entity, ExampleNestedKeyAsClassInstanceClass) + assert isinstance(entity.nested_key1, ExampleClass) + assert entity.nested_key1.key1 == "value1" + assert entity.nested_key1.key2 == 1 + assert entity.get_data_model() == ExampleNestedSchema + + +def test_update_nested_as_class_instance(): + entity = ExampleNestedKeyAsClassInstanceClass.create(REFERENCE_OBJECT_NESTED_VALID) + entity.nested_key1 = ExampleClass(**REFERENCE_OBJECT_VALID_UPDATED) + assert entity.nested_key1.key1 == "value1-updated" + assert entity.nested_key1.key2 == 2 + entity_json = entity.to_json() + reference_json = json.dumps(REFERENCE_OBJECT_NESTED_VALID_UPDATED) + assert json.loads(entity_json) == json.loads(reference_json) + assert isinstance(entity.nested_key1, ExampleClass) def test_validate(): # Test valid case - in_memory_entity = ExampleClass.create(REFERENCE_OBJECT_VALID) - assert isinstance(in_memory_entity, ExampleClass) + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + assert isinstance(entity, ExampleClass) # Test invalid case try: _ = ExampleClass.create(REFERENCE_OBJECT_INVALID) @@ -85,10 +92,10 @@ def test_is_valid(): def test_from_json(): # Test from_json method with valid JSON - in_memory_entity = ExampleClass.from_json(REFERENCE_OBJECT_VALID_JSON) - assert isinstance(in_memory_entity, ExampleClass) - assert in_memory_entity.key1 == "value1" - assert in_memory_entity.key2 == 1 + entity = ExampleClass.from_json(REFERENCE_OBJECT_VALID_JSON) + assert isinstance(entity, ExampleClass) + assert entity.key1 == "value1" + assert entity.key2 == 1 # Test from_json with invalid JSON try: From f1c1380472fc2a674f0c56895b6d1eeb3ace38ad Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Sat, 29 Mar 2025 16:12:12 -0700 Subject: [PATCH 28/41] chore: update esse --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dca6df37..89f159e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "numpy", "jsonschema>=2.6.0", "pydantic>=2.10.5", - "mat3ra-esse @ git+https://github.com/Exabyte-io/esse.git@refs/pull/325/head", + "mat3ra-esse @ git+https://github.com/Exabyte-io/esse.git@feature/SOF-7570-2", "mat3ra-utils>=2024.5.15.post0", ] From aace74d861a6229084a4352c96b833cc0555e333 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Sun, 30 Mar 2025 12:08:48 -0700 Subject: [PATCH 29/41] update: add more tests: --- src/py/mat3ra/code/mixins/__init__.py | 9 +--- tests/py/unit/__init__.py | 44 ++++++++++++++++++++ tests/py/unit/test_entity.py | 60 ++++++++++++--------------- tests/py/unit/test_mixins.py | 30 +++++++++++++- 4 files changed, 100 insertions(+), 43 deletions(-) diff --git a/src/py/mat3ra/code/mixins/__init__.py b/src/py/mat3ra/code/mixins/__init__.py index 1a82f5b9..564362af 100644 --- a/src/py/mat3ra/code/mixins/__init__.py +++ b/src/py/mat3ra/code/mixins/__init__.py @@ -4,21 +4,14 @@ from mat3ra.esse.models.system.description import DescriptionSchema from mat3ra.esse.models.system.metadata import MetadataSchema from mat3ra.esse.models.system.name import NameEntitySchema -from pydantic import BaseModel class DefaultableMixin(DefaultableEntitySchema): __default_config__: ClassVar[Dict[str, Any]] = {} - @property - def default_config(self) -> Dict[str, Any]: - return self.__default_config__ - @classmethod def create_default(cls) -> "DefaultableMixin": - instance = cls(**cls.__default_config__) - instance.isDefault = True - return instance + return cls(**cls.__default_config__) class NamedMixin(NameEntitySchema): diff --git a/tests/py/unit/__init__.py b/tests/py/unit/__init__.py index e69de29b..ce42873b 100644 --- a/tests/py/unit/__init__.py +++ b/tests/py/unit/__init__.py @@ -0,0 +1,44 @@ +import json + +from mat3ra.code.entity import InMemoryEntityPydantic +from pydantic import BaseModel + +REFERENCE_OBJECT_VALID = {"key1": "value1", "key2": 1} +REFERENCE_OBJECT_VALID_UPDATED = {"key1": "value1-updated", "key2": 2} +REFERENCE_OBJECT_INVALID = {"key1": "value1", "key2": "value2"} +REFERENCE_OBJECT_VALID_JSON = json.dumps(REFERENCE_OBJECT_VALID) +REFERENCE_OBJECT_NESTED_VALID = {"nested_key1": {**REFERENCE_OBJECT_VALID}} +REFERENCE_OBJECT_NESTED_VALID_UPDATED = {"nested_key1": {**REFERENCE_OBJECT_VALID_UPDATED}} + +REFERENCE_OBJECT_DOUBLE_NESTED_VALID = {"double_nested_key1": {**REFERENCE_OBJECT_NESTED_VALID}} + + +class ExampleSchema(BaseModel): + key1: str + key2: int + + +class ExampleNestedSchema(BaseModel): + nested_key1: ExampleSchema + + +class ExampleDoubleNestedSchema(BaseModel): + double_nested_key1: ExampleNestedSchema + + +class ExampleClass(ExampleSchema, InMemoryEntityPydantic): + pass + + +class ExampleNestedClass(ExampleNestedSchema, InMemoryEntityPydantic): + @property + def nested_key1_instance(self) -> ExampleClass: + return ExampleClass.create(self.nested_key1.model_dump()) + + +class ExampleNestedKeyAsClassInstanceClass(ExampleNestedSchema, InMemoryEntityPydantic): + nested_key1: ExampleClass + + +class ExampleDoubleNestedKeyAsClassInstancesClass(ExampleDoubleNestedSchema, InMemoryEntityPydantic): + double_nested_key1: ExampleNestedKeyAsClassInstanceClass diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index 17705f48..e3ba87a6 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -1,39 +1,21 @@ import json -from mat3ra.code.entity import InMemoryEntityPydantic -from pydantic import BaseModel - -REFERENCE_OBJECT_VALID = {"key1": "value1", "key2": 1} -REFERENCE_OBJECT_VALID_UPDATED = {"key1": "value1-updated", "key2": 2} -REFERENCE_OBJECT_INVALID = {"key1": "value1", "key2": "value2"} -REFERENCE_OBJECT_VALID_JSON = json.dumps(REFERENCE_OBJECT_VALID) -REFERENCE_OBJECT_NESTED_VALID = {"nested_key1": {**REFERENCE_OBJECT_VALID}} -REFERENCE_OBJECT_NESTED_VALID_UPDATED = {"nested_key1": {**REFERENCE_OBJECT_VALID_UPDATED}} - - -class ExampleSchema(BaseModel): - key1: str - key2: int - - -class ExampleNestedSchema(BaseModel): - nested_key1: ExampleSchema - - -class ExampleClass(ExampleSchema, InMemoryEntityPydantic): - pass - - -class ExampleNestedClass(ExampleNestedSchema, InMemoryEntityPydantic): - @property - def nested_key1_instance(self) -> ExampleClass: - return ExampleClass.create(self.nested_key1.model_dump()) - - -class ExampleNestedKeyAsClassInstanceClass(ExampleNestedSchema, InMemoryEntityPydantic): - __default_config__ = REFERENCE_OBJECT_NESTED_VALID - - nested_key1: ExampleClass = ExampleClass(**REFERENCE_OBJECT_VALID) +from . import ( + REFERENCE_OBJECT_DOUBLE_NESTED_VALID, + REFERENCE_OBJECT_INVALID, + REFERENCE_OBJECT_NESTED_VALID, + REFERENCE_OBJECT_NESTED_VALID_UPDATED, + REFERENCE_OBJECT_VALID, + REFERENCE_OBJECT_VALID_JSON, + REFERENCE_OBJECT_VALID_UPDATED, + ExampleClass, + ExampleDoubleNestedKeyAsClassInstancesClass, + ExampleDoubleNestedSchema, + ExampleNestedClass, + ExampleNestedKeyAsClassInstanceClass, + ExampleNestedSchema, + ExampleSchema, +) def test_create(): @@ -62,6 +44,16 @@ def test_create_nested_as_class_instance(): assert entity.get_data_model() == ExampleNestedSchema +def test_create_double_nested_as_class_instances(): + entity = ExampleDoubleNestedKeyAsClassInstancesClass.create(REFERENCE_OBJECT_DOUBLE_NESTED_VALID) + assert isinstance(entity, ExampleDoubleNestedKeyAsClassInstancesClass) + assert isinstance(entity.double_nested_key1, ExampleNestedKeyAsClassInstanceClass) + assert isinstance(entity.double_nested_key1.nested_key1, ExampleClass) + assert entity.double_nested_key1.nested_key1.key1 == "value1" + assert entity.double_nested_key1.nested_key1.key2 == 1 + assert entity.get_data_model() == ExampleDoubleNestedSchema + + def test_update_nested_as_class_instance(): entity = ExampleNestedKeyAsClassInstanceClass.create(REFERENCE_OBJECT_NESTED_VALID) entity.nested_key1 = ExampleClass(**REFERENCE_OBJECT_VALID_UPDATED) diff --git a/tests/py/unit/test_mixins.py b/tests/py/unit/test_mixins.py index b0941f6e..64daaf01 100644 --- a/tests/py/unit/test_mixins.py +++ b/tests/py/unit/test_mixins.py @@ -1,3 +1,5 @@ +from typing import Optional + from mat3ra.code.mixins import DefaultableMixin, NamedMixin @@ -16,7 +18,7 @@ class ExampleDefaultable(DefaultableMixin): assert instance.key == "value" assert instance.number == 42 - assert hasattr(instance, "isDefault") and instance.isDefault is True + assert hasattr(instance, "isDefault") def test_named_mixin(): @@ -32,3 +34,29 @@ class ExampleNamed(NamedMixin): # Test with None instance_none = ExampleNamed() assert instance_none.name is None + + +def test_complex_mixin(): + # Test a complex mixin + default_config = {"key": "value", "number": 42} + + class ExampleComplex(DefaultableMixin, NamedMixin): + key: Optional[str] + number: Optional[int] + + __default_config__ = default_config + + # Create a default instance + instance = ExampleComplex.create_default() + + assert instance.key == "value" + assert instance.number == 42 + assert hasattr(instance, "isDefault") + + # Create an instance with a name + instance = ExampleComplex(name="TestName", key=None, number=None) + + assert instance.name == "TestName" + assert instance.key is None + assert instance.number is None + assert hasattr(instance, "isDefault") From cee162e5ce430e58f6e32ff5f705fc1e233e1c38 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 31 Mar 2025 17:08:36 -0700 Subject: [PATCH 30/41] feat: add array of values with ids, rounded + tests --- src/py/mat3ra/code/array_with_ids.py | 116 ++++++++++++ src/py/mat3ra/code/entity.py | 2 +- src/py/mat3ra/code/value_with_id.py | 34 ++++ tests/py/unit/test_array_with_ids.py | 269 +++++++++++++++++++++++++++ tests/py/unit/test_value_with_id.py | 39 ++++ 5 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 src/py/mat3ra/code/array_with_ids.py create mode 100644 src/py/mat3ra/code/value_with_id.py create mode 100644 tests/py/unit/test_array_with_ids.py create mode 100644 tests/py/unit/test_value_with_id.py diff --git a/src/py/mat3ra/code/array_with_ids.py b/src/py/mat3ra/code/array_with_ids.py new file mode 100644 index 00000000..5982bb5f --- /dev/null +++ b/src/py/mat3ra/code/array_with_ids.py @@ -0,0 +1,116 @@ +import json +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from mat3ra.utils.mixins import RoundNumericValuesMixin +from pydantic import BaseModel, model_serializer + +from .value_with_id import RoundedValueWithId, ValueWithId + + +class ArrayWithIds(BaseModel): + values: List[Any] + ids: List[int] + + @classmethod + def from_values(cls, values: List[Any]) -> "ArrayWithIds": + try: + ids = list(range(len(values))) + return cls(values=values, ids=ids) + except KeyError: + raise ValueError("Values must be a list") + + @classmethod + def get_values_and_ids_from_list_of_dicts(cls, list_of_dicts: List[Dict[str, Any]]) -> Tuple[List[Any], List[int]]: + try: + values = [item["value"] for item in list_of_dicts] + ids = [item["id"] for item in list_of_dicts] + return values, ids + except KeyError: + raise ValueError("List of dictionaries must contain 'id' and 'value' keys") + + @classmethod + def from_list_of_dicts(cls, list_of_dicts: List[Dict[str, Any]]) -> "ArrayWithIds": + try: + values, ids = cls.get_values_and_ids_from_list_of_dicts(list_of_dicts) + return cls(values=values, ids=ids) + except KeyError: + raise ValueError("List of dictionaries must contain 'id' and 'value' keys") + + @model_serializer + def to_dict(self) -> List[Dict[str, Any]]: + return list(map(lambda x: x.to_dict(), self.to_array_of_values_with_ids())) + + def to_json(self, skip_rounding=True) -> str: + return json.dumps(self.to_dict()) + + def to_array_of_values_with_ids(self) -> List[ValueWithId]: + return [ValueWithId(id=id, value=item) for id, item in zip(self.ids, self.values)] + + def get_element_value_by_index(self, index: int) -> Any: + return self.values[index] if index < len(self.values) else None + + def get_element_id_by_value(self, value: Any) -> Optional[int]: + try: + return self.ids[self.values.index(value)] + except ValueError: + return None + + def filter_by_values(self, values: Union[List[Any], Any]): + def make_hashable(value): + return tuple(value) if isinstance(value, list) else value + + values_to_keep = set(make_hashable(v) for v in values) if isinstance(values, list) else {make_hashable(values)} + filtered_items = [(v, i) for v, i in zip(self.values, self.ids) if make_hashable(v) in values_to_keep] + if filtered_items: + values_unpacked, ids_unpacked = zip(*filtered_items) + self.values = list(values_unpacked) + self.ids = list(ids_unpacked) + else: + self.values = [] + self.ids = [] + + def filter_by_indices(self, indices: Union[List[int], int]): + index_set = set(indices) if isinstance(indices, list) else {indices} + self.values = [self.values[i] for i in range(len(self.values)) if i in index_set] + self.ids = [self.ids[i] for i in range(len(self.ids)) if i in index_set] + + def filter_by_ids(self, ids: Union[List[int], int]): + if isinstance(ids, int): + ids = [ids] + ids_set = set(ids) + keep_indices = [index for index, id_ in enumerate(self.ids) if id_ in ids_set] + self.values = [self.values[index] for index in keep_indices] + self.ids = [self.ids[index] for index in keep_indices] + + def __eq__(self, other: object) -> bool: + return isinstance(other, ArrayWithIds) and self.values == other.values and self.ids == other.ids + + def map_array_in_place(self, func: Callable): + self.values = list(map(func, self.values)) + + def add_item(self, element: Any, id: Optional[int] = None): + if id is None: + new_id = max(self.ids, default=-1) + 1 + else: + new_id = id + self.values.append(element) + self.ids.append(new_id) + + def remove_item(self, index: int, id: Optional[int] = None): + if id is not None: + try: + index = self.ids.index(id) + except ValueError: + raise ValueError("ID not found in the list") + if index < len(self.values): + del self.values[index] + del self.ids[index] + else: + raise IndexError("Index out of range") + + +class RoundedArrayWithIds(RoundNumericValuesMixin, ArrayWithIds): + def to_array_of_values_with_ids(self) -> List[ValueWithId]: + class_reference = RoundedValueWithId + class_reference.__round_precision__ = self.__round_precision__ + return [class_reference(id=id, value=item) for id, item in zip(self.ids, self.values)] diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index f21c58e3..93aa7219 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -68,7 +68,7 @@ def get_schema(self) -> Dict[str, Any]: def get_data_model(self) -> Type[B]: for base in self.__class__.__bases__: if issubclass(base, BaseModel) and base is not self.__class__: - return base # this will be MaterialSchema + return base raise ValueError(f"No schema base model found for {self.__class__.__name__}") def get_cls_name(self) -> str: diff --git a/src/py/mat3ra/code/value_with_id.py b/src/py/mat3ra/code/value_with_id.py new file mode 100644 index 00000000..7f3e0b0f --- /dev/null +++ b/src/py/mat3ra/code/value_with_id.py @@ -0,0 +1,34 @@ +import json +from typing import Any, Dict + +from mat3ra.utils.mixins import RoundNumericValuesMixin +from pydantic import BaseModel, model_serializer + + +class ValueWithId(BaseModel): + id: int = 0 + value: Any = None + + @model_serializer + def to_dict(self): + # If `to_dict` is present in `value`, call it + if hasattr(self.value, "to_dict"): + return {"id": self.id, "value": self.value.to_dict()} + else: + return {"id": self.id, "value": self.value} + + def to_json(self) -> str: + return json.dumps(self.to_dict()) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ValueWithId): + return False + return self.value == other.value and self.id == other.id + + +class RoundedValueWithId(RoundNumericValuesMixin, ValueWithId): + @model_serializer + def to_dict(self, skip_rounding: bool = False) -> Dict[str, Any]: + rounded_value = self.round_array_or_number(self.value) if not skip_rounding else self.value + rounded_value_with_id = ValueWithId(id=self.id, value=rounded_value) + return rounded_value_with_id.to_dict() diff --git a/tests/py/unit/test_array_with_ids.py b/tests/py/unit/test_array_with_ids.py new file mode 100644 index 00000000..dcfd71fb --- /dev/null +++ b/tests/py/unit/test_array_with_ids.py @@ -0,0 +1,269 @@ +import json + +import numpy as np +from mat3ra.code.array_with_ids import ArrayWithIds, RoundedArrayWithIds + +ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG = {"values": [1, 2, 3], "ids": [0, 1, 2]} + +ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT = [ + {"id": 0, "value": 1}, + {"id": 1, "value": 2}, + {"id": 2, "value": 3}, +] + +ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_JSON_OUTPUT = json.dumps(ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT) + +ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG = {"values": [[1, 2], [3, 4], [5, 6]], "ids": [0, 1, 2]} + +ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_DICT_OUTPUT = [ + {"id": 0, "value": [1, 2]}, + {"id": 1, "value": [3, 4]}, + {"id": 2, "value": [5, 6]}, +] + +ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_JSON_OUTPUT = json.dumps(ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_DICT_OUTPUT) + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG = { + "values": [[1.23456789, 2.3456789], [3.456789, 4.56789], [-5.6789, 0.0000006789]], + "ids": [0, 1, 2], +} + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT = [ + {"id": 0, "value": [1.23456789, 2.3456789]}, + {"id": 1, "value": [3.456789, 4.56789]}, + {"id": 2, "value": [-5.6789, 0.0000006789]}, +] + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_JSON_OUTPUT = json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT +) + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE = { + "values": [[1.23456789, 2.3456789], [3.456789, 4.56789], [-5.6789, 0.0000006789]], + "ids": [2, 4, 6], +} + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_DICT_OUTPUT = [ + {"id": 2, "value": [1.23456789, 2.3456789]}, + {"id": 4, "value": [3.456789, 4.56789]}, + {"id": 6, "value": [-5.6789, 0.0000006789]}, +] + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_JSON_OUTPUT = json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_DICT_OUTPUT +) + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED = [ + {"id": 0, "value": [1.235, 2.346]}, + {"id": 1, "value": [3.457, 4.568]}, + {"id": 2, "value": [-5.679, 0.0]}, +] + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_JSON = json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED +) + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE = [ + {"id": 0, "value": [2.235, 3.346]}, + {"id": 1, "value": [4.457, 5.568]}, + {"id": 2, "value": [-4.679, 1.0]}, +] + +ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE_JSON = json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE +) + + +def test_create_integers(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG) + assert instance.values == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_create_array_values(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG) + assert instance.values == ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAY_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_create_arrays_of_float_values(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_create_arrays_of_float_values_rounded(): + local_ = RoundedArrayWithIds + local_.__round_precision__ = 3 + instance = local_(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_JSON + + +def test_from_values(): + instance = ArrayWithIds.from_values(ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"]) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_from_list_of_dicts(): + instance = ArrayWithIds.from_list_of_dicts(ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_JSON_OUTPUT + + +def test_from_list_of_dicts_non_consecutive(): + instance = ArrayWithIds.from_list_of_dicts( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_DICT_OUTPUT + ) + assert instance.values == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE["values"] + assert instance.ids == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE["ids"] + assert instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_DICT_OUTPUT + assert instance.to_json() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_NON_CONSECUTIVE_TO_JSON_OUTPUT + + +def test_filter_by_values(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.filter_by_values( + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][2], + ] + ) + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][2], + ] + assert instance.ids == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][2], + ] + assert instance.to_dict() == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + assert instance.to_json() == json.dumps( + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + ) + + +def test_filter_by_indices(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.filter_by_indices([0, 2]) + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][2], + ] + assert instance.ids == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][2], + ] + assert instance.to_dict() == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + assert instance.to_json() == json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[:1] + + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2:] + ) + + +def test_filter_by_ids(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.filter_by_ids([0, 2]) + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][2], + ] + assert instance.ids == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][2], + ] + assert ( + instance.to_dict() + == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[:1] + + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2:] + ) + assert instance.to_json() == json.dumps( + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[:1] + + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2:] + ) + + +def test_map_array_in_place_rounded(): + def add_one_to_each_element(value): + return [float(np.round(v + 1.0, decimals=3)) for v in value] + + instance = RoundedArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.map_array_in_place(add_one_to_each_element) + + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE[0]["value"], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE[1]["value"], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE[2]["value"], + ] + assert instance.ids == [0, 1, 2] + assert ( + instance.to_dict() == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE + ) + assert ( + instance.to_json() + == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT_ROUNDED_WITH_ADDITION_OF_ONE_JSON + ) + + +def test_add_item(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG) + instance.add_item(4) + assert instance.values == [1, 2, 3, 4] + assert instance.ids == [0, 1, 2, 3] + assert instance.to_dict() == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + [{"id": 3, "value": 4}] + assert instance.to_json() == json.dumps( + ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + [{"id": 3, "value": 4}] + ) + + instance.add_item(5, 5) + assert instance.values == [1, 2, 3, 4, 5] + assert instance.ids == [0, 1, 2, 3, 5] + assert instance.to_dict() == ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + [ + {"id": 3, "value": 4}, + {"id": 5, "value": 5}, + ] + assert instance.to_json() == json.dumps( + ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT + [{"id": 3, "value": 4}] + [{"id": 5, "value": 5}] + ) + + +def test_remove_item(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG) + instance.remove_item(1) + assert instance.values == [1, 3] + assert instance.ids == [0, 2] + assert instance.to_dict() == [ + ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] + assert instance.to_json() == json.dumps( + [ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[0], ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[2]] + ) + + instance.remove_item(0, 0) + assert instance.values == [3] + assert instance.ids == [2] + assert instance.to_dict() == [ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[2]] + assert instance.to_json() == json.dumps([ARRAY_WITH_IDS_INTEGER_VALUES_CONFIG_TO_DICT_OUTPUT[2]]) diff --git a/tests/py/unit/test_value_with_id.py b/tests/py/unit/test_value_with_id.py new file mode 100644 index 00000000..7751a33f --- /dev/null +++ b/tests/py/unit/test_value_with_id.py @@ -0,0 +1,39 @@ +from mat3ra.code.value_with_id import RoundedValueWithId, ValueWithId + +EXAMPLE_VALUE_WITH_ID_DICT = {"id": 1, "value": "value"} + +EXAMPLE_VALUE_WITH_ID_JSON = '{"id": 1, "value": "value"}' + +EXAMPLE_VALUE_WITH_ID_DICT_FLOAT = {"id": 1, "value": 1.23456789} + +EXAMPLE_VALUE_WITH_ID_DICT_FLOAT_JSON = '{"id": 1, "value": 1.23456789}' + +EXAMPLE_VALUE_WITH_ID_DICT_FLOAT_ROUNDED_JSON = '{"id": 1, "value": 1.235}' + + +def test_create(): + instance = ValueWithId(**EXAMPLE_VALUE_WITH_ID_DICT) + assert instance.id == EXAMPLE_VALUE_WITH_ID_DICT["id"] + assert instance.value == EXAMPLE_VALUE_WITH_ID_DICT["value"] + assert instance.to_dict() == EXAMPLE_VALUE_WITH_ID_DICT + + +def test_to_json(): + instance = ValueWithId(**EXAMPLE_VALUE_WITH_ID_DICT) + assert instance.to_json() == EXAMPLE_VALUE_WITH_ID_JSON + + +def test_create_float(): + instance = ValueWithId(**EXAMPLE_VALUE_WITH_ID_DICT_FLOAT) + assert instance.id == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT["id"] + assert instance.value == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT["value"] + assert instance.to_dict() == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT + + +def test_create_float_with_precision(): + local_ = RoundedValueWithId + local_.__round_precision__ = 3 + instance = local_(**EXAMPLE_VALUE_WITH_ID_DICT_FLOAT) + assert instance.id == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT["id"] + assert instance.value == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT["value"] + assert instance.to_json() == EXAMPLE_VALUE_WITH_ID_DICT_FLOAT_ROUNDED_JSON From 321827342fba0faab0c0ea16fac5cd1ff7cb4326 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 31 Mar 2025 17:30:14 -0700 Subject: [PATCH 31/41] chore: cleanup --- tests/py/unit/test_array_with_ids.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/py/unit/test_array_with_ids.py b/tests/py/unit/test_array_with_ids.py index dcfd71fb..e45d29df 100644 --- a/tests/py/unit/test_array_with_ids.py +++ b/tests/py/unit/test_array_with_ids.py @@ -178,8 +178,10 @@ def test_filter_by_indices(): ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], ] assert instance.to_json() == json.dumps( - ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[:1] - + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2:] + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] ) @@ -194,14 +196,15 @@ def test_filter_by_ids(): ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][0], ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][2], ] - assert ( - instance.to_dict() - == ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[:1] - + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2:] - ) + assert instance.to_dict() == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] assert instance.to_json() == json.dumps( - ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[:1] - + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2:] + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[0], + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[2], + ] ) From 89901f17f1daff211d9275815c86ceefedf2ee70 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 31 Mar 2025 18:50:54 -0700 Subject: [PATCH 32/41] update: fix to deep clone --- src/py/mat3ra/code/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index 93aa7219..a4a94076 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -81,7 +81,7 @@ def to_json(self, exclude: Optional[List[str]] = None) -> str: return self.model_dump_json(exclude=set(exclude) if exclude else None) def clone(self: T, extra_context: Optional[Dict[str, Any]] = None) -> T: - return self.model_copy(update=extra_context or {}) + return self.model_copy(update=extra_context or {}, deep=True) # TODO: remove in the next PR From 069ded03cec760762a999b449cdc2c93c93abc8d Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 31 Mar 2025 18:55:26 -0700 Subject: [PATCH 33/41] update: add test --- src/py/mat3ra/code/entity.py | 4 ++-- tests/py/unit/test_entity.py | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/py/mat3ra/code/entity.py b/src/py/mat3ra/code/entity.py index a4a94076..f4ae9e91 100644 --- a/src/py/mat3ra/code/entity.py +++ b/src/py/mat3ra/code/entity.py @@ -80,8 +80,8 @@ def to_dict(self, exclude: Optional[List[str]] = None) -> Dict[str, Any]: def to_json(self, exclude: Optional[List[str]] = None) -> str: return self.model_dump_json(exclude=set(exclude) if exclude else None) - def clone(self: T, extra_context: Optional[Dict[str, Any]] = None) -> T: - return self.model_copy(update=extra_context or {}, deep=True) + def clone(self: T, extra_context: Optional[Dict[str, Any]] = None, deep=True) -> T: + return self.model_copy(update=extra_context or {}, deep=deep) # TODO: remove in the next PR diff --git a/tests/py/unit/test_entity.py b/tests/py/unit/test_entity.py index e3ba87a6..b8d669e7 100644 --- a/tests/py/unit/test_entity.py +++ b/tests/py/unit/test_entity.py @@ -141,14 +141,27 @@ def test_to_json(): def test_clone(): entity = ExampleClass.create(REFERENCE_OBJECT_VALID) # Test clone method - cloned_entity = entity.clone() + cloned_entity = entity.clone(deep=False) assert isinstance(cloned_entity, ExampleClass) assert cloned_entity.key1 == entity.key1 assert cloned_entity.key2 == entity.key2 # Test clone with extra context extra_context = {"key2": 2} - cloned_entity_with_extra = entity.clone(extra_context=extra_context) + cloned_entity_with_extra = entity.clone(extra_context=extra_context, deep=False) assert isinstance(cloned_entity_with_extra, ExampleClass) assert cloned_entity_with_extra.key1 == entity.key1 assert cloned_entity_with_extra.key2 == 2 # Should override to 2 + + +def test_clone_deep(): + entity = ExampleClass.create(REFERENCE_OBJECT_VALID) + # Test clone with deep=True + cloned_entity_deep = entity.clone(deep=True) + assert isinstance(cloned_entity_deep, ExampleClass) + assert cloned_entity_deep.key1 == entity.key1 + assert cloned_entity_deep.key2 == entity.key2 + + cloned_entity_deep.key1 = "adjusted_value" + assert entity.key1 == "value1" + assert cloned_entity_deep.key1 == "adjusted_value" From c89d16f19a2ea1cefc2015795aceeb74698927df Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 1 Apr 2025 16:22:41 -0700 Subject: [PATCH 34/41] chore: invert ids filter option --- src/py/mat3ra/code/array_with_ids.py | 7 +++++-- tests/py/unit/test_array_with_ids.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/py/mat3ra/code/array_with_ids.py b/src/py/mat3ra/code/array_with_ids.py index 5982bb5f..087052c0 100644 --- a/src/py/mat3ra/code/array_with_ids.py +++ b/src/py/mat3ra/code/array_with_ids.py @@ -74,10 +74,13 @@ def filter_by_indices(self, indices: Union[List[int], int]): self.values = [self.values[i] for i in range(len(self.values)) if i in index_set] self.ids = [self.ids[i] for i in range(len(self.ids)) if i in index_set] - def filter_by_ids(self, ids: Union[List[int], int]): + def filter_by_ids(self, ids: Union[List[int], int], invert: bool = False): if isinstance(ids, int): ids = [ids] - ids_set = set(ids) + if not invert: + ids_set = set(ids) + else: + ids_set = set(self.ids) - set(ids) keep_indices = [index for index, id_ in enumerate(self.ids) if id_ in ids_set] self.values = [self.values[index] for index in keep_indices] self.ids = [self.ids[index] for index in keep_indices] diff --git a/tests/py/unit/test_array_with_ids.py b/tests/py/unit/test_array_with_ids.py index e45d29df..676a24e7 100644 --- a/tests/py/unit/test_array_with_ids.py +++ b/tests/py/unit/test_array_with_ids.py @@ -208,6 +208,25 @@ def test_filter_by_ids(): ) +def test_filter_by_ids_invert(): + instance = ArrayWithIds(**ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG) + instance.filter_by_ids([0, 2], invert=True) + assert instance.values == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["values"][1], + ] + assert instance.ids == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG["ids"][1], + ] + assert instance.to_dict() == [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[1], + ] + assert instance.to_json() == json.dumps( + [ + ARRAY_WITH_IDS_ARRAYS_OF_FLOAT_VALUES_CONFIG_TO_DICT_OUTPUT[1], + ] + ) + + def test_map_array_in_place_rounded(): def add_one_to_each_element(value): return [float(np.round(v + 1.0, decimals=3)) for v in value] From 4fb20fcd5eafcd9996d3730d0c5dbbbbb33b3e64 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 2 Apr 2025 13:56:07 -0700 Subject: [PATCH 35/41] chore: add vector3d --- src/py/mat3ra/code/constants.py | 19 ++++++++++------- src/py/mat3ra/code/vector.py | 16 ++++++++++++++ tests/py/unit/test_vector.py | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 src/py/mat3ra/code/vector.py create mode 100644 tests/py/unit/test_vector.py diff --git a/src/py/mat3ra/code/constants.py b/src/py/mat3ra/code/constants.py index 0fd18284..eb6fb316 100644 --- a/src/py/mat3ra/code/constants.py +++ b/src/py/mat3ra/code/constants.py @@ -1,5 +1,7 @@ from math import pi +from mat3ra.esse.models.definitions.constants import FundamentalConstants + class Coefficients: # Same as used in: JS/TS @@ -13,18 +15,19 @@ class Coefficients: # and originally taken from https://github.com/hplgit/physical-quantities/blob/master/PhysicalQuantities.py # Internal, for convenience purposes - _c = 299792458.0 # speed of light, m/s - _mu0 = 4.0e-7 * pi # permeability of vacuum - _eps0 = 1 / _mu0 / _c**2 # permittivity of vacuum - _Grav = 6.67259e-11 # gravitational constant - _hplanck = 6.6260755e-34 # Planck constant, J s - _hbar = _hplanck / (2 * pi) # Planck constant / 2pi, J s - _e = 1.60217733e-19 # elementary charge - _me = 9.1093897e-31 # electron mass + _c = FundamentalConstants.c # speed of light, m/s + _Grav = FundamentalConstants.G # gravitational constant + _hplanck = FundamentalConstants.h # Planck constant, J s + _e = FundamentalConstants.e # elementary charge + _me = FundamentalConstants.me # electron mass + _mu0 = 4.0e-7 * pi # permeability of vacuum, atomic units + _mp = 1.6726231e-27 # proton mass _Nav = 6.0221367e23 # Avogadro number _k = 1.380658e-23 # Boltzmann constant, J/K _amu = 1.6605402e-27 # atomic mass unit, kg + _eps0 = 1 / _mu0 / _c**2 # permittivity of vacuum + _hbar = _hplanck / (2 * pi) # Planck constant / 2pi, J s # External BOHR = 4e10 * pi * _eps0 * _hbar**2 / _me / _e**2 # Bohr radius in angstrom diff --git a/src/py/mat3ra/code/vector.py b/src/py/mat3ra/code/vector.py new file mode 100644 index 00000000..ea8b7211 --- /dev/null +++ b/src/py/mat3ra/code/vector.py @@ -0,0 +1,16 @@ +from typing import List + +from mat3ra.esse.models.core.abstract.point import PointSchema as Vector3DSchema +from mat3ra.utils.mixins import RoundNumericValuesMixin +from pydantic import model_serializer + + +class Vector3D(Vector3DSchema): + pass + + +class RoundedVector3D(RoundNumericValuesMixin, Vector3D): + @model_serializer + def to_dict(self, skip_rounding: bool = False) -> List[float]: + rounded_value = self.round_array_or_number(self.root) if not skip_rounding else self.root + return Vector3D(root=rounded_value).model_dump() diff --git a/tests/py/unit/test_vector.py b/tests/py/unit/test_vector.py new file mode 100644 index 00000000..a0ff8e0d --- /dev/null +++ b/tests/py/unit/test_vector.py @@ -0,0 +1,37 @@ +from mat3ra.code.vector import RoundedVector3D, Vector3D + +VECTOR_FLOAT = [1.234567890, 2.345678901, 3.456789012] +VECTOR_FLOAT_ROUNDED_4 = [1.2346, 2.3457, 3.4568] +VECTOR_FLOAT_ROUNDED_3 = [1.235, 2.346, 3.457] + + +def test_vector_init(): + vector = Vector3D(root=VECTOR_FLOAT) + assert vector.model_dump() == VECTOR_FLOAT + + +def test_vector_init_wrong_type(): + try: + _ = Vector3D(root=[1, 2, "3"]) + except Exception as e: + assert str(e) == "3 is not of type float" + + +def test_vector_init_wrong_size(): + try: + _ = Vector3D(root=[1, 2]) + assert False + except Exception: + assert True + + +def test_rounded_vector_serialization(): + class_reference = RoundedVector3D + class_reference.__round_precision__ = 4 + vector = class_reference(root=VECTOR_FLOAT) + assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_4 + + class_reference = RoundedVector3D + class_reference.__round_precision__ = 3 + vector = class_reference(root=VECTOR_FLOAT) + assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_3 From 043d1cd931af86bb3009371988ba3459cab2ec3f Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 2 Apr 2025 14:11:15 -0700 Subject: [PATCH 36/41] chore: add constants test --- src/py/mat3ra/code/constants.py | 12 +++++++----- tests/py/unit/test_constants.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 tests/py/unit/test_constants.py diff --git a/src/py/mat3ra/code/constants.py b/src/py/mat3ra/code/constants.py index eb6fb316..5081fe6b 100644 --- a/src/py/mat3ra/code/constants.py +++ b/src/py/mat3ra/code/constants.py @@ -2,6 +2,8 @@ from mat3ra.esse.models.definitions.constants import FundamentalConstants +CONSTANTS = FundamentalConstants() + class Coefficients: # Same as used in: JS/TS @@ -15,11 +17,11 @@ class Coefficients: # and originally taken from https://github.com/hplgit/physical-quantities/blob/master/PhysicalQuantities.py # Internal, for convenience purposes - _c = FundamentalConstants.c # speed of light, m/s - _Grav = FundamentalConstants.G # gravitational constant - _hplanck = FundamentalConstants.h # Planck constant, J s - _e = FundamentalConstants.e # elementary charge - _me = FundamentalConstants.me # electron mass + _c = CONSTANTS.c # speed of light, m/s + _Grav = CONSTANTS.G # gravitational constant + _hplanck = CONSTANTS.h # Planck constant, J s + _e = CONSTANTS.e # elementary charge + _me = CONSTANTS.me # electron mass _mu0 = 4.0e-7 * pi # permeability of vacuum, atomic units _mp = 1.6726231e-27 # proton mass diff --git a/tests/py/unit/test_constants.py b/tests/py/unit/test_constants.py new file mode 100644 index 00000000..662ce2a6 --- /dev/null +++ b/tests/py/unit/test_constants.py @@ -0,0 +1,12 @@ +from mat3ra.code.constants import FundamentalConstants + + +def test_constants(): + CONSTANTS = FundamentalConstants() + assert CONSTANTS.c == 299792458.0 + assert CONSTANTS.h == 6.62607015e-34 + assert CONSTANTS.e == 1.602176634e-19 + assert CONSTANTS.me == 9.109383713928e-31 + assert CONSTANTS.G == 6.6743015e-11 + assert CONSTANTS.eps0 == 8.854187818814e-12 + assert CONSTANTS.mu0 == 1.256637061272e-6 From 2598779a475eff65b2ffb265b7b81840b7f36231 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 2 Apr 2025 16:04:40 -0700 Subject: [PATCH 37/41] chore: add value_rounded --- src/py/mat3ra/code/vector.py | 8 ++++++++ tests/py/unit/test_vector.py | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/src/py/mat3ra/code/vector.py b/src/py/mat3ra/code/vector.py index ea8b7211..45386396 100644 --- a/src/py/mat3ra/code/vector.py +++ b/src/py/mat3ra/code/vector.py @@ -8,9 +8,17 @@ class Vector3D(Vector3DSchema): pass + @property + def value(self): + return self.root + class RoundedVector3D(RoundNumericValuesMixin, Vector3D): @model_serializer def to_dict(self, skip_rounding: bool = False) -> List[float]: rounded_value = self.round_array_or_number(self.root) if not skip_rounding else self.root return Vector3D(root=rounded_value).model_dump() + + @property + def value_rounded(self): + return self.to_dict() diff --git a/tests/py/unit/test_vector.py b/tests/py/unit/test_vector.py index a0ff8e0d..b18a5a64 100644 --- a/tests/py/unit/test_vector.py +++ b/tests/py/unit/test_vector.py @@ -30,8 +30,12 @@ def test_rounded_vector_serialization(): class_reference.__round_precision__ = 4 vector = class_reference(root=VECTOR_FLOAT) assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_4 + assert vector.value_rounded == VECTOR_FLOAT_ROUNDED_4 + assert vector.value == VECTOR_FLOAT class_reference = RoundedVector3D class_reference.__round_precision__ = 3 vector = class_reference(root=VECTOR_FLOAT) assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_3 + assert vector.value_rounded == VECTOR_FLOAT_ROUNDED_3 + assert vector.value == VECTOR_FLOAT From 69505912b878d9e92461b9b4196ae422a67fcb56 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 2 Apr 2025 16:23:48 -0700 Subject: [PATCH 38/41] chore: simplify init --- src/py/mat3ra/code/vector.py | 6 +++++- tests/py/unit/test_vector.py | 13 +++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/py/mat3ra/code/vector.py b/src/py/mat3ra/code/vector.py index 45386396..f5ba74ac 100644 --- a/src/py/mat3ra/code/vector.py +++ b/src/py/mat3ra/code/vector.py @@ -6,7 +6,8 @@ class Vector3D(Vector3DSchema): - pass + def __init__(self, root: List[float]): + super().__init__(root=root) @property def value(self): @@ -14,6 +15,9 @@ def value(self): class RoundedVector3D(RoundNumericValuesMixin, Vector3D): + def __init__(self, root: List[float]): + super().__init__(root=root) + @model_serializer def to_dict(self, skip_rounding: bool = False) -> List[float]: rounded_value = self.round_array_or_number(self.root) if not skip_rounding else self.root diff --git a/tests/py/unit/test_vector.py b/tests/py/unit/test_vector.py index b18a5a64..a4bc1efb 100644 --- a/tests/py/unit/test_vector.py +++ b/tests/py/unit/test_vector.py @@ -6,7 +6,7 @@ def test_vector_init(): - vector = Vector3D(root=VECTOR_FLOAT) + vector = Vector3D(VECTOR_FLOAT) assert vector.model_dump() == VECTOR_FLOAT @@ -19,23 +19,28 @@ def test_vector_init_wrong_type(): def test_vector_init_wrong_size(): try: - _ = Vector3D(root=[1, 2]) + _ = Vector3D([1, 2]) assert False except Exception: assert True +def test_rounded_vector_init(): + vector = RoundedVector3D(VECTOR_FLOAT) + assert vector.model_dump() == VECTOR_FLOAT + + def test_rounded_vector_serialization(): class_reference = RoundedVector3D class_reference.__round_precision__ = 4 - vector = class_reference(root=VECTOR_FLOAT) + vector = class_reference(VECTOR_FLOAT) assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_4 assert vector.value_rounded == VECTOR_FLOAT_ROUNDED_4 assert vector.value == VECTOR_FLOAT class_reference = RoundedVector3D class_reference.__round_precision__ = 3 - vector = class_reference(root=VECTOR_FLOAT) + vector = class_reference(VECTOR_FLOAT) assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_3 assert vector.value_rounded == VECTOR_FLOAT_ROUNDED_3 assert vector.value == VECTOR_FLOAT From a9114023c76b86abd86979c71e2cfaf0f91837f0 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 2 Apr 2025 16:37:59 -0700 Subject: [PATCH 39/41] chore: add xyz to vector --- src/py/mat3ra/code/vector.py | 24 ++++++++++++++++++++++++ tests/py/unit/test_vector.py | 17 +++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/py/mat3ra/code/vector.py b/src/py/mat3ra/code/vector.py index f5ba74ac..246a6166 100644 --- a/src/py/mat3ra/code/vector.py +++ b/src/py/mat3ra/code/vector.py @@ -13,6 +13,18 @@ def __init__(self, root: List[float]): def value(self): return self.root + @property + def x(self): + return self.root[0] + + @property + def y(self): + return self.root[1] + + @property + def z(self): + return self.root[2] + class RoundedVector3D(RoundNumericValuesMixin, Vector3D): def __init__(self, root: List[float]): @@ -26,3 +38,15 @@ def to_dict(self, skip_rounding: bool = False) -> List[float]: @property def value_rounded(self): return self.to_dict() + + @property + def x_rounded(self): + return self.value_rounded[0] + + @property + def y_rounded(self): + return self.value_rounded[1] + + @property + def z_rounded(self): + return self.value_rounded[2] diff --git a/tests/py/unit/test_vector.py b/tests/py/unit/test_vector.py index a4bc1efb..e2464c4b 100644 --- a/tests/py/unit/test_vector.py +++ b/tests/py/unit/test_vector.py @@ -8,6 +8,10 @@ def test_vector_init(): vector = Vector3D(VECTOR_FLOAT) assert vector.model_dump() == VECTOR_FLOAT + assert vector.value == VECTOR_FLOAT + assert vector.x == 1.234567890 + assert vector.y == 2.345678901 + assert vector.z == 3.456789012 def test_vector_init_wrong_type(): @@ -28,6 +32,7 @@ def test_vector_init_wrong_size(): def test_rounded_vector_init(): vector = RoundedVector3D(VECTOR_FLOAT) assert vector.model_dump() == VECTOR_FLOAT + assert vector.value == VECTOR_FLOAT def test_rounded_vector_serialization(): @@ -36,6 +41,12 @@ def test_rounded_vector_serialization(): vector = class_reference(VECTOR_FLOAT) assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_4 assert vector.value_rounded == VECTOR_FLOAT_ROUNDED_4 + assert vector.x == VECTOR_FLOAT[0] + assert vector.y == VECTOR_FLOAT[1] + assert vector.z == VECTOR_FLOAT[2] + assert vector.x_rounded == VECTOR_FLOAT_ROUNDED_4[0] + assert vector.y_rounded == VECTOR_FLOAT_ROUNDED_4[1] + assert vector.z_rounded == VECTOR_FLOAT_ROUNDED_4[2] assert vector.value == VECTOR_FLOAT class_reference = RoundedVector3D @@ -44,3 +55,9 @@ def test_rounded_vector_serialization(): assert vector.model_dump() == VECTOR_FLOAT_ROUNDED_3 assert vector.value_rounded == VECTOR_FLOAT_ROUNDED_3 assert vector.value == VECTOR_FLOAT + assert vector.x == VECTOR_FLOAT[0] + assert vector.y == VECTOR_FLOAT[1] + assert vector.z == VECTOR_FLOAT[2] + assert vector.x_rounded == VECTOR_FLOAT_ROUNDED_3[0] + assert vector.y_rounded == VECTOR_FLOAT_ROUNDED_3[1] + assert vector.z_rounded == VECTOR_FLOAT_ROUNDED_3[2] From 6608d9ba09ca9a7f880cab5e84bb8a92a6a6915c Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 2 Apr 2025 18:08:53 -0700 Subject: [PATCH 40/41] chore: fix tests --- src/py/mat3ra/code/vector.py | 14 ++++++++++++++ tests/py/unit/test_vector.py | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/src/py/mat3ra/code/vector.py b/src/py/mat3ra/code/vector.py index 246a6166..d3d824b4 100644 --- a/src/py/mat3ra/code/vector.py +++ b/src/py/mat3ra/code/vector.py @@ -1,11 +1,14 @@ from typing import List +import numpy as np from mat3ra.esse.models.core.abstract.point import PointSchema as Vector3DSchema from mat3ra.utils.mixins import RoundNumericValuesMixin from pydantic import model_serializer class Vector3D(Vector3DSchema): + __atol__ = 1e-8 + def __init__(self, root: List[float]): super().__init__(root=root) @@ -25,6 +28,11 @@ def y(self): def z(self): return self.root[2] + def __eq__(self, other): + if isinstance(other, list): + other = Vector3D(other) + return np.allclose(self.root, other.root, atol=self.__atol__, rtol=0) + class RoundedVector3D(RoundNumericValuesMixin, Vector3D): def __init__(self, root: List[float]): @@ -50,3 +58,9 @@ def y_rounded(self): @property def z_rounded(self): return self.value_rounded[2] + + def __eq__(self, other): + if isinstance(other, list): + other = RoundedVector3D(other) + atol = self.__atol__ or 10 ** (-self.__round_precision__) + return np.allclose(self.value_rounded, other.value_rounded, atol=atol, rtol=0) diff --git a/tests/py/unit/test_vector.py b/tests/py/unit/test_vector.py index e2464c4b..b10ec225 100644 --- a/tests/py/unit/test_vector.py +++ b/tests/py/unit/test_vector.py @@ -1,6 +1,8 @@ from mat3ra.code.vector import RoundedVector3D, Vector3D VECTOR_FLOAT = [1.234567890, 2.345678901, 3.456789012] +VECTOR_FLOAT_DIFFERENT_WITHIN_TOL = [1.23456789999, 2.345678901, 3.456789012] +VECTOR_FLOAT_DIFFERENT_OUTSIDE_TOL = [1.2345699999, 2.345678901, 3.456789012] VECTOR_FLOAT_ROUNDED_4 = [1.2346, 2.3457, 3.4568] VECTOR_FLOAT_ROUNDED_3 = [1.235, 2.346, 3.457] @@ -29,6 +31,13 @@ def test_vector_init_wrong_size(): assert True +def test_vector_equality(): + vector = Vector3D(VECTOR_FLOAT) + assert vector == VECTOR_FLOAT + assert vector == Vector3D(VECTOR_FLOAT_DIFFERENT_WITHIN_TOL) + assert vector != Vector3D(VECTOR_FLOAT_DIFFERENT_OUTSIDE_TOL) + + def test_rounded_vector_init(): vector = RoundedVector3D(VECTOR_FLOAT) assert vector.model_dump() == VECTOR_FLOAT From d189de2acb9bf29b22a3d280c53ad22a48f35251 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 3 Apr 2025 13:43:26 -0700 Subject: [PATCH 41/41] chore: update pyproject --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 89f159e3..96262283 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "numpy", "jsonschema>=2.6.0", "pydantic>=2.10.5", - "mat3ra-esse @ git+https://github.com/Exabyte-io/esse.git@feature/SOF-7570-2", + "mat3ra-esse", "mat3ra-utils>=2024.5.15.post0", ]