diff --git a/README.md b/README.md index 6854f37..7a8a38b 100644 --- a/README.md +++ b/README.md @@ -142,14 +142,12 @@ ProductId = NewType('ProductId', int) BasketId = NewType('BasketId', int) -@dataclass(kw_only=True) class Product(Entity): product_id: ProductId name: ProductName amount: float = 0 -@dataclass(kw_only=True) class Basket(Aggregate): basket_id: BasketId items: dict[ProductId, Product] = field(default_factory=dict) @@ -228,7 +226,6 @@ ProductId = NewType('ProductId', int) BasketId = NewType('BasketId', int) -@dataclass(kw_only=True) class Product(Entity): product_id: ProductId name: ProductName @@ -240,7 +237,6 @@ class Basket(Aggregate): basket_id: BasketId items: dict[ProductId, Product] = field(default_factory=dict) - @dataclass(frozen=True, kw_only=True) class Created(AggregateEvent): """Basket created event""" diff --git a/pyproject.toml b/pyproject.toml index 7015931..074a5e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -305,6 +305,7 @@ reportUnnecessaryIsInstance = false # reportUnknownArgumentType = false reportUninitializedInstanceVariable = false reportUnusedParameter = false +reportUntypedFunctionDecorator = false [tool.pytest.ini_options] python_files = ["*.py"] diff --git a/src/dddkit/dataclasses/aggregates.py b/src/dddkit/dataclasses/aggregates.py index d7754d3..3a74876 100644 --- a/src/dddkit/dataclasses/aggregates.py +++ b/src/dddkit/dataclasses/aggregates.py @@ -6,7 +6,6 @@ BasketId = NewType('BasketId', UUID) - @dataclass(kw_only=True) class Basket(Aggregate): basket_id: BasketId @@ -37,12 +36,20 @@ def delete(self) -> None: self.add_event(self.Deleted(basket_id=self.basket_id)) """ +import inspect import zoneinfo +from collections.abc import Callable, Generator from dataclasses import dataclass, field from datetime import datetime -from typing import TypeVar +from functools import wraps +from types import SimpleNamespace +from typing import ParamSpec, TypeVar, cast -A = TypeVar('A', bound='Aggregate') +from dddkit.exceptions import CheckFailedError, CheckMustReturnBoolError + +P = ParamSpec('P') +R = bool | tuple[bool, str] +T = TypeVar('T', bound=Callable[..., R]) @dataclass(frozen=True, kw_only=True) @@ -51,9 +58,28 @@ class AggregateEvent: occurred_on: datetime = field(default_factory=lambda: datetime.now(zoneinfo.ZoneInfo('UTC'))) + def __init_subclass__(cls) -> None: + dataclass(cls, frozen=True, kw_only=True) + + +class CheckableObject(SimpleNamespace): + def __post_init__(self) -> None: + self.__check() + + def __check(self) -> None: + for _check in self.__obtain_checks(): + _check() + + def __obtain_checks(self) -> Generator[Callable[..., bool | tuple[bool, str]], None, None]: + for _method_name, method in inspect.getmembers( + self, predicate=lambda v: inspect.ismethod(v) and not v.__name__.startswith('_') + ): + if getattr(method, '_check', False): + yield cast(Callable[..., bool | tuple[bool, str]], method) + @dataclass(kw_only=True) -class Aggregate: +class Aggregate(CheckableObject): """Aggregate. Key characteristics: @@ -69,6 +95,9 @@ class Aggregate: _events: list[AggregateEvent] = field(default_factory=list, init=False, repr=False, compare=False) + def __init_subclass__(cls) -> None: + dataclass(cls, kw_only=True) + def get_events(self) -> list[AggregateEvent]: return self._events @@ -79,8 +108,7 @@ def add_event(self, event: AggregateEvent) -> None: self._events.append(event) -@dataclass(kw_only=True) -class Entity: +class Entity(CheckableObject): """Entity. Key characteristics: @@ -92,9 +120,11 @@ class Entity: * May contain logic """ + def __init_subclass__(cls) -> None: + dataclass(cls, kw_only=True) -@dataclass(frozen=True, kw_only=True) -class ValueObject: + +class ValueObject(CheckableObject): """Value object. Key characteristics: @@ -104,3 +134,35 @@ class ValueObject: * Can validate itself. * Can represent itself in different formats. """ + + def __init_subclass__(cls) -> None: + dataclass(cls, frozen=True, kw_only=True) + + +def check(func: T | None = None, *, exception_type: type[Exception] | None = None) -> Callable[[T], T] | T: + def decorator(f: T) -> T: + @wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + result = f(*args, **kwargs) + message = '' + + if isinstance(result, tuple): + result, message = result + + if not isinstance(result, bool): + raise CheckMustReturnBoolError + + if not result: + if exception_type: + raise exception_type(message) + raise CheckFailedError(message or f'Check failed: {f.__name__}') + + return result + + wrapper._check = True # pyright: ignore[reportAttributeAccessIssue] + + return wrapper # pyright: ignore[reportReturnType] + + if func is None: + return decorator + return decorator(func) diff --git a/src/dddkit/exceptions.py b/src/dddkit/exceptions.py index 1a4daba..f384edc 100644 --- a/src/dddkit/exceptions.py +++ b/src/dddkit/exceptions.py @@ -1,3 +1,6 @@ +CHECK_MUST_RETURN_BOOL_MSG = 'Invariants must return a boolean value' + + class DDDKitError(Exception): """Base class for all DDDKit exceptions.""" @@ -14,3 +17,12 @@ def __init__(self, package: str, install_package: str | None = None, extra: str f"`pip install 'dddkit[{extra or install_package or package}]'` to install dddkit with the required extra " f"or 'pip install {install_package or package}' to install the package separately" ) + + +class CheckMustReturnBoolError(DDDKitError): + def __init__(self) -> None: + super().__init__(CHECK_MUST_RETURN_BOOL_MSG) + + +class CheckFailedError(DDDKitError): + """Error: check failed.""" diff --git a/src/dddkit/stories/hooks.py b/src/dddkit/stories/hooks.py index b7fdb5f..ada5eed 100644 --- a/src/dddkit/stories/hooks.py +++ b/src/dddkit/stories/hooks.py @@ -1,3 +1,8 @@ +"""Hooks are needed to implement Domain Probe. + +https://martinfowler.com/articles/domain-oriented-observability.html +""" + from __future__ import annotations import time diff --git a/tests/dataclasses/conftest.py b/tests/dataclasses/conftest.py index 55b2681..ee9618a 100644 --- a/tests/dataclasses/conftest.py +++ b/tests/dataclasses/conftest.py @@ -18,19 +18,15 @@ class BasketChanged(DomainEvent): class Basket(Aggregate): basket_id: BasketId - @dataclass(frozen=True, kw_only=True) class Created(AggregateEvent): """Event for basket creation.""" - @dataclass(frozen=True, kw_only=True) class Changed(AggregateEvent): - """Event for basket change.""" + """Event for basket changed.""" - @dataclass(frozen=True, kw_only=True) class ChangedId(Changed): basket_id: BasketId - @dataclass(frozen=True, kw_only=True) class Deleted(AggregateEvent): """Event for basket deletion.""" diff --git a/tests/dataclasses/test_dataclasses_aggregates.py b/tests/dataclasses/test_dataclasses_aggregates.py index 54c58ab..e8fe9cc 100644 --- a/tests/dataclasses/test_dataclasses_aggregates.py +++ b/tests/dataclasses/test_dataclasses_aggregates.py @@ -1,6 +1,12 @@ from typing import cast from uuid import uuid4 +import pytest + +from dddkit.dataclasses import ValueObject +from dddkit.dataclasses.aggregates import Entity, check +from dddkit.exceptions import CHECK_MUST_RETURN_BOOL_MSG, CheckFailedError, CheckMustReturnBoolError + from .conftest import Basket, BasketId @@ -26,3 +32,80 @@ def test_add_event(self, basket: Basket) -> None: assert (events := basket.get_events()) assert isinstance(events[0], Basket.ChangedId) assert isinstance(events[0], Basket.Changed) + + +class TestValueObject: + class Point(ValueObject): + lon: float + lat: float + + @check() + def lat_must_be_in_range(self) -> bool: + return -90 <= self.lat <= 90 + + @check + def lon_must_be_in_range(self) -> tuple[bool, str]: + return -180 <= self.lon <= 180, 'Longitude must be in the range -180 to 180' + + @check + def lat_must_be_precision_to_4(self): + return self.check_precision(self.lat, 4) + + @check + def lon_must_be_precision_to_4(self): + return self.check_precision(self.lon, 4) + + def check_precision(self, val: float, max_val: int) -> bool: + s = f'{val:.5f}' + decimal_part = s.rstrip('0').split('.')[-1] + return len(decimal_part) <= max_val + + class PointError(ValueObject): + lon: float + lat: float + + @check # pyright: ignore[reportArgumentType] + def must_be_error(self) -> list: # pyright: ignore[reportUnknownParameterType,reportMissingTypeArgument] + return [] # pyright: ignore[reportUnknownVariableType] + + class PointCustomError(ValueObject): + lon: float + lat: float + + @check(exception_type=ValueError) + def must_be_error(self) -> tuple[bool, str]: + return False, 'Custom error message' + + @pytest.fixture + def point(self) -> Point: + return self.Point(lon=2.2945, lat=48.8584) + + def test_value_object(self) -> None: + assert self.Point(lon=2.2945, lat=48.8584) + + def test_value_object_not_valid(self) -> None: + with pytest.raises(CheckFailedError, match='Longitude must be in the range -180 to 180'): + self.Point(lon=200.0, lat=48.8584) + + def test_value_object_with_invalid_check_return(self) -> None: + with pytest.raises(CheckMustReturnBoolError, match=CHECK_MUST_RETURN_BOOL_MSG): + self.PointError(lon=2.2945, lat=48.8584) + + def test_value_object_with_custom_error(self) -> None: + with pytest.raises(ValueError, match='Custom error message'): + self.PointCustomError(lon=2.2945, lat=48.8584) + + +class TestEntity: + class Customer(Entity): + first_name: str + last_name: str + age: int + + @check + def customer_must_be_adult(self) -> bool: + return self.age >= 18 + + def test_entity_check(self): + with pytest.raises(CheckFailedError, match='customer_must_be_adult'): + self.Customer(first_name='Pete', last_name='Hodgson', age=17) diff --git a/tests/stories/test_prometheus_hook.py b/tests/stories/test_prometheus_hook.py index 22a3616..07c7edc 100644 --- a/tests/stories/test_prometheus_hook.py +++ b/tests/stories/test_prometheus_hook.py @@ -27,6 +27,17 @@ class ExpectedData(TypedDict): labels_step: dict[str, str] +@pytest.fixture +def mock_context_story( + sample_story: SampleStory, prometheus_hook: AIOPrometheusMetricsHook, mocker: MockerFixture +) -> StoryExecutionContext: + ctx = StoryExecutionContext(story=sample_story) + mocker.patch.object(SampleStory, '__context__cls__', autospec=True, return_value=ctx) + + sample_story.register_hook('after', prometheus_hook.after) + return ctx + + class TestPrometheusMetricsHook: @pytest.fixture def prometheus_hook_factory(self) -> Generator[Callable[..., PrometheusMetricsHook], None, None]: @@ -77,16 +88,6 @@ def expected_data(self, init_data: dict[str, Any]) -> dict[str, Any]: }, } - @pytest.fixture - def mock_context_story( - self, sample_story: SampleStory, prometheus_hook: PrometheusMetricsHook, mocker: MockerFixture - ) -> StoryExecutionContext: - ctx = StoryExecutionContext(story=sample_story) - mocker.patch.object(SampleStory, '__context__cls__', autospec=True, return_value=ctx) - - sample_story.register_hook('after', prometheus_hook.after) - return ctx - def test_hook_initialization(self, prometheus_hook: PrometheusMetricsHook, expected_data: dict[str, Any]) -> None: metric_name = expected_data.get('_metric_name') step_metric_name = expected_data.get('_step_metric_name') or '' @@ -178,16 +179,6 @@ def expected_data(self, init_data: dict[str, Any]) -> ExpectedData: labels_step={'story_name': 'SampleStory', 'step_name': ANY, 'status': ANY}, ) - @pytest.fixture - def mock_context_story( - self, sample_story: SampleStory, prometheus_hook: AIOPrometheusMetricsHook, mocker: MockerFixture - ) -> StoryExecutionContext: - ctx = StoryExecutionContext(story=sample_story) - mocker.patch.object(SampleStory, '__context__cls__', autospec=True, return_value=ctx) - - sample_story.register_hook('after', prometheus_hook.after) - return ctx - def test_hook_initialization(self, prometheus_hook: AIOPrometheusMetricsHook, expected_data: ExpectedData) -> None: metric_name = expected_data.get('_metric_name') step_metric_name = expected_data.get('_step_metric_name') or ''