diff --git a/src/protean/core/aggregate.py b/src/protean/core/aggregate.py index c88d05f8..387345bb 100644 --- a/src/protean/core/aggregate.py +++ b/src/protean/core/aggregate.py @@ -64,6 +64,8 @@ def __new__(cls, *args, **kwargs): def __init_subclass__(subclass) -> None: super().__init_subclass__() + # Event-Sourcing Functionality + # # Associate a `_projections` map with subclasses. # It needs to be initialized here because if it # were initialized in __init__, the same collection object @@ -71,6 +73,8 @@ def __init_subclass__(subclass) -> None: # defeating its purpose. setattr(subclass, "_projections", defaultdict(set)) + # Event-Sourcing Functionality + # # Store associated events setattr(subclass, "_events_cls_map", {}) @@ -99,7 +103,9 @@ def _default_options(cls): ] def _apply(self, event: BaseEvent) -> None: - """Apply the event onto the aggregate by calling the appropriate projection. + """Event-Sourcing Functionality + + Apply the event onto the aggregate by calling the appropriate projection. Args: event (BaseEvent): Event object to apply @@ -120,7 +126,10 @@ def _apply(self, event: BaseEvent) -> None: @classmethod def from_events(cls, events: List[BaseEvent]) -> "BaseAggregate": - """Reconstruct an aggregate from a list of events.""" + """Event-Sourcing Functionality + + Reconstruct an aggregate from a list of events. + """ # Initialize the aggregate with the first event's payload and apply it aggregate = cls(**events[0].payload) aggregate._apply(events[0]) @@ -210,6 +219,7 @@ def aggregate_factory(element_cls, domain, **opts): f"{domain.normalized_name}::{element_cls.meta_.stream_category}" ) + # Event-Sourcing Functionality # Iterate through methods marked as `@apply` and construct a projections map methods = inspect.getmembers(element_cls, predicate=inspect.isroutine) for method_name, method in methods: @@ -240,7 +250,10 @@ def __exit__(self, *args): def apply(fn): - """Decorator to mark methods in EventHandler classes.""" + """Event-Sourcing Functionality + + Decorator to mark methods in EventHandler classes. + """ if len(typing.get_type_hints(fn)) > 2: raise IncorrectUsageError( diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index d45ea172..a0c2545f 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -968,11 +968,12 @@ def _setup_event_handlers(self): def _generate_fact_event_classes(self): """Generate FactEvent classes for all aggregates with `fact_events` enabled""" - for element_type in [DomainObjects.AGGREGATE]: - for _, element in self.registry._elements[element_type.value].items(): - if element.cls.meta_.fact_events: - event_cls = element_to_fact_event(element.cls) - self.register(event_cls, part_of=element.cls) + for _, element in self.registry._elements[ + DomainObjects.AGGREGATE.value + ].items(): + if element.cls.meta_.fact_events: + event_cls = element_to_fact_event(element.cls) + self.register(event_cls, part_of=element.cls) ###################### # Element Decorators # @@ -1209,8 +1210,10 @@ def repository_for(self, element_cls) -> BaseRepository: element_cls.element_type == DomainObjects.AGGREGATE and element_cls.meta_.is_event_sourced ): + # Return an Event Sourced repository return self.event_store.repository_for(element_cls) else: + # This is a regular aggregate or a view return self.providers.repository_for(element_cls) ####################### diff --git a/src/protean/fields/association.py b/src/protean/fields/association.py index 4278c165..83c3a78c 100644 --- a/src/protean/fields/association.py +++ b/src/protean/fields/association.py @@ -377,6 +377,7 @@ def __set__(self, instance, value): The `temp_cache` we set up here is eventually used by the `Repository` to determine the changes to be persisted. """ + # Accept dictionary values and convert them to Entity objects if isinstance(value, dict): value = self.to_cls(**value) @@ -485,6 +486,7 @@ def __set__(self, instance, value): """ value = value if isinstance(value, list) else [value] + # Accept dictionary values and convert them to Entity objects values = [] for item in value: if isinstance(item, dict): diff --git a/src/protean/utils/__init__.py b/src/protean/utils/__init__.py index 9182cbda..7fed2290 100644 --- a/src/protean/utils/__init__.py +++ b/src/protean/utils/__init__.py @@ -4,7 +4,6 @@ to the maximum extent possible. """ -import functools import importlib import logging from datetime import UTC, datetime @@ -73,14 +72,6 @@ def get_version(): return importlib.metadata.version("protean") -def import_from_full_path(domain, path): - spec = importlib.util.spec_from_file_location(domain, path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - - return getattr(mod, domain) - - def fully_qualified_name(cls): """Return Fully Qualified name along with module""" return ".".join([cls.__module__, cls.__qualname__]) @@ -89,19 +80,6 @@ def fully_qualified_name(cls): fqn = fully_qualified_name -def singleton(cls): - """Make a class a Singleton class (only one instance)""" - - @functools.wraps(cls) - def wrapper_singleton(*args, **kwargs): - if not wrapper_singleton.instance: - wrapper_singleton.instance = cls(*args, **kwargs) - return wrapper_singleton.instance - - wrapper_singleton.instance = None - return wrapper_singleton - - def convert_str_values_to_list(value): if not value: return [] @@ -184,7 +162,7 @@ def generate_identity( elif id_type == IdentityType.UUID.value: id_value = uuid4() else: - raise ConfigurationError(f"Unknown Identity Type {id_type}") + raise ConfigurationError(f"Unknown Identity Type '{id_type}'") # Function Strategy elif id_strategy == IdentityStrategy.FUNCTION.value: @@ -214,7 +192,5 @@ def generate_identity( "fully_qualified_name", "generate_identity", "get_version", - "import_from_full_path", - "singleton", "utcnow_func", ] diff --git a/tests/test_containers.py b/tests/test_containers.py index 4a5bd44b..40eebb53 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,7 +1,7 @@ import pytest from protean.container import BaseContainer, OptionsMixin -from protean.exceptions import InvalidDataError +from protean.exceptions import InvalidDataError, NotSupportedError from protean.fields import Integer, String from protean.reflection import declared_fields @@ -18,6 +18,11 @@ class CustomContainer(CustomContainerMeta, OptionsMixin): bar = String() +def test_that_base_container_class_cannot_be_instantiated(): + with pytest.raises(NotSupportedError): + BaseContainer() + + class TestContainerInitialization: def test_that_base_container_class_cannot_be_instantiated(self): with pytest.raises(TypeError): diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..0eece996 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,56 @@ +import pytest + +from protean.exceptions import ConfigurationError +from protean.utils import convert_str_values_to_list, generate_identity + + +def test_convert_str_values_to_list(): + # Test when value is None + assert convert_str_values_to_list(None) == [] + + # Test when value is an empty string + assert convert_str_values_to_list("") == [] + + # Test when value is a non-empty string + assert convert_str_values_to_list("test") == ["test"] + + # Test when value is a list of strings + assert convert_str_values_to_list(["a", "b"]) == ["a", "b"] + + # Test when value is a list of integers + assert convert_str_values_to_list([1, 2, 3]) == [1, 2, 3] + + # Test when value is a tuple + assert convert_str_values_to_list((1, 2, 3)) == [1, 2, 3] + + # Test when value is a set + assert convert_str_values_to_list({1, 2, 3}) == [1, 2, 3] + + # Test when value is a dictionary + assert convert_str_values_to_list({"a": 1, "b": 2}) == ["a", "b"] + + # Test when value is an integer (not iterable) + with pytest.raises(TypeError): + convert_str_values_to_list(10) + + # Test when value is a float (not iterable) + with pytest.raises(TypeError): + convert_str_values_to_list(10.5) + + # Test when value is a boolean + with pytest.raises(TypeError): + convert_str_values_to_list(True) + + # Test when value is an object + class TestObject: + pass + + with pytest.raises(TypeError): + convert_str_values_to_list(TestObject()) + + +def test_unknown_identity_type_raises_exception(): + with pytest.raises(ConfigurationError) as exc: + generate_identity(identity_type="foo") + + assert str(exc.value) == "Unknown Identity Type 'foo'"