Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -228,7 +226,6 @@ ProductId = NewType('ProductId', int)
BasketId = NewType('BasketId', int)


@dataclass(kw_only=True)
class Product(Entity):
product_id: ProductId
name: ProductName
Expand All @@ -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"""

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ reportUnnecessaryIsInstance = false
# reportUnknownArgumentType = false
reportUninitializedInstanceVariable = false
reportUnusedParameter = false
reportUntypedFunctionDecorator = false

[tool.pytest.ini_options]
python_files = ["*.py"]
Expand Down
78 changes: 70 additions & 8 deletions src/dddkit/dataclasses/aggregates.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

BasketId = NewType('BasketId', UUID)

@dataclass(kw_only=True)
class Basket(Aggregate):
basket_id: BasketId

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
12 changes: 12 additions & 0 deletions src/dddkit/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
CHECK_MUST_RETURN_BOOL_MSG = 'Invariants must return a boolean value'


class DDDKitError(Exception):
"""Base class for all DDDKit exceptions."""

Expand All @@ -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."""
5 changes: 5 additions & 0 deletions src/dddkit/stories/hooks.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 1 addition & 5 deletions tests/dataclasses/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,15 @@
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."""

Expand All @@ -42,7 +38,7 @@

def change_id(self, basket_id: BasketId) -> None:
self.basket_id = basket_id
self.add_event(self.ChangedId(basket_id=basket_id))

Check failure on line 41 in tests/dataclasses/conftest.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.12)

No parameter named "basket_id" (reportCallIssue)

Check failure on line 41 in tests/dataclasses/conftest.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.11)

No parameter named "basket_id" (reportCallIssue)

Check failure on line 41 in tests/dataclasses/conftest.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.13)

No parameter named "basket_id" (reportCallIssue)

Check failure on line 41 in tests/dataclasses/conftest.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.10)

No parameter named "basket_id" (reportCallIssue)

Check failure on line 41 in tests/dataclasses/conftest.py

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 3.14)

No parameter named "basket_id" (reportCallIssue)

def delete(self) -> None:
self.add_event(self.Deleted())
Expand Down
83 changes: 83 additions & 0 deletions tests/dataclasses/test_dataclasses_aggregates.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)
31 changes: 11 additions & 20 deletions tests/stories/test_prometheus_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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 ''
Expand Down Expand Up @@ -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 ''
Expand Down
Loading