Skip to content

Commit

Permalink
Additional tests for coverage and documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
subhashb committed Jul 19, 2024
1 parent 18d1b1b commit 13acb55
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 34 deletions.
19 changes: 16 additions & 3 deletions src/protean/core/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ 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
# would be made available across all subclasses,
# defeating its purpose.
setattr(subclass, "_projections", defaultdict(set))

# Event-Sourcing Functionality
#
# Store associated events
setattr(subclass, "_events_cls_map", {})

Expand Down Expand Up @@ -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
Expand All @@ -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])
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 8 additions & 5 deletions src/protean/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 #
Expand Down Expand Up @@ -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)

#######################
Expand Down
2 changes: 2 additions & 0 deletions src/protean/fields/association.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand Down
26 changes: 1 addition & 25 deletions src/protean/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
to the maximum extent possible.
"""

import functools
import importlib
import logging
from datetime import UTC, datetime
Expand Down Expand Up @@ -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__])
Expand All @@ -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 []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -214,7 +192,5 @@ def generate_identity(
"fully_qualified_name",
"generate_identity",
"get_version",
"import_from_full_path",
"singleton",
"utcnow_func",
]
7 changes: 6 additions & 1 deletion tests/test_containers.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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):
Expand Down
56 changes: 56 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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'"

0 comments on commit 13acb55

Please sign in to comment.