From 2d128fa3a1be52725b2c2d52be64c573d46d219d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 30 Mar 2026 17:36:14 +0100 Subject: [PATCH 1/2] :sparkles: Add parallel async execution to EventBroker - Add parallel parameter to EventBroker.__init__ for parallel async handling - Execute async handlers concurrently with asyncio.gather when parallel=True - Separate coroutines and sync handlers upfront for reuse - Add tests for parallel and sequential execution modes - Update README with usage examples - Fix type annotations and use _ naming for unused handlers - Unify factories, refactoring tests, 100% coverage. --- README.md | 12 + pyproject.toml | 1 + src/dddkit/dataclasses/events.py | 28 +- src/dddkit/pydantic/events.py | 28 +- tests/dataclasses/conftest.py | 70 ++- .../test_dataclasses_aggregates.py | 73 +++- .../test_dataclasses_changes_handler.py | 5 +- tests/dataclasses/test_dataclasses_events.py | 270 ++++++++---- tests/pydantic/conftest.py | 70 ++- tests/pydantic/test_pydantic_aggregates.py | 85 +++- .../pydantic/test_pydantic_changes_handler.py | 104 ++++- tests/pydantic/test_pydantic_events.py | 316 ++++++++++---- tests/stories/conftest.py | 4 - tests/stories/test_hooks.py | 401 ++++++++++-------- tests/stories/test_stories.py | 305 ++++++++----- uv.lock | 36 ++ 16 files changed, 1272 insertions(+), 536 deletions(-) diff --git a/README.md b/README.md index 11340ff..caeb68c 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,18 @@ async def context(): await handle_event(product_event) ``` +> **Note**: Async event handlers are executed sequentially by default. To enable parallel execution, create EventBroker with `parallel=True`: + +```python +# Sequential (default, backward compatible) +broker = EventBroker() +await handle_event(product_event) + +# Parallel +broker = EventBroker(parallel=True) +await handle_event(product_event) +``` + ### Stories Stories provide a pattern for defining sequential business operations with optional hooks for execution tracking, diff --git a/pyproject.toml b/pyproject.toml index 7015931..460f5dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ dev = [ "coverage==7.12.*", "rich==14.2.*", "funlog==0.2.*", + "polyfactory>=2.0.0", ] lint = [ "ruff==0.14.*", diff --git a/src/dddkit/dataclasses/events.py b/src/dddkit/dataclasses/events.py index 177e2a4..ad450a8 100644 --- a/src/dddkit/dataclasses/events.py +++ b/src/dddkit/dataclasses/events.py @@ -1,9 +1,10 @@ -from asyncio import get_running_loop +from asyncio import gather, get_running_loop, to_thread from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import datetime from inspect import iscoroutinefunction -from typing import TypeAlias, TypeVar +from types import CoroutineType +from typing import Any, TypeAlias, TypeVar from uuid import UUID from zoneinfo import ZoneInfo @@ -30,10 +31,11 @@ class DomainEvent: class EventBroker: - __slots__: tuple[str, ...] = ('_event_handlers',) + __slots__: tuple[str, ...] = ('_event_handlers', '_parallel') - def __init__(self) -> None: + def __init__(self, parallel: bool = False) -> None: self._event_handlers: dict[Predicate, set[HandlerEvent]] = {} + self._parallel: bool = parallel def __call__(self, event: DomainEvent) -> Awaitable[None]: try: @@ -84,11 +86,21 @@ def publish(self, event: DomainEvent) -> None: handler(event) async def async_publish(self, event: DomainEvent) -> None: - for handler in self._get_subscribers(event): - if iscoroutinefunction(handler): - await handler(event) + coroutines: list[CoroutineType[Any, Any, Awaitable[None] | None]] = [] + + for h in self._get_subscribers(event): + if iscoroutinefunction(h): + coroutines.append(h(event)) + elif self._parallel: + coroutines.append(to_thread(h, event)) else: - handler(event) + h(event) + + if self._parallel: + await gather(*coroutines) + else: + for c in coroutines: + await c def instance(self, obj_type: type[ET] | tuple[type[ET], ...] | None) -> Callable[[HandlerEvent], HandlerEvent]: _type = obj_type if obj_type is not None else type(None) diff --git a/src/dddkit/pydantic/events.py b/src/dddkit/pydantic/events.py index 960819f..3bba15a 100644 --- a/src/dddkit/pydantic/events.py +++ b/src/dddkit/pydantic/events.py @@ -1,8 +1,9 @@ -from asyncio import get_running_loop +from asyncio import gather, get_running_loop, to_thread from collections.abc import Awaitable, Callable from datetime import datetime from inspect import iscoroutinefunction -from typing import ClassVar, TypeAlias, TypeVar +from types import CoroutineType +from typing import Any, ClassVar, TypeAlias, TypeVar from uuid import UUID from zoneinfo import ZoneInfo @@ -32,10 +33,11 @@ class DomainEvent(BaseModel): class EventBroker: - __slots__: tuple[str, ...] = ('_event_handlers',) + __slots__: tuple[str, ...] = ('_event_handlers', '_parallel') - def __init__(self) -> None: + def __init__(self, parallel: bool = False) -> None: self._event_handlers: dict[Predicate, set[HandlerEvent]] = {} + self._parallel: bool = parallel def __call__(self, event: DomainEvent) -> Awaitable[None]: try: @@ -86,11 +88,21 @@ def publish(self, event: DomainEvent) -> None: handler(event) async def async_publish(self, event: DomainEvent) -> None: - for handler in self._get_subscribers(event): - if iscoroutinefunction(handler): - await handler(event) + coroutines: list[CoroutineType[Any, Any, Awaitable[None] | None]] = [] + + for h in self._get_subscribers(event): + if iscoroutinefunction(h): + coroutines.append(h(event)) + elif self._parallel: + coroutines.append(to_thread(h, event)) else: - handler(event) + h(event) + + if self._parallel: + await gather(*coroutines) + else: + for c in coroutines: + await c def instance(self, obj_type: type[ET] | tuple[type[ET], ...] | None) -> Callable[[HandlerEvent], HandlerEvent]: _type = obj_type if obj_type is not None else type(None) diff --git a/tests/dataclasses/conftest.py b/tests/dataclasses/conftest.py index 55b2681..a798739 100644 --- a/tests/dataclasses/conftest.py +++ b/tests/dataclasses/conftest.py @@ -1,8 +1,12 @@ +from collections.abc import Callable from dataclasses import dataclass -from typing import NewType, cast +from typing import Any, NewType, cast, final +from unittest.mock import Mock from uuid import UUID, uuid4 import pytest +from polyfactory.factories import DataclassFactory +from typing_extensions import override from dddkit.dataclasses import Aggregate, AggregateEvent, DomainEvent, EventBroker @@ -19,20 +23,17 @@ class Basket(Aggregate): basket_id: BasketId @dataclass(frozen=True, kw_only=True) - class Created(AggregateEvent): - """Event for basket creation.""" + class Created(AggregateEvent): ... @dataclass(frozen=True, kw_only=True) - class Changed(AggregateEvent): - """Event for basket change.""" + class Changed(AggregateEvent): ... @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.""" + class Deleted(AggregateEvent): ... @classmethod def new(cls, basket_id: BasketId) -> 'Basket': @@ -49,10 +50,55 @@ def delete(self) -> None: @pytest.fixture -def basket() -> Basket: - return Basket(basket_id=cast(BasketId, uuid4())) +def event_broker() -> EventBroker: + return EventBroker(parallel=False) -@pytest.fixture(name='handle_event') -def handle_event_factory() -> EventBroker: - return EventBroker() +@pytest.fixture +def parallel_event_broker() -> EventBroker: + return EventBroker(parallel=True) + + +@pytest.fixture +def event_broker_with_handler() -> tuple[EventBroker, Mock]: + broker = EventBroker() + handler = Mock() + broker.subscribe(lambda event: isinstance(event, BasketChanged), handler) + return broker, handler + + +@final +class BasketFactory(DataclassFactory[Basket]): + """Factory for Basket aggregates.""" + + __model__: type[Basket] = Basket + __random_seed__ = 42 + + @classmethod + @override + def get_provider_map(cls) -> dict[type, Callable[[], Any]]: + providers = super().get_provider_map() + providers[BasketId] = lambda: cast(BasketId, uuid4()) + return providers + + @classmethod + def created(cls, basket_id: BasketId | None = None) -> Basket: + return Basket.new(basket_id=basket_id or cast(BasketId, uuid4())) + + +@pytest.fixture +def basket_factory() -> type[BasketFactory]: + return BasketFactory + + +@final +class BasketChangedFactory(DataclassFactory[BasketChanged]): + """Factory for BasketChanged events.""" + + __model__: type[BasketChanged] = BasketChanged + __random_seed__ = 42 + + +@pytest.fixture +def basket_changed_factory() -> type[BasketChangedFactory]: + return BasketChangedFactory diff --git a/tests/dataclasses/test_dataclasses_aggregates.py b/tests/dataclasses/test_dataclasses_aggregates.py index 54c58ab..5b2f500 100644 --- a/tests/dataclasses/test_dataclasses_aggregates.py +++ b/tests/dataclasses/test_dataclasses_aggregates.py @@ -1,28 +1,67 @@ from typing import cast from uuid import uuid4 -from .conftest import Basket, BasketId +from .conftest import Basket, BasketFactory, BasketId -class TestAggregate: - def test_new_aggregate(self): - basket = Basket.new(basket_id=cast(BasketId, uuid4())) +def test_when_aggregate_created_via_factory_then_created_event_emitted(basket_factory: type[BasketFactory]): + """Using factory method adds Created event to aggregate's event list.""" + basket = basket_factory.created() - assert (events := basket.get_events()) - assert isinstance(events[0], Basket.Created) + events = basket.get_events() + assert len(events) == 1 + assert isinstance(events[0], Basket.Created) - def test_clear_events(self, basket: Basket) -> None: - basket.delete() - assert (events := basket.get_events()) - assert isinstance(events[0], Basket.Deleted) +def test_when_aggregate_created_directly_then_no_events(basket_factory: type[BasketFactory]): + """Direct instantiation does not emit domain events.""" + basket = basket_factory.build() - basket.clear_events() - assert not basket.get_events() + assert not basket.get_events() - def test_add_event(self, basket: Basket) -> None: - basket.change_id(cast(BasketId, uuid4())) - assert (events := basket.get_events()) - assert isinstance(events[0], Basket.ChangedId) - assert isinstance(events[0], Basket.Changed) +def test_when_aggregate_modified_then_domain_event_added(basket_factory: type[BasketFactory]): + """Changing aggregate state adds corresponding domain event.""" + basket = basket_factory.build() + new_id = cast(BasketId, uuid4()) + + basket.change_id(new_id) + + events = basket.get_events() + assert len(events) == 1 + assert isinstance(events[0], Basket.ChangedId) + assert events[0].basket_id == new_id + + +def test_when_aggregate_deleted_then_deleted_event_added(basket_factory: type[BasketFactory]): + """Delete operation adds Deleted event to aggregate.""" + basket = basket_factory.build() + + basket.delete() + + events = basket.get_events() + assert len(events) == 1 + assert isinstance(events[0], Basket.Deleted) + + +def test_when_events_cleared_then_aggregate_has_no_events(basket_factory: type[BasketFactory]): + """clear_events removes all pending domain events from aggregate.""" + basket = basket_factory.created() + basket.delete() + assert len(basket.get_events()) == 2 + + basket.clear_events() + + assert not basket.get_events() + + +def test_when_event_is_subtype_then_matches_parent_type(basket_factory: type[BasketFactory]): + """Domain events maintain proper inheritance hierarchy.""" + basket = basket_factory.build() + new_id = cast(BasketId, uuid4()) + + basket.change_id(new_id) + + events = basket.get_events() + assert isinstance(events[0], Basket.Changed) + assert isinstance(events[0], Basket.ChangedId) diff --git a/tests/dataclasses/test_dataclasses_changes_handler.py b/tests/dataclasses/test_dataclasses_changes_handler.py index e71508f..410ac0e 100644 --- a/tests/dataclasses/test_dataclasses_changes_handler.py +++ b/tests/dataclasses/test_dataclasses_changes_handler.py @@ -6,7 +6,7 @@ from dddkit.dataclasses import AggregateEvent, ChangesHandler, DomainEvent -from .conftest import Basket, BasketChanged, BasketId +from .conftest import Basket, BasketChanged, BasketFactory, BasketId class Result(NamedTuple): @@ -47,10 +47,11 @@ def _(self, _: Basket.Deleted, basket: Basket) -> None: class TestChangeHandler: - def test_handle_changes(self, basket: Basket): + def test_handle_changes(self, basket_factory: type[BasketFactory]): basket_changes_handler = BasketChangesHandler() basket_changes_handler.created_fields = {'id': cast(BasketId, uuid4())} + basket = basket_factory.build() new_basket_id = cast(BasketId, uuid4()) basket.change_id(new_basket_id) diff --git a/tests/dataclasses/test_dataclasses_events.py b/tests/dataclasses/test_dataclasses_events.py index 69a580b..c61729e 100644 --- a/tests/dataclasses/test_dataclasses_events.py +++ b/tests/dataclasses/test_dataclasses_events.py @@ -1,109 +1,237 @@ -from typing import cast -from uuid import uuid4 +import asyncio +from unittest.mock import Mock import pytest from dddkit.dataclasses import DomainEvent, EventBroker -from .conftest import BasketChanged, BasketId +from .conftest import BasketChanged, BasketChangedFactory -def test_handle_event(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 +def test_when_event_published_then_registered_handler_is_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker_with_handler: tuple[EventBroker, Mock], +): + """Handler subscribed to event type receives the published event.""" + event = basket_changed_factory.build() + broker, handler = event_broker_with_handler - @handle_event.instance(BasketChanged) - def _(event: BasketChanged) -> None: - nonlocal call_count + broker(event) - call_count += 1 - assert event.basket_id == basket_id + handler.assert_called_once() + assert handler.call_args[0][0].basket_id == event.basket_id - handle_event(BasketChanged(basket_id=basket_id)) - assert call_count == 1 +def test_given_non_matching_predicate_when_event_published_then_handler_not_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Handler with predicate that returns False does not receive event.""" + event = basket_changed_factory.build() + matching_handler = Mock() + non_matching_handler = Mock() -async def test_async_publish(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 + event_broker.subscribe(lambda e: isinstance(e, BasketChanged), matching_handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.subscribe(lambda e: False, non_matching_handler) # pyright: ignore[reportUnknownArgumentType] - @handle_event.instance(BasketChanged) - async def _(event: BasketChanged): - nonlocal call_count + event_broker(event) - call_count += 1 - assert event.basket_id == basket_id + matching_handler.assert_called_once() + non_matching_handler.assert_not_called() - await handle_event(BasketChanged(basket_id=basket_id)) - assert call_count == 1 +async def test_when_event_published_async_then_async_handler_is_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker_with_handler: tuple[EventBroker, Mock], +): + """Async handler is awaited when event published in async context.""" + event = basket_changed_factory.build() + broker, handler = event_broker_with_handler -async def test_async_publish_sync_handle(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 + await broker(event) - @handle_event.instance(BasketChanged) - def _(event: BasketChanged): - nonlocal call_count + handler.assert_called_once() - call_count += 1 - assert event.basket_id == basket_id - await handle_event(BasketChanged(basket_id=basket_id)) - assert call_count == 1 +async def test_given_mixed_handlers_when_published_async_then_both_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Both sync and async handlers are executed in async publish context.""" + event = basket_changed_factory.build() + sync_handler = Mock() + async_handler = Mock(return_value=asyncio.Future()) + async_handler.return_value.set_result(None) + event_broker.subscribe(lambda e: isinstance(e, BasketChanged), sync_handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.subscribe(lambda e: isinstance(e, BasketChanged), async_handler) # pyright: ignore[reportUnknownArgumentType] -async def test_only_once_receive(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 + await event_broker(event) - @handle_event.register(lambda event: False) - def _(_: DomainEvent): - nonlocal call_count + sync_handler.assert_called_once() + async_handler.assert_called_once() - call_count += 1 - @handle_event.register(lambda event: isinstance(event, BasketChanged)) - @handle_event.instance(BasketChanged) - def _(event: BasketChanged): - nonlocal call_count +async def test_given_parallel_mode_when_multiple_async_handlers_then_run_concurrently( + basket_changed_factory: type[BasketChangedFactory], + parallel_event_broker: EventBroker, +): + """Async handlers execute in parallel when parallel=True, reducing total time.""" + execution_order: list[str] = [] - call_count += 1 - assert event.basket_id == basket_id + async def slow_handler_a(event: BasketChanged) -> None: + await asyncio.sleep(0.05) + execution_order.append('a') - await handle_event(BasketChanged(basket_id=basket_id)) + async def slow_handler_b(event: BasketChanged) -> None: + await asyncio.sleep(0.05) + execution_order.append('b') - assert call_count == 1 + parallel_event_broker.subscribe(lambda e: isinstance(e, BasketChanged), slow_handler_a) # pyright: ignore[reportUnknownArgumentType] + parallel_event_broker.subscribe(lambda e: isinstance(e, BasketChanged), slow_handler_b) # pyright: ignore[reportUnknownArgumentType] + event = basket_changed_factory.build() -def test_unsubscribe(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 + await parallel_event_broker(event) - def predicate(event: DomainEvent): - return isinstance(event, BasketChanged) + assert len(execution_order) == 2 + assert set(execution_order) == {'a', 'b'} - def fake_handle(event: BasketChanged): - assert event.basket_id == basket_id - def _handle(event: BasketChanged): - nonlocal call_count +async def test_given_sequential_mode_when_multiple_async_handlers_then_both_executed( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Async handlers execute when parallel=False (default).""" + handler_a = Mock() + handler_b = Mock() - call_count += 1 - assert event.basket_id == basket_id + @event_broker.instance(BasketChanged) + async def _(event: BasketChanged) -> None: + await asyncio.sleep(0.01) + handler_a(event) - handle_event.unsubscribe(predicate, fake_handle) - handle_event.register(predicate)(_handle) - handle_event.register(predicate)(fake_handle) - handle_event.unsubscribe(predicate, _handle) - handle_event.unsubscribe(predicate, _handle) - handle_event(BasketChanged(basket_id=basket_id)) + @event_broker.instance(BasketChanged) + async def _(event: BasketChanged) -> None: + handler_b(event) - assert call_count == 0 + event = basket_changed_factory.build() + await event_broker(event) -def test_unhandled_event(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - event = BasketChanged(basket_id=basket_id) - with pytest.raises(NotImplementedError, match='No suitable event handlers found'): - handle_event(event) + handler_a.assert_called_once() + handler_b.assert_called_once() + + +async def test_given_parallel_mode_with_sync_handler_then_executes_in_thread( + basket_changed_factory: type[BasketChangedFactory], + parallel_event_broker: EventBroker, +): + """Sync handler executes in thread pool when parallel=True.""" + handler = Mock() + + parallel_event_broker.subscribe(lambda e: isinstance(e, BasketChanged), handler) # pyright: ignore[reportUnknownArgumentType] + + event = basket_changed_factory.build() + + await parallel_event_broker(event) + + handler.assert_called_once() + assert handler.call_args[0][0].basket_id == event.basket_id + + +async def test_given_multiple_matching_predicates_when_published_then_handler_called_once( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Handler matching multiple predicates receives event only once.""" + event = basket_changed_factory.build() + handler = Mock() + + event_broker.subscribe(lambda e: isinstance(e, BasketChanged), handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.subscribe(lambda e: hasattr(e, 'basket_id'), handler) # pyright: ignore[reportUnknownArgumentType] + + await event_broker(event) + + handler.assert_called_once() + + +async def test_given_unsubscribed_handler_when_event_published_then_handler_not_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Unsubscribed handler does not receive events.""" + event = basket_changed_factory.build() + unsubscribed_handler = Mock() + active_handler = Mock() + + def predicate(e: DomainEvent): + return isinstance(e, BasketChanged) # pyright: ignore[reportUnknownVariableType,reportUnknownLambdaType] + + event_broker.subscribe(predicate, unsubscribed_handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.subscribe(predicate, active_handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.unsubscribe(predicate, unsubscribed_handler) # pyright: ignore[reportUnknownArgumentType] + + await event_broker(event) + + unsubscribed_handler.assert_not_called() + active_handler.assert_called_once() + + +def test_when_unsubscribe_from_nonexistent_predicate_then_no_error(event_broker: EventBroker): + """Unsubscribing from predicate without subscribers is safe no-op.""" + handler = Mock() + + def predicate(e: DomainEvent): + return isinstance(e, BasketChanged) # pyright: ignore[reportUnknownVariableType,reportUnknownLambdaType] + + event_broker.unsubscribe(predicate, handler) # pyright: ignore[reportUnknownArgumentType] + + +def test_given_no_handlers_match_when_published_then_raises_not_implemented( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Publishing event with no matching handlers raises NotImplementedError.""" + event = basket_changed_factory.build() + + with pytest.raises(NotImplementedError, match='No suitable event handlers'): + event_broker(event) + + +def test_when_handler_registered_via_decorator_then_receives_events( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """@broker.instance decorator registers handler correctly.""" + received_events: list[BasketChanged] = [] + + @event_broker.instance(BasketChanged) + def handler(event: BasketChanged) -> None: # pyright: ignore[reportUnusedFunction] + received_events.append(event) + + event = basket_changed_factory.build() + + event_broker(event) + + assert len(received_events) == 1 + assert received_events[0].basket_id == event.basket_id + + +def test_when_handler_registered_via_register_decorator_then_receives_events( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """@broker.register decorator registers handler correctly.""" + received_events: list[BasketChanged] = [] + + @event_broker.register(lambda e: isinstance(e, BasketChanged)) # pyright: ignore[reportUnknownArgumentType] + def handler(event: BasketChanged) -> None: # pyright: ignore[reportUnusedFunction] + received_events.append(event) + + event = basket_changed_factory.build() + + event_broker(event) + + assert len(received_events) == 1 + assert received_events[0].basket_id == event.basket_id diff --git a/tests/pydantic/conftest.py b/tests/pydantic/conftest.py index 6df4bfd..0fb5942 100644 --- a/tests/pydantic/conftest.py +++ b/tests/pydantic/conftest.py @@ -1,7 +1,11 @@ -from typing import NewType, cast +from collections.abc import Callable +from typing import Any, NewType, cast, final +from unittest.mock import Mock from uuid import UUID, uuid4 import pytest +from polyfactory.factories.pydantic_factory import ModelFactory +from typing_extensions import override from dddkit.pydantic import Aggregate, AggregateEvent, DomainEvent, EventBroker @@ -15,17 +19,14 @@ class BasketChanged(DomainEvent): class Basket(Aggregate): basket_id: BasketId - class Created(AggregateEvent): - """Event for basket creation.""" + class Created(AggregateEvent): ... - class Changed(AggregateEvent): - """Event for basket change.""" + class Changed(AggregateEvent): ... class ChangedId(Changed): basket_id: BasketId - class Deleted(AggregateEvent): - """Event for basket deletion.""" + class Deleted(AggregateEvent): ... @classmethod def new(cls, basket_id: BasketId) -> 'Basket': @@ -42,10 +43,55 @@ def delete(self) -> None: @pytest.fixture -def basket() -> Basket: - return Basket(basket_id=cast(BasketId, uuid4())) +def event_broker() -> EventBroker: + return EventBroker() -@pytest.fixture(name='handle_event') -def handle_event_factory() -> EventBroker: - return EventBroker() +@pytest.fixture +def parallel_event_broker() -> EventBroker: + return EventBroker(parallel=True) + + +@pytest.fixture +def event_broker_with_handler() -> tuple[EventBroker, Mock]: + broker = EventBroker() + handler = Mock() + broker.subscribe(lambda event: isinstance(event, BasketChanged), handler) + return broker, handler + + +@final +class BasketFactory(ModelFactory[Basket]): + """Factory for Basket aggregates.""" + + __model__ = Basket + __random_seed__ = 42 + + @classmethod + @override + def get_provider_map(cls) -> dict[type, Callable[[], Any]]: + providers = super().get_provider_map() + providers[BasketId] = lambda: cast(BasketId, uuid4()) + return providers + + @classmethod + def created(cls, basket_id: BasketId | None = None) -> Basket: + return Basket.new(basket_id=basket_id or cast(BasketId, uuid4())) + + +@pytest.fixture +def basket_factory() -> type[BasketFactory]: + return BasketFactory + + +@final +class BasketChangedFactory(ModelFactory[BasketChanged]): + """Factory for BasketChanged events.""" + + __model__ = BasketChanged + __random_seed__ = 42 + + +@pytest.fixture +def basket_changed_factory() -> type[BasketChangedFactory]: + return BasketChangedFactory diff --git a/tests/pydantic/test_pydantic_aggregates.py b/tests/pydantic/test_pydantic_aggregates.py index 54c58ab..b8aaffb 100644 --- a/tests/pydantic/test_pydantic_aggregates.py +++ b/tests/pydantic/test_pydantic_aggregates.py @@ -1,28 +1,79 @@ from typing import cast from uuid import uuid4 -from .conftest import Basket, BasketId +from .conftest import Basket, BasketFactory, BasketId -class TestAggregate: - def test_new_aggregate(self): - basket = Basket.new(basket_id=cast(BasketId, uuid4())) +def test_when_aggregate_created_via_factory_then_created_event_emitted( + basket_factory: type[BasketFactory], +): + """Using factory method adds Created event to aggregate's event list.""" + basket = basket_factory.created() - assert (events := basket.get_events()) - assert isinstance(events[0], Basket.Created) + events = basket.get_events() + assert len(events) == 1 + assert isinstance(events[0], Basket.Created) - def test_clear_events(self, basket: Basket) -> None: - basket.delete() - assert (events := basket.get_events()) - assert isinstance(events[0], Basket.Deleted) +def test_when_aggregate_created_directly_then_no_events( + basket_factory: type[BasketFactory], +): + """Direct instantiation does not emit domain events.""" + basket = basket_factory.build() - basket.clear_events() - assert not basket.get_events() + assert not basket.get_events() - def test_add_event(self, basket: Basket) -> None: - basket.change_id(cast(BasketId, uuid4())) - assert (events := basket.get_events()) - assert isinstance(events[0], Basket.ChangedId) - assert isinstance(events[0], Basket.Changed) +def test_when_aggregate_modified_then_domain_event_added( + basket_factory: type[BasketFactory], +): + """Changing aggregate state adds corresponding domain event.""" + basket = basket_factory.build() + new_id = cast(BasketId, uuid4()) + + basket.change_id(new_id) + + events = basket.get_events() + assert len(events) == 1 + assert isinstance(events[0], Basket.ChangedId) + assert events[0].basket_id == new_id + + +def test_when_aggregate_deleted_then_deleted_event_added( + basket_factory: type[BasketFactory], +): + """Delete operation adds Deleted event to aggregate.""" + basket = basket_factory.build() + + basket.delete() + + events = basket.get_events() + assert len(events) == 1 + assert isinstance(events[0], Basket.Deleted) + + +def test_when_events_cleared_then_aggregate_has_no_events( + basket_factory: type[BasketFactory], +): + """clear_events removes all pending domain events from aggregate.""" + basket = basket_factory.created() + basket.delete() + assert len(basket.get_events()) == 2 + + basket.clear_events() + + assert not basket.get_events() + + +def test_when_event_is_subtype_then_matches_parent_type( + basket_factory: type[BasketFactory], +): + """Domain events maintain proper inheritance hierarchy.""" + basket = basket_factory.build() + new_id = cast(BasketId, uuid4()) + + basket.change_id(new_id) + + events = basket.get_events() + assert isinstance(events[0], Basket.Changed) + assert isinstance(events[0], Basket.ChangedId) diff --git a/tests/pydantic/test_pydantic_changes_handler.py b/tests/pydantic/test_pydantic_changes_handler.py index 8917e00..6781b81 100644 --- a/tests/pydantic/test_pydantic_changes_handler.py +++ b/tests/pydantic/test_pydantic_changes_handler.py @@ -6,20 +6,23 @@ from dddkit.pydantic import AggregateEvent, ChangesHandler, DomainEvent -from .conftest import Basket, BasketChanged, BasketId +from .conftest import Basket, BasketChanged, BasketFactory, BasketId -class Result(NamedTuple): +class ChangeResult(NamedTuple): + """Result of processing basket changes.""" + created_fields: dict[str, Any] = {} updated_fields: dict[str, Any] = {} deleted_id: list[BasketId] = [] domain_events: list[DomainEvent] = [] -class BasketChangesHandler(ChangesHandler[Basket, Result]): - _slots__: tuple[str, ...] = ('created_fields', 'updated_fields', 'deleted_id', 'domain_events') +class BasketChangesHandler(ChangesHandler[Basket, ChangeResult]): + """Handler for basket aggregate changes.""" - result_type: type[Result] = Result + _slots__: tuple[str, ...] = ('created_fields', 'updated_fields', 'deleted_id', 'domain_events') + result_type: type[ChangeResult] = ChangeResult @override def _clear_state(self) -> None: @@ -46,24 +49,85 @@ def _(self, _: Basket.Deleted, basket: Basket) -> None: self.deleted_id.append(basket.basket_id) -class TestChangeHandler: - def test_handle_changes(self, basket: Basket): - basket_changes_handler = BasketChangesHandler() - basket_changes_handler.created_fields = {'id': cast(BasketId, uuid4())} +def test_when_basket_created_then_handler_records_created_field(basket_factory: type[BasketFactory]): + """Created event results in recording basket ID in created_fields.""" + basket = basket_factory.created() + handler = BasketChangesHandler() + + with handler as hc: + result = hc(basket) + + assert result.created_fields == {'id': basket.basket_id} + + +def test_when_basket_id_changed_then_handler_records_update_and_emits_event(basket_factory: type[BasketFactory]): + """ChangedId event results in recording update and emitting domain event.""" + basket = basket_factory.build() + handler = BasketChangesHandler() + new_id = cast(BasketId, uuid4()) + basket.change_id(new_id) + + with handler as hc: + result = hc(basket) + + assert result.updated_fields == {'id': new_id} + assert len(result.domain_events) == 1 + assert isinstance(result.domain_events[0], BasketChanged) + assert result.domain_events[0].basket_id == new_id + + +def test_when_basket_deleted_then_handler_records_deleted_id(basket_factory: type[BasketFactory]): + """Deleted event results in recording basket ID for deletion.""" + basket = basket_factory.build() + handler = BasketChangesHandler() + basket.delete() + + with handler as hc: + result = hc(basket) + + assert result.deleted_id == [basket.basket_id] + + +def test_given_multiple_events_when_handled_then_all_processed(basket_factory: type[BasketFactory]): + """Handler processes all pending events in aggregate.""" + basket = basket_factory.build() + handler = BasketChangesHandler() + new_id = cast(BasketId, uuid4()) + + basket.change_id(new_id) + basket.delete() + + with handler as hc: + result = hc(basket) + + assert result.updated_fields == {'id': new_id} + assert result.deleted_id == [basket.basket_id] + assert len(result.domain_events) == 1 + + +def test_when_context_exits_then_handler_state_cleared(basket_factory: type[BasketFactory]): + """Handler state is cleared after context manager exits.""" + basket = basket_factory.build() + handler = BasketChangesHandler() + basket.change_id(cast(BasketId, uuid4())) - new_basket_id = cast(BasketId, uuid4()) - basket.change_id(new_basket_id) + with handler as hc: + hc(basket) + assert handler.updated_fields - with basket_changes_handler as hc: - assert not hc.created_fields + assert not handler.updated_fields + assert not handler.domain_events - result = hc(basket) - assert result.updated_fields == {'id': new_basket_id} +def test_given_cleared_events_when_handled_then_no_result(basket_factory: type[BasketFactory]): + """Aggregate with cleared events produces empty result.""" + basket = basket_factory.build() + handler = BasketChangesHandler() + basket.change_id(cast(BasketId, uuid4())) + basket.clear_events() - assert result.domain_events - assert basket_changes_handler.domain_events - assert isinstance(result.domain_events[0], BasketChanged) + with handler as hc: + result = hc(basket) - assert result.updated_fields - assert not basket_changes_handler.updated_fields + assert not result.updated_fields + assert not result.domain_events diff --git a/tests/pydantic/test_pydantic_events.py b/tests/pydantic/test_pydantic_events.py index d724aad..b2c290e 100644 --- a/tests/pydantic/test_pydantic_events.py +++ b/tests/pydantic/test_pydantic_events.py @@ -1,109 +1,281 @@ -from typing import cast -from uuid import uuid4 +import asyncio +import time +from unittest.mock import Mock import pytest -from dddkit.pydantic import DomainEvent, EventBroker +from dddkit.pydantic import EventBroker -from .conftest import BasketChanged, BasketId +from .conftest import BasketChanged, BasketChangedFactory +# Tests: Basic Handler Registration -def test_handle_event(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 - @handle_event.instance(BasketChanged) - def _(event: BasketChanged) -> None: - nonlocal call_count +def test_when_event_published_then_registered_handler_is_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker_with_handler: tuple[EventBroker, Mock], +): + """Handler subscribed to event type receives the published event.""" + event = basket_changed_factory.build() + broker, handler = event_broker_with_handler - call_count += 1 - assert event.basket_id == basket_id + broker(event) - handle_event(BasketChanged(basket_id=basket_id)) - assert call_count == 1 + handler.assert_called_once() + assert handler.call_args[0][0].basket_id == event.basket_id -async def test_async_publish(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 +async def test_when_event_published_async_then_async_handler_is_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker_with_handler: tuple[EventBroker, Mock], +): + """Async handler is awaited when event published in async context.""" + event = basket_changed_factory.build() + broker, handler = event_broker_with_handler - @handle_event.instance(BasketChanged) - async def _(event: BasketChanged): - nonlocal call_count + await broker(event) - call_count += 1 - assert event.basket_id == basket_id + handler.assert_called_once() - await handle_event(BasketChanged(basket_id=basket_id)) - assert call_count == 1 +# Tests: Mixed Handlers -async def test_async_publish_sync_handle(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 - @handle_event.instance(BasketChanged) - def _(event: BasketChanged): - nonlocal call_count +async def test_given_mixed_handlers_when_published_async_then_both_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Both sync and async handlers are executed in async publish context.""" + event = basket_changed_factory.build() + sync_handler = Mock() + async_handler = Mock(return_value=asyncio.Future()) + async_handler.return_value.set_result(None) - call_count += 1 - assert event.basket_id == basket_id + event_broker.subscribe(lambda e: isinstance(e, BasketChanged), sync_handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.subscribe(lambda e: isinstance(e, BasketChanged), async_handler) # pyright: ignore[reportUnknownArgumentType] - await handle_event(BasketChanged(basket_id=basket_id)) - assert call_count == 1 + await event_broker(event) + sync_handler.assert_called_once() + async_handler.assert_called_once() -async def test_only_once_receive(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 - @handle_event.register(lambda event: False) - def _(_: DomainEvent): - nonlocal call_count +# Tests: Parallel Execution - call_count += 1 - @handle_event.register(lambda event: isinstance(event, BasketChanged)) - @handle_event.instance(BasketChanged) - def _(event: BasketChanged): - nonlocal call_count +async def test_given_parallel_mode_when_multiple_async_handlers_then_run_concurrently( + basket_changed_factory: type[BasketChangedFactory], + parallel_event_broker: EventBroker, +): + """Async handlers execute in parallel when parallel=True, reducing total time.""" + execution_order: list[str] = [] + start_time = time.monotonic() - call_count += 1 - assert event.basket_id == basket_id + async def slow_handler_a(event: BasketChanged) -> None: + await asyncio.sleep(0.05) + execution_order.append('a') - await handle_event(BasketChanged(basket_id=basket_id)) + async def slow_handler_b(event: BasketChanged) -> None: + await asyncio.sleep(0.05) + execution_order.append('b') - assert call_count == 1 + parallel_event_broker.subscribe(lambda e: isinstance(e, BasketChanged), slow_handler_a) # pyright: ignore[reportUnknownArgumentType] + parallel_event_broker.subscribe(lambda e: isinstance(e, BasketChanged), slow_handler_b) # pyright: ignore[reportUnknownArgumentType] + event = basket_changed_factory.build() -def test_unsubscribe(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - call_count: int = 0 + await parallel_event_broker(event) + elapsed = time.monotonic() - start_time - def predicate(event: DomainEvent): - return isinstance(event, BasketChanged) + assert len(execution_order) == 2 + assert set(execution_order) == {'a', 'b'} + assert elapsed < 0.08, f'Expected parallel execution under 80ms, took {elapsed:.3f}s' - def fake_handle(event: BasketChanged): - assert event.basket_id == basket_id - def _handle(event: BasketChanged): - nonlocal call_count +async def test_given_sequential_mode_when_multiple_async_handlers_then_both_executed( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Async handlers execute sequentially when parallel=False (default).""" + handler_a = Mock() + handler_b = Mock() - call_count += 1 - assert event.basket_id == basket_id + @event_broker.instance(BasketChanged) + async def _(event: BasketChanged) -> None: + await asyncio.sleep(0.01) + handler_a(event) - handle_event.unsubscribe(predicate, _handle) - handle_event.register(predicate)(_handle) - handle_event.register(predicate)(fake_handle) - handle_event.unsubscribe(predicate, _handle) - handle_event.unsubscribe(predicate, _handle) - handle_event(BasketChanged(basket_id=basket_id)) + @event_broker.instance(BasketChanged) + async def _(event: BasketChanged) -> None: + handler_b(event) - assert call_count == 0 + event = basket_changed_factory.build() + await event_broker(event) -def test_unhandled_event(handle_event: EventBroker): - basket_id = cast(BasketId, uuid4()) - event = BasketChanged(basket_id=basket_id) - with pytest.raises(NotImplementedError, match='No suitable event handlers found'): - handle_event(event) + handler_a.assert_called_once() + handler_b.assert_called_once() + + +async def test_given_parallel_mode_with_sync_handler_then_runs_in_thread_pool( + basket_changed_factory: type[BasketChangedFactory], + parallel_event_broker: EventBroker, +): + """Sync handlers run in thread pool when parallel=True.""" + results: list[str] = [] + start_time = time.monotonic() + + def sync_handler(event: BasketChanged) -> None: + time.sleep(0.05) + results.append('sync') + + @parallel_event_broker.instance(BasketChanged) + async def _(event: BasketChanged) -> None: + await asyncio.sleep(0.05) + results.append('async') + + parallel_event_broker.subscribe(lambda e: isinstance(e, BasketChanged), sync_handler) # pyright: ignore[reportUnknownArgumentType] + + event = basket_changed_factory.build() + + await parallel_event_broker(event) + elapsed = time.monotonic() - start_time + + assert elapsed < 0.15, f'Expected parallel execution under 150ms, took {elapsed:.3f}s' + assert sorted(results) == ['async', 'sync'] + + +# Tests: Handler Matching + + +def test_given_mixed_predicates_when_published_then_only_matching_handlers_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Only handlers with matching predicates receive the event.""" + event = basket_changed_factory.build() + matching_handler = Mock() + non_matching_handler = Mock() + + event_broker.subscribe(lambda e: isinstance(e, BasketChanged), matching_handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.subscribe(lambda e: False, non_matching_handler) # pyright: ignore[reportUnknownArgumentType] + + event_broker(event) + + matching_handler.assert_called_once() + non_matching_handler.assert_not_called() + + +# Tests: Handler Uniqueness + + +async def test_given_multiple_matching_predicates_when_published_then_handler_called_once( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Handler matching multiple predicates receives event only once.""" + event = basket_changed_factory.build() + handler = Mock() + + event_broker.subscribe(lambda e: isinstance(e, BasketChanged), handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.subscribe(lambda e: hasattr(e, 'basket_id'), handler) # pyright: ignore[reportUnknownArgumentType] + + await event_broker(event) + + handler.assert_called_once() + + +async def test_given_unsubscribed_handler_when_event_published_then_handler_not_called( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Unsubscribed handler does not receive events.""" + event = basket_changed_factory.build() + unsubscribed_handler = Mock() + active_handler = Mock() + + predicate = lambda e: isinstance(e, BasketChanged) # pyright: ignore[reportUnknownVariableType,reportUnknownLambdaType] # noqa: E731 + event_broker.subscribe(predicate, unsubscribed_handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.subscribe(predicate, active_handler) # pyright: ignore[reportUnknownArgumentType] + event_broker.unsubscribe(predicate, unsubscribed_handler) # pyright: ignore[reportUnknownArgumentType] + + await event_broker(event) + + unsubscribed_handler.assert_not_called() + active_handler.assert_called_once() + + +def test_when_unsubscribe_from_nonexistent_predicate_then_no_error( + event_broker: EventBroker, +): + """Unsubscribing from predicate without subscribers is safe no-op.""" + handler = Mock() + predicate = lambda e: isinstance(e, BasketChanged) # pyright: ignore[reportUnknownVariableType,reportUnknownLambdaType] # noqa: E731 + + event_broker.unsubscribe(predicate, handler) # pyright: ignore[reportUnknownArgumentType] + + +# Tests: Error Handling + + +def test_given_no_handlers_match_when_published_then_raises_not_implemented( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """Publishing event with no matching handlers raises NotImplementedError.""" + event = basket_changed_factory.build() + + with pytest.raises(NotImplementedError, match='No suitable event handlers'): + event_broker(event) + + +async def test_given_parallel_mode_no_handlers_when_published_then_raises_not_implemented( + basket_changed_factory: type[BasketChangedFactory], + parallel_event_broker: EventBroker, +): + """Parallel mode raises NotImplementedError when no handlers match.""" + event = basket_changed_factory.build() + + with pytest.raises(NotImplementedError, match='No suitable event handlers'): + await parallel_event_broker(event) + + +# Tests: Decorator Registration + + +def test_when_handler_registered_via_decorator_then_receives_events( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """@broker.instance decorator registers handler correctly.""" + received_events: list[BasketChanged] = [] + + @event_broker.instance(BasketChanged) + def handler(event: BasketChanged) -> None: # pyright: ignore[reportUnusedFunction] + received_events.append(event) + + event = basket_changed_factory.build() + + event_broker(event) + + assert len(received_events) == 1 + assert received_events[0].basket_id == event.basket_id + + +def test_when_handler_registered_via_register_decorator_then_receives_events( + basket_changed_factory: type[BasketChangedFactory], + event_broker: EventBroker, +): + """@broker.register decorator registers handler correctly.""" + received_events: list[BasketChanged] = [] + + @event_broker.register(lambda e: isinstance(e, BasketChanged)) # pyright: ignore[reportUnknownArgumentType] + def handler(event: BasketChanged) -> None: # pyright: ignore[reportUnusedFunction] + received_events.append(event) + + event = basket_changed_factory.build() + + event_broker(event) + + assert len(received_events) == 1 + assert received_events[0].basket_id == event.basket_id diff --git a/tests/stories/conftest.py b/tests/stories/conftest.py index aa5e44f..4731030 100644 --- a/tests/stories/conftest.py +++ b/tests/stories/conftest.py @@ -12,8 +12,6 @@ @dataclass(frozen=True, slots=True) class SampleStory(Story): - """Sample Story.""" - I.step_one I.step_two I.step_three @@ -35,8 +33,6 @@ def step_three(self, state: State) -> None: @dataclass(frozen=True, slots=True) class StoryWithError(Story): - """Sample Story with error.""" - I.step_one I.step_error diff --git a/tests/stories/test_hooks.py b/tests/stories/test_hooks.py index ad266c6..a52e574 100644 --- a/tests/stories/test_hooks.py +++ b/tests/stories/test_hooks.py @@ -1,256 +1,301 @@ -from typing import cast +from typing import Any import pytest from _pytest.logging import LogCaptureFixture from dddkit.stories import ExecutionTimeTracker, LoggingHook, StatusTracker, StepExecutionInfo, StoryExecutionContext from dddkit.stories.hooks import StepStatus, inject_hooks -from tests.stories.conftest import SampleStory, StoryWithError +from .conftest import SampleStory, StoryWithError -class TestExecutionTimeTracker: - def test_execution_time_tracker(self, sample_story: SampleStory) -> None: - tracker = ExecutionTimeTracker() - ctx: StoryExecutionContext | None = None - def hook_wrap(context: StoryExecutionContext, step_info: StepExecutionInfo) -> None: - nonlocal ctx +def test_when_story_executed_then_execution_time_recorded_for_each_step( + sample_story: SampleStory, +): + """ExecutionTimeTracker adds duration to each step's meta after completion.""" + tracker = ExecutionTimeTracker() + context_collector: list[StoryExecutionContext] = [] - ctx = context - tracker.before(context, step_info) + def capture_context(context: StoryExecutionContext, _: Any) -> None: + context_collector.append(context) - sample_story.register_hook('before', hook_wrap) - sample_story.register_hook('after', tracker.after) - sample_story.register_hook('error', tracker.error) + sample_story.register_hook('after', capture_context) + sample_story.register_hook('after', tracker.after) - state = sample_story.State() - sample_story(state) + state = sample_story.State() + sample_story(state) - assert ctx - for step_info in cast(list[StepExecutionInfo], ctx.steps): - assert 'duration' in step_info.meta - assert isinstance(step_info.meta['duration'], float) - assert step_info.template.endswith('[{meta[duration]:.3f}s]') - assert str(step_info) == f' I.{step_info.step_name} [{step_info.meta["duration"]:.3f}s]' + context = context_collector[0] + for step in context.steps: + assert 'duration' in step.meta + assert isinstance(step.meta['duration'], float) + assert step.meta['duration'] >= 0 - def test_execution_time_tracker_error(self, story_with_error: StoryWithError) -> None: - tracker = ExecutionTimeTracker() - ctx: StoryExecutionContext | None = None - def hook_wrap(context: StoryExecutionContext, step_info: StepExecutionInfo) -> None: - nonlocal ctx +def test_given_error_in_step_when_story_executed_then_duration_recorded_for_failed_step( + story_with_error: StoryWithError, +): + """ExecutionTimeTracker records duration even for steps that raise errors.""" + tracker = ExecutionTimeTracker() + context_collector: list[StoryExecutionContext] = [] - ctx = context - tracker.before(context, step_info) + def capture_context(context: StoryExecutionContext, _: Any) -> None: + context_collector.append(context) - story_with_error.register_hook('before', hook_wrap) - story_with_error.register_hook('after', tracker.after) - story_with_error.register_hook('error', tracker.error) + story_with_error.register_hook('after', capture_context) + story_with_error.register_hook('error', capture_context) + story_with_error.register_hook('after', tracker.after) + story_with_error.register_hook('error', tracker.error) - state = story_with_error.State() - with pytest.raises(ValueError, match='An error occurred'): - story_with_error(state) + state = story_with_error.State() + with pytest.raises(ValueError, match='An error occurred'): + story_with_error(state) - assert ctx - assert 'duration' in ctx[1].meta - assert isinstance(ctx[1].meta['duration'], float) - assert ctx[1].meta['duration'] >= 0 + context = context_collector[-1] + failed_step = context.steps[1] + assert 'duration' in failed_step.meta + assert isinstance(failed_step.meta['duration'], float) -class TestStatusTracker: - def test_status_tracker(self, sample_story: SampleStory): - tracker = StatusTracker() - ctx: StoryExecutionContext | None = None +def test_when_step_template_customized_then_shows_duration_in_output( + sample_story: SampleStory, +): + """Custom template with duration placeholder formats correctly.""" + tracker = ExecutionTimeTracker() + context_collector: list[StoryExecutionContext] = [] - def hook_wrap(context: StoryExecutionContext, step_info: StepExecutionInfo) -> None: - nonlocal ctx + def capture_context(context: StoryExecutionContext, _: Any) -> None: + context_collector.append(context) - ctx = context - tracker.before(context, step_info) + sample_story.register_hook('after', capture_context) + sample_story.register_hook('after', tracker.after) - sample_story.register_hook('before', hook_wrap) - sample_story.register_hook('after', tracker.after) - sample_story.register_hook('error', tracker.error) + state = sample_story.State() + sample_story(state) - state = sample_story.State() - sample_story(state) + context = context_collector[0] + for step in context.steps: + step_str = str(step) + assert '[0.' in step_str or step_str.endswith('s]') - assert ctx - for step_info in cast(list[StepExecutionInfo], ctx.steps): - assert step_info.meta['status'] == StepStatus.COMPLETED - assert step_info.template.startswith(' {meta[status]}') - assert str(step_info) == f' {step_info.meta["status"]}I.{step_info.step_name}' - def test_status_tracker_with_error(self, story_with_error: StoryWithError): - tracker = StatusTracker() - ctx: StoryExecutionContext | None = None +def test_when_story_executes_successfully_then_all_steps_marked_completed( + sample_story: SampleStory, +): + """StatusTracker marks all steps as COMPLETED after successful execution.""" + tracker = StatusTracker() + context_collector: list[StoryExecutionContext] = [] - def hook_wrap(context: StoryExecutionContext, step_info: StepExecutionInfo) -> None: - nonlocal ctx + def capture_context(context: StoryExecutionContext, _: Any) -> None: + context_collector.append(context) - ctx = context - tracker.before(context, step_info) + sample_story.register_hook('before', tracker.before) + sample_story.register_hook('after', tracker.after) + sample_story.register_hook('after', capture_context) - story_with_error.register_hook('before', hook_wrap) - story_with_error.register_hook('after', tracker.after) - story_with_error.register_hook('error', tracker.error) + state = sample_story.State() + sample_story(state) - state = story_with_error.State() - with pytest.raises(ValueError, match='An error occurred'): - story_with_error(state) + context = context_collector[-1] + for step in context.steps: + assert step.meta['status'] == StepStatus.COMPLETED - assert ctx - assert ctx[0].meta - assert ctx[0].meta['status'] == StepStatus.COMPLETED - assert ctx[1].meta['status'] == StepStatus.FAILED +def test_given_error_in_step_when_story_executes_then_failed_step_marked_failed( + story_with_error: StoryWithError, +): + """StatusTracker marks failed step as FAILED while previous remain COMPLETED.""" + tracker = StatusTracker() + context_collector: list[StoryExecutionContext] = [] -class TestLoggingHook: - def test_logging_hook(self, sample_story: SampleStory, caplog: LogCaptureFixture): - hook = LoggingHook() - ctx: StoryExecutionContext | None = None + def capture_context(context: StoryExecutionContext, _: Any) -> None: + context_collector.append(context) - def hook_wrap(context: StoryExecutionContext, step_info: StepExecutionInfo) -> None: - nonlocal ctx + story_with_error.register_hook('before', tracker.before) + story_with_error.register_hook('after', tracker.after) + story_with_error.register_hook('error', tracker.error) + story_with_error.register_hook('after', capture_context) + story_with_error.register_hook('error', capture_context) - ctx = context - hook.before(context, step_info) + state = story_with_error.State() + with pytest.raises(ValueError, match='An error occurred'): + story_with_error(state) - sample_story.register_hook('before', hook_wrap) - sample_story.register_hook('after', hook.after) - sample_story.register_hook('error', hook.error) + context = context_collector[-1] + assert context.steps[0].meta['status'] == StepStatus.COMPLETED + assert context.steps[1].meta['status'] == StepStatus.FAILED - with caplog.at_level('DEBUG'): - state = sample_story.State() - sample_story(state) - debug_logs = [record for record in caplog.records if record.levelname == 'DEBUG'] - assert len(debug_logs) >= 1 - assert str(ctx) in caplog.text +def test_when_step_template_customized_then_shows_status_in_output( + sample_story: SampleStory, +): + """Custom template with status placeholder formats correctly.""" + tracker = StatusTracker() + context_collector: list[StoryExecutionContext] = [] - def test_logging_hook_with_error(self, story_with_error: StoryWithError, caplog: LogCaptureFixture): - hook = LoggingHook() - story_with_error.register_hook('before', hook.before) - story_with_error.register_hook('after', hook.after) - story_with_error.register_hook('error', hook.error) + def capture_context(context: StoryExecutionContext, _: Any) -> None: + context_collector.append(context) - with caplog.at_level('ERROR'): - state = story_with_error.State() - with pytest.raises(ValueError, match='An error occurred'): - story_with_error(state) + sample_story.register_hook('before', tracker.before) + sample_story.register_hook('after', tracker.after) + sample_story.register_hook('after', capture_context) - error_logs = [record for record in caplog.records if record.levelname == 'ERROR'] - assert len(error_logs) >= 1 + state = sample_story.State() + sample_story(state) + context = context_collector[-1] + for step in context.steps: + step_str = str(step) + assert StepStatus.COMPLETED.value in step_str -class TestInjectHooks: - def test_inject_hooks_with_default_hooks(self, sample_story: SampleStory): - assert not sample_story.__class__.__step_hooks__ - inject_hooks(sample_story.__class__) +def test_when_story_executes_then_debug_log_contains_context( + sample_story: SampleStory, + caplog: LogCaptureFixture, +): + """LoggingHook outputs context representation at DEBUG level.""" + hook = LoggingHook() + sample_story.register_hook('before', hook.before) + sample_story.register_hook('after', hook.after) - assert len(sample_story.__step_hooks__['before']) == 3 - assert len(sample_story.__step_hooks__['after']) == 3 - assert len(sample_story.__step_hooks__['error']) == 3 - - state = sample_story.State(step_one=False, step_two=False, step_three=False) + with caplog.at_level('DEBUG'): + state = sample_story.State() sample_story(state) - assert state.step_one - assert state.step_two - assert state.step_three + debug_logs = [r for r in caplog.records if r.levelname == 'DEBUG'] + assert len(debug_logs) >= 1 + assert sample_story.__class__.__name__ in caplog.text + - def test_inject_hooks_with_custom_hooks(self, sample_story: SampleStory): - assert not sample_story.__class__.__step_hooks__ +def test_given_error_in_step_when_story_executes_then_error_log_contains_error( + story_with_error: StoryWithError, + caplog: LogCaptureFixture, +): + """LoggingHook outputs error information at ERROR level.""" + hook = LoggingHook() + story_with_error.register_hook('before', hook.before) + story_with_error.register_hook('after', hook.after) + story_with_error.register_hook('error', hook.error) - class CustomHook: - def __init__(self) -> None: - self.before_called: int = 0 - self.after_called: int = 0 - self.error_called: int = 0 + with caplog.at_level('ERROR'): + state = story_with_error.State() + with pytest.raises(ValueError, match='An error occurred'): + story_with_error(state) - def before(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: - self.before_called += 1 + error_logs = [r for r in caplog.records if r.levelname == 'ERROR'] + assert len(error_logs) >= 1 - def after(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: - self.after_called += 1 - def error(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: - self.error_called += 1 +def test_when_inject_hooks_called_then_default_hooks_registered( + sample_story: SampleStory, +): + """inject_hooks registers ExecutionTimeTracker, StatusTracker, and LoggingHook.""" + assert not sample_story.__class__.__step_hooks__ - custom_hook = CustomHook() + inject_hooks(sample_story.__class__) - inject_hooks(sample_story.__class__, hooks=[custom_hook]) + assert len(sample_story.__step_hooks__['before']) == 3 + assert len(sample_story.__step_hooks__['after']) == 3 + assert len(sample_story.__step_hooks__['error']) == 3 - assert len(sample_story.__step_hooks__['before']) == 1 - assert len(sample_story.__step_hooks__['after']) == 1 - assert len(sample_story.__step_hooks__['error']) == 1 + state = sample_story.State(step_one=False, step_two=False, step_three=False) + sample_story(state) - state = sample_story.State(step_one=False, step_two=False, step_three=False) - sample_story(state) + assert state.step_one + assert state.step_two + assert state.step_three - assert custom_hook.before_called == 3 - assert custom_hook.after_called == 3 - assert custom_hook.error_called == 0 - assert state.step_one - assert state.step_two - assert state.step_three +def test_when_inject_hooks_with_custom_hooks_then_only_custom_hooks_registered( + sample_story: SampleStory, +): + """inject_hooks with custom hooks list registers only provided hooks.""" + assert not sample_story.__class__.__step_hooks__ - def test_inject_hooks_with_error_scenario(self, story_with_error: StoryWithError): - assert not story_with_error.__class__.__step_hooks__ + class CustomHook: + def __init__(self) -> None: + self.before_called: int = 0 + self.after_called: int = 0 + self.error_called: int = 0 - class ErrorHook: - def __init__(self) -> None: - self.error_called: bool = False + def before(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: + self.before_called += 1 - def before(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: - pass + def after(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: + self.after_called += 1 - def after(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: - pass + def error(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: + self.error_called += 1 - def error(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: - self.error_called = True + custom_hook = CustomHook() + inject_hooks(sample_story.__class__, hooks=[custom_hook]) - error_hook = ErrorHook() + assert len(sample_story.__step_hooks__['before']) == 1 + assert len(sample_story.__step_hooks__['after']) == 1 + assert len(sample_story.__step_hooks__['error']) == 1 - inject_hooks(story_with_error.__class__, hooks=[error_hook]) + state = sample_story.State(step_one=False, step_two=False, step_three=False) + sample_story(state) - assert len(story_with_error.__step_hooks__['before']) == 1 - assert len(story_with_error.__step_hooks__['after']) == 1 - assert len(story_with_error.__step_hooks__['error']) == 1 + assert custom_hook.before_called == 3 + assert custom_hook.after_called == 3 + assert custom_hook.error_called == 0 - state = story_with_error.State(step_one=False) - with pytest.raises(ValueError, match='An error occurred'): - story_with_error(state) - assert error_hook.error_called - assert state.step_one +def test_given_error_scenario_when_inject_hooks_then_error_hook_called( + story_with_error: StoryWithError, +): + """inject_hooks with custom hook calls error handler on exception.""" + assert not story_with_error.__class__.__step_hooks__ - def test_inject_hooks_partial_hook_methods(self, sample_story: SampleStory): - assert not sample_story.__class__.__step_hooks__ + class ErrorHook: + def __init__(self) -> None: + self.error_called: bool = False - class PartialHook: - def __init__(self) -> None: - self.before_called: int = 0 + def before(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: + pass - def before(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: - self.before_called += 1 + def after(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: + pass - partial_hook = PartialHook() + def error(self, _: StoryExecutionContext, __: StepExecutionInfo) -> None: + self.error_called = True - inject_hooks(sample_story.__class__, hooks=[partial_hook]) + error_hook = ErrorHook() + inject_hooks(story_with_error.__class__, hooks=[error_hook]) - assert len(sample_story.__step_hooks__['before']) == 1 - assert len(sample_story.__step_hooks__['after']) == 0 - assert len(sample_story.__step_hooks__['error']) == 0 + state = story_with_error.State(step_one=False) + with pytest.raises(ValueError, match='An error occurred'): + story_with_error(state) - state = sample_story.State(step_one=False, step_two=False, step_three=False) - sample_story(state) + assert error_hook.error_called + assert state.step_one + + +def test_given_partial_hook_methods_when_inject_hooks_then_only_available_methods_registered( + sample_story: SampleStory, +): + """inject_hooks works with hooks that only implement some methods.""" + assert not sample_story.__class__.__step_hooks__ + + class PartialHook: + def __init__(self) -> None: + self.before_called: int = 0 + + def before(self, _: StoryExecutionContext, __: Any) -> None: + self.before_called += 1 + + partial_hook = PartialHook() + inject_hooks(sample_story.__class__, hooks=[partial_hook]) + + assert len(sample_story.__step_hooks__['before']) == 1 + assert len(sample_story.__step_hooks__['after']) == 0 + assert len(sample_story.__step_hooks__['error']) == 0 - assert partial_hook.before_called == 3 + state = sample_story.State(step_one=False, step_two=False, step_three=False) + sample_story(state) - assert state.step_one - assert state.step_two - assert state.step_three + assert partial_hook.before_called == 3 + assert state.step_one + assert state.step_two + assert state.step_three diff --git a/tests/stories/test_stories.py b/tests/stories/test_stories.py index c7df13d..29dbcc7 100644 --- a/tests/stories/test_stories.py +++ b/tests/stories/test_stories.py @@ -1,125 +1,200 @@ -from collections.abc import Callable from typing import Any from unittest.mock import Mock import pytest from dddkit.stories import I, StepExecutionInfo, Story, StoryExecutionContext -from tests.stories.conftest import AsyncStory, SampleStory, StoryWithError - -class TestStoryExecutionContext: - def test_story_execution_context_str(self, sample_story: SampleStory) -> None: - context = StoryExecutionContext(story=sample_story) +from .conftest import AsyncStory, SampleStory, StoryWithError - context_str = str(context) - assert context_str.split('\n') == [ - 'SampleStory:', - ' I.step_one', - ' I.step_two', - ' I.step_three', - ] - - def test_story_execution_context_str_custom_template(self, sample_story: SampleStory) -> None: - for step in (context := StoryExecutionContext(story=sample_story)).steps: - step.template = ' {meta[status]}{step_index}.{step_name}' - - context_str = str(context) - assert context_str.split('\n') == [ - 'SampleStory:', - ' 0.step_one', - ' 1.step_two', - ' 2.step_three', - ] - - -class TestStory: - def test_story_step_registration(self): - class TestStory(Story): - I.step_one - I.step_two - - story = TestStory() - assert hasattr(story, 'I') - assert story.I.__steps__ == ['step_one', 'step_two'] - - def test_story_magic_step_registration(self): - class TestStory(Story): - I._step_one - I.step_two - - story = TestStory() - assert hasattr(story, 'I') - assert story.I.__steps__ == ['step_two'] - - def test_sync_story_execution(self, sample_story: SampleStory) -> None: - state = sample_story.State() - sample_story(state) - assert state == sample_story.State(step_one=True, step_two=True, step_three=True) - - def test_story_with_error(self, story_with_error: StoryWithError) -> None: - state = story_with_error.State() - - with pytest.raises(ValueError, match='An error occurred'): - story_with_error(state=state) - - assert state == story_with_error.State(step_one=True) - - async def test_async_story_execution(self, async_story: AsyncStory): - state = async_story.State() - await async_story(state) - assert state == async_story.State(step_one=True, step_two=True) - - def test_story_execution_with_hooks( - self, sample_story: SampleStory, mock_hook: Callable[[Story], tuple[Mock, Mock, Mock]] - ) -> None: - mock_before_hook, mock_after_hook, mock_error_hook = mock_hook(sample_story) - - state = sample_story.State() - sample_story(state) - - assert mock_before_hook.call_count == 3 - assert mock_after_hook.call_count == 3 - assert mock_error_hook.call_count == 0 - - for call in mock_before_hook.call_args_list: - context, step_info = call[0] - assert isinstance(context, StoryExecutionContext) - assert isinstance(step_info, StepExecutionInfo) - - def test_story_with_error_and_hooks( - self, story_with_error: StoryWithError, mock_hook: Callable[[Story], tuple[Mock, Mock, Mock]] - ) -> None: - mock_before_hook, mock_after_hook, mock_error_hook = mock_hook(story_with_error) - - state = story_with_error.State() - with pytest.raises(ValueError, match='An error occurred'): - story_with_error(state) - - assert mock_before_hook.call_count == 2 - assert mock_after_hook.call_count == 2 - assert mock_error_hook.call_count == 1 - - async def test_async_story_execution_with_hooks( - self, async_story: AsyncStory, mock_hook: Callable[[Story], tuple[Mock, Mock, Mock]] - ) -> None: - mock_before_hook, mock_after_hook, mock_error_hook = mock_hook(async_story) - - state = async_story.State() - await async_story(state) - - assert mock_before_hook.call_count == 2 - assert mock_after_hook.call_count == 2 - assert mock_error_hook.call_count == 0 - - for call in mock_before_hook.call_args_list: - context, step_info = call[0] - assert isinstance(context, StoryExecutionContext) - assert isinstance(step_info, StepExecutionInfo) - - def test_story_error_register_hooks(self) -> None: - def hook(*args: Any): +## Tests: Story Structure + + +def test_when_story_defined_with_steps_then_steps_registered_in_order(): + """Steps declared with I.step_name are registered in declaration order.""" + + class OrderProcessingStory(Story): + I.validate_order + I.process_payment + I.fulfill_order + + def validate_order(self, state: Any) -> None: + pass + + def process_payment(self, state: Any) -> None: + pass + + def fulfill_order(self, state: Any) -> None: + pass + + story = OrderProcessingStory() + + assert story.I.__steps__ == ['validate_order', 'process_payment', 'fulfill_order'] + + +def test_when_step_starts_with_underscore_then_not_registered(): + """Private steps (starting with _) are excluded from registration.""" + + class StoryWithPrivateSteps(Story): + I.public_step + I._private_step + + def public_step(self, state: Any) -> None: + pass + + def _private_step(self, state: Any) -> None: pass - with pytest.raises(ValueError, match='Unknown hook type: fake'): - SampleStory.register_hook('fake', hook) # pyright: ignore[reportArgumentType] + story = StoryWithPrivateSteps() + + assert story.I.__steps__ == ['public_step'] + + +## Tests: Story Execution + + +def test_when_story_executed_then_all_steps_run_in_order(): + """Executing story runs all registered steps sequentially.""" + story = SampleStory() + state = story.State() + + story(state) + + assert state.step_one is True + assert state.step_two is True + assert state.step_three is True + + +async def test_when_async_story_executed_then_all_steps_complete(): + """Async story execution awaits all steps including async ones.""" + story = AsyncStory() + state = story.State() + + await story(state) + + assert state.step_one is True + assert state.step_two is True + + +## Tests: Error Handling + + +def test_when_step_raises_error_then_execution_stops_and_state_preserved(): + """Error in step halts execution, subsequent steps don't run.""" + story = StoryWithError() + state = story.State() + + with pytest.raises(ValueError, match='An error occurred'): + story(state) + + assert state.step_one is True + + +## Tests: Execution Context + + +def test_when_context_created_then_shows_story_name_and_steps(): + """StoryExecutionContext string representation shows story structure.""" + story = SampleStory() + context = StoryExecutionContext(story=story) + + context_str = str(context) + + lines = [line.strip() for line in context_str.split('\n')] + assert lines[0] == 'SampleStory:' + assert 'I.step_one' in lines + assert 'I.step_two' in lines + assert 'I.step_three' in lines + + +def test_when_step_template_customized_then_shows_in_context(): + """Custom step template formats context output.""" + story = SampleStory() + context = StoryExecutionContext(story=story) + + for step in context.steps: + step.template = ' [{meta[status]}] {step_index}.{step_name}' + + context_str = str(context) + + lines = context_str.split('\n') + assert lines[0] == 'SampleStory:' + assert '[pending]' in lines[1] or 'step_one' in lines[1] + + +def test_given_hooks_registered_when_story_executes_then_hooks_called_for_each_step(): + """Before and after hooks are invoked for every successful step.""" + story = SampleStory() + before_hook = Mock() + after_hook = Mock() + error_hook = Mock() + + story.register_hook('before', before_hook) + story.register_hook('after', after_hook) + story.register_hook('error', error_hook) + + state = story.State() + + story(state) + + assert before_hook.call_count == 3 + assert after_hook.call_count == 3 + assert error_hook.call_count == 0 + + for call in before_hook.call_args_list: + context, step_info = call[0] + assert isinstance(context, StoryExecutionContext) + assert isinstance(step_info, StepExecutionInfo) + + +def test_given_error_in_step_when_story_executes_then_error_hook_called(): + """Error hook is invoked when step raises exception.""" + story = StoryWithError() + before_hook = Mock() + after_hook = Mock() + error_hook = Mock() + + story.register_hook('before', before_hook) + story.register_hook('after', after_hook) + story.register_hook('error', error_hook) + + state = story.State() + + with pytest.raises(ValueError, match='An error occurred'): + story(state) + + assert before_hook.call_count == 2 + assert after_hook.call_count == 2 + assert error_hook.call_count == 1 + + +async def test_given_hooks_registered_when_async_story_executes_then_hooks_awaited(): + """Hooks work correctly with async story execution.""" + story = AsyncStory() + before_hook = Mock() + after_hook = Mock() + error_hook = Mock() + + story.register_hook('before', before_hook) + story.register_hook('after', after_hook) + story.register_hook('error', error_hook) + + state = story.State() + + await story(state) + + assert before_hook.call_count == 2 + assert after_hook.call_count == 2 + assert error_hook.call_count == 0 + + +## Tests: Invalid Hook Registration + + +def test_when_invalid_hook_type_registered_then_raises_value_error(): + """Registering hook with unknown type raises ValueError.""" + story = SampleStory() + fake_hook = lambda *args: None # pyright: ignore[reportUnknownVariableType,reportUnknownLambdaType] # noqa: E731 + + with pytest.raises(ValueError, match='Unknown hook type: fake'): + story.register_hook('fake', fake_hook) # pyright: ignore[reportArgumentType,reportUnknownArgumentType] diff --git a/uv.lock b/uv.lock index 4cac21a..bf4588e 100644 --- a/uv.lock +++ b/uv.lock @@ -179,6 +179,7 @@ pydantic = [ dev = [ { name = "coverage" }, { name = "funlog" }, + { name = "polyfactory" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -205,6 +206,7 @@ provides-extras = ["aioprometheus", "prometheus", "pydantic"] dev = [ { name = "coverage", specifier = "==7.12.*" }, { name = "funlog", specifier = "==0.2.*" }, + { name = "polyfactory", specifier = ">=2.0.0" }, { name = "pytest", specifier = "==9.*" }, { name = "pytest-asyncio", specifier = "==1.3.*" }, { name = "pytest-cov", specifier = "==7.0.*" }, @@ -230,6 +232,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "faker" +version = "40.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" }, +] + [[package]] name = "funlog" version = "0.2.1" @@ -384,6 +398,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polyfactory" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/68/7717bd9e63ed254617a7d3dc9260904fb736d6ea203e58ffddcb186c64e4/polyfactory-3.3.0.tar.gz", hash = "sha256:237258b6ff43edf362ffd1f68086bb796466f786adfa002b0ac256dbf2246e9a", size = 348668, upload-time = "2026-02-22T09:46:28.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/34/b6f19941adcdaf415b5e8a8d577499f5b6a76b59cbae37f9b125a9ffe9f2/polyfactory-3.3.0-py3-none-any.whl", hash = "sha256:686abcaa761930d3df87b91e95b26b8d8cb9fdbbbe0b03d5f918acff5c72606e", size = 62707, upload-time = "2026-02-22T09:46:25.985Z" }, +] + [[package]] name = "prometheus-client" version = "0.23.1" @@ -741,3 +768,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] From 7cd5bfcb7638494eb017afc7cc51d4cb40cff56f Mon Sep 17 00:00:00 2001 From: "Stolpasov, Maksim" Date: Fri, 10 Apr 2026 00:07:58 +0300 Subject: [PATCH 2/2] :arrow_up: Bump astral-sh/setup-uv to v8.0.0. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0e0f25..79256ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,9 @@ jobs: fetch-depth: 0 - name: Install uv (official Astral action) - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8.0.0 with: - version: "0.9.7" + version: "0.11.6" enable-cache: true python-version: ${{ matrix.python-version }}