From 75f44d2bfcd26f3936112586f7a0cd47ede013f7 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 00:42:32 -0300 Subject: [PATCH 01/86] refactor: restructure Message into message/ subpackage with abstract from_payload_fields Split the monolithic message.py into message/_message.py (base Message class with abstract from_payload_fields), message/_metadata.py (MessageMetadata), and message/__init__.py (public re-exports). Make from_payload_fields @abstractmethod on Command, Event, and Query base classes. Update @event_dataclass / @command_dataclass / @query_dataclass decorators to auto-patch the new method name. --- .../foundation/messages/command.py | 7 +- .../foundation/messages/decorators.py | 23 +- .../foundation/messages/event.py | 9 +- .../foundation/messages/message.py | 341 ------------------ .../foundation/messages/message/__init__.py | 6 + .../foundation/messages/message/_message.py | 124 +++++++ .../foundation/messages/message/_metadata.py | 143 ++++++++ .../foundation/messages/query.py | 8 +- 8 files changed, 298 insertions(+), 363 deletions(-) delete mode 100644 src/forging_blocks/foundation/messages/message.py create mode 100644 src/forging_blocks/foundation/messages/message/__init__.py create mode 100644 src/forging_blocks/foundation/messages/message/_message.py create mode 100644 src/forging_blocks/foundation/messages/message/_metadata.py diff --git a/src/forging_blocks/foundation/messages/command.py b/src/forging_blocks/foundation/messages/command.py index 657eea7c..fc66a683 100644 --- a/src/forging_blocks/foundation/messages/command.py +++ b/src/forging_blocks/foundation/messages/command.py @@ -1,4 +1,4 @@ -"""Module defining the Command base class for domain commands.""" +"""Module defining the Command base class for commands.""" from datetime import datetime from uuid import UUID @@ -7,14 +7,15 @@ class Command[RawCommandType](Message[RawCommandType]): - """Base class for all domain commands. + """Base class for all commands. - Commands represent an intent to do something in the domain. + Commands represent an intent to do something in the system. They are requests that may succeed or fail, and are handled by a command handler. Commands are named in imperative mood (e.g., CreateOrder, RegisterCustomer, ProcessPayment). + Example: ```python class CreateOrder(Command[dict[str, object]]): diff --git a/src/forging_blocks/foundation/messages/decorators.py b/src/forging_blocks/foundation/messages/decorators.py index 9eea7c13..3f7468fb 100644 --- a/src/forging_blocks/foundation/messages/decorators.py +++ b/src/forging_blocks/foundation/messages/decorators.py @@ -4,7 +4,7 @@ ``@command_dataclass``, ``@query_dataclass``) to reduce boilerplate when defining message types. The decorated class is a frozen dataclass whose fields are automatically exposed via ``get_payload_fields()`` and are used -by ``_from_payload_fields()`` for deserialisation. +by ``from_payload_fields()`` for reconstruction. Example:: @@ -20,7 +20,6 @@ class OrderCreated(Event[dict[str, object]]): event = OrderCreated(order_id="ORD-001", customer_id="CUST-42", total=99.95) - event.to_dict() # includes both "payload" and "data" keys """ import dataclasses @@ -30,7 +29,7 @@ class OrderCreated(Event[dict[str, object]]): from forging_blocks.foundation.messages.message import Message, MessageMetadata -_M = TypeVar("_M", bound="Message[dict[str, object]]") +_M = TypeVar("_M", bound="Message[Any]") @runtime_checkable @@ -45,7 +44,7 @@ class _PatchedMessage(Protocol): def get_payload_fields(self) -> dict[str, object]: ... @classmethod - def _from_payload_fields( + def from_payload_fields( cls, data: dict[str, object], metadata: MessageMetadata, @@ -72,7 +71,7 @@ def message_dataclass( """Decorate a class as a message dataclass. The decorator applies ``@dataclass(frozen=frozen)`` and then patches - ``get_payload_fields`` and ``_from_payload_fields`` onto the class so + ``get_payload_fields`` and ``from_payload_fields`` onto the class so that payload data is automatically derived from its fields. Args: @@ -119,7 +118,7 @@ def get_payload_fields(self: _M) -> dict[str, object]: } @classmethod - def _from_payload_fields( + def from_payload_fields( cls: type[_M], data: dict[str, object], metadata: MessageMetadata, @@ -133,13 +132,11 @@ def _from_payload_fields( patched = cast(Any, dc_cls) patched.__init__ = new_init patched.get_payload_fields = get_payload_fields - patched._from_payload_fields = _from_payload_fields + patched.from_payload_fields = from_payload_fields # Patch abstract members so decorated subclasses of Event/Command/Query - # can be instantiated without manually implementing _payload / value. - # ``_from_payload_fields`` is intentionally not patched here: it is never - # declared abstract by the Message hierarchy, so it cannot appear in - # ``__abstractmethods__``. It is added explicitly above. + # can be instantiated without manually implementing _payload / value + # or from_payload_fields. abstract_methods: frozenset[str] = getattr(dc_cls, "__abstractmethods__", frozenset()) if "_payload" in abstract_methods: patched._payload = property(lambda self: self.get_payload_fields()) @@ -151,6 +148,10 @@ def _from_payload_fields( dc_cls.__abstractmethods__ = frozenset( m for m in dc_cls.__abstractmethods__ if m != "value" ) + if "from_payload_fields" in abstract_methods: + dc_cls.__abstractmethods__ = frozenset( + m for m in dc_cls.__abstractmethods__ if m != "from_payload_fields" + ) if not isinstance(dc_cls, _PatchedMessage): raise TypeError( diff --git a/src/forging_blocks/foundation/messages/event.py b/src/forging_blocks/foundation/messages/event.py index 2efb9d5a..f2c5b8da 100644 --- a/src/forging_blocks/foundation/messages/event.py +++ b/src/forging_blocks/foundation/messages/event.py @@ -1,4 +1,4 @@ -"""Module defining the base Event class for domain events.""" +"""Module defining the base Event class for events.""" from abc import abstractmethod from datetime import datetime @@ -7,14 +7,15 @@ class Event[RawEventType](Message[RawEventType]): - """Base class for all domain events. + """Base class for all events. - Domain events represent something significant that happened in the domain. + Events represent something significant that happened in the system. They are immutable facts about the past that other parts of the system can react to. Events are named in past tense (e.g., OrderCreated, CustomerRegistered, PaymentProcessed). + Example: ```python class OrderCreated(Event[dict[str, object]]): @@ -42,7 +43,7 @@ def occurred_at(self) -> datetime: @property @abstractmethod - def _payload(self) -> dict[str, object]: + def _payload(self) -> RawEventType: """Retrieve the event's payload as a dictionary. Subclasses MUST implement this property to return the event-specific diff --git a/src/forging_blocks/foundation/messages/message.py b/src/forging_blocks/foundation/messages/message.py deleted file mode 100644 index 75242520..00000000 --- a/src/forging_blocks/foundation/messages/message.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Message module for messaging patterns. - -This module provides the base Message class and MessageMetadata for implementing -foundation messages influenced by Domain-Driven Design (DDD) and CQRS principles. -""" - -from abc import ABC, abstractmethod -from collections.abc import Callable -from datetime import datetime, timezone -from typing import Self, cast -from uuid import UUID, uuid7 - -from forging_blocks.foundation.value_object import ValueObject - - -class MessageMetadata(ValueObject[dict[str, object]]): - """Metadata associated with foundational messages. - - Contains technical-level information about messages such as: - - Unique message identifier - - When the message was created - - correlation_id is used to trace related messages across systems. - - correlation_id is used to link messages that belong to the same business process. - - causation_id is used to identify the immediate predecessor message that caused - - This separation allows messages to focus on foundational data while keeping - infrastructure handling concerns in metadata without foundation understand anything about - infrastructure rules. - - Example: - ```python - metadata = MessageMetadata(message_type="OrderCreated") - - # Or with custom values - custom_metadata = MessageMetadata( - message_type="UserCreated", - message_id=UUID("123e4567-e89b-12d3-a456-426614174000"), - created_at=datetime(2025, 6, 11, 19, 36, 6, tzinfo=timezone.utc), - ) - ``` - - """ - - __slots__ = ( - "_message_type", - "_message_id", - "_created_at", - "_correlation_id", - "_causation_id", - ) - - def __init__( - self, - message_type: str, - message_id: UUID | None = None, - created_at: datetime | None = None, - correlation_id: UUID | None = None, - causation_id: UUID | None = None, - ) -> None: - """Initialize message metadata. - - Args: - message_type: The type/name of the message. - message_id: Unique identifier for the message. If None, generates a - new UUID. - created_at: When the message was created. If None, uses current UTC time. - correlation_id: Identifier to correlate related messages. If None, - generates a new UUID. - causation_id: Identifier of the message that caused this one. If None, - generates a new UUID. - - """ - super().__init__() - self._message_type = message_type - self._message_id = message_id or uuid7() - self._created_at = created_at or datetime.now(timezone.utc) - self._correlation_id = correlation_id or uuid7() - self._causation_id = causation_id or uuid7() - - @property - def message_id(self) -> UUID: - """Get the unique identifier for this message. - - Returns: - The unique message identifier. - - """ - return self._message_id - - @property - def causation_id(self) -> UUID: - """Get the causation ID for this message. - - Returns: - The causation identifier. - - """ - return self._causation_id - - @property - def created_at(self) -> datetime: - """Get the timestamp when this message was created. - - Returns: - When the message was created (UTC timezone). - - """ - return self._created_at - - @property - def correlation_id(self) -> UUID: - """Get the correlation ID for this message. - - Returns: - The correlation identifier. - - """ - return self._correlation_id - - @property - def message_type(self) -> str: - """Get the type of this message. - - Returns: - The message type name. - - """ - return self._message_type - - @property - def value(self) -> dict[str, object]: - """Get the raw dictionary representation of the metadata.""" - return { - "created_at": self._created_at.isoformat(), - "correlation_id": str(self._correlation_id), - "causation_id": str(self._causation_id), - "message_id": str(self._message_id), - "message_type": self._message_type, - } - - @property - def _equality_components(self) -> tuple[object, ...]: - """Message metadata equality is based on message ID and timestamp. - - Returns: - Tuple containing message_id and created_at. - - """ - return (self._message_id, self._created_at) - - def to_dict(self) -> dict[str, object]: - """Convert metadata to dictionary representation. - - Returns: - Dictionary representation of the metadata. - - """ - return self.value - - @classmethod - def from_dict(cls, data: dict[str, object]) -> MessageMetadata: - """Create metadata from a dictionary representation. - - Args: - data: Dictionary containing the serialised metadata fields. - - Returns: - A new MessageMetadata instance reconstituted from *data*. - - """ - return cls( - message_type=str(data["message_type"]), - message_id=UUID(str(data["message_id"])) if "message_id" in data else None, - created_at=datetime.fromisoformat(str(data["created_at"])) - if "created_at" in data - else None, - correlation_id=UUID(str(data["correlation_id"])) if "correlation_id" in data else None, - causation_id=UUID(str(data["causation_id"])) if "causation_id" in data else None, - ) - - -class Message[MessageRawType](ValueObject[MessageRawType], ABC): - """Base class for all foundation messages. - - Messages are immutable value objects that represent intent or facts in the application. - This is the base class for Events (something that happened) and Commands - (something to do). - - Features: - - Immutable by design (inherits from ValueObject) - - Contains MessageMetadata for infrastructure concerns - - Focus on data in subclasses - - Each message instance is unique (based on metadata.message_id) - - This class should not be used directly. Use Event or Command instead. - """ - - def __init__(self, metadata: MessageMetadata | None = None) -> None: - """Initialize the message with metadata. - - Args: - metadata: Message metadata. If None, creates new metadata with - generated ID and current timestamp. - - """ - super().__init__() - effective_type = type(self).__name__ - self._metadata = metadata or MessageMetadata(message_type=effective_type) - - def __eq__(self, other: object) -> bool: - """Check equality based on _equality_components.""" - if not isinstance(other, Message): - return False - return self._equality_components == other._equality_components - - def __hash__(self) -> int: - return hash(self._equality_components) - - @property - def metadata(self) -> MessageMetadata: - """Get the message metadata. - - Returns: - The message metadata containing ID, timestamp, etc. - - """ - return self._metadata - - @property - def message_id(self) -> UUID: - """Convenience property to get the message ID. - - Returns: - The unique message identifier. - - """ - return self._metadata.message_id - - @property - def created_at(self) -> datetime: - """Convenience property to get when the message was created. - - Returns: - When the message was created. - - """ - return self._metadata.created_at - - @property - @abstractmethod - def _payload(self) -> dict[str, object]: - """Get the data carried by this message. - - Subclasses must implement this property to provide their specific message - data. This makes the Message class truly abstract. - - Returns: - The message payload. - - """ - - @property - def _equality_components(self) -> tuple[object, ...]: - """Messages are equal if they have the same message ID. - - Each message instance is unique, even if they have the same data. - - Returns: - Tuple containing the message ID for equality comparison. - - """ - return (self._metadata.message_id,) - - def to_dict(self) -> dict[str, object]: - """Convert the message to a dictionary representation. - - Combines metadata, message type, payload, and domain data. - - Returns: - Complete dictionary representation of the message. - - """ - result: dict[str, object] = { - "metadata": self._metadata.to_dict(), - "payload": self._payload, - } - result["data"] = self.get_domain_data() - return result - - def get_domain_data(self) -> dict[str, object]: - """Return the domain-level data carried by this message. - - The default implementation delegates to ``_payload``. Subclasses - that use the decorator-based approach (``@event_dataclass`` etc.) - override this to return only the message fields. - - Returns: - Dictionary of domain data fields. - - """ - return self._payload - - @classmethod - def from_dict(cls, data: dict[str, object]) -> Self: - """Create a message instance from a dictionary representation. - - Args: - data: Dictionary containing the serialised message. - - Returns: - A new message instance reconstituted from *data*. - - """ - metadata = MessageMetadata.from_dict(cast(dict[str, object], data["metadata"])) - payload = cast(dict[str, object], data.get("payload", data.get("data", {}))) - return cls._from_domain_data(payload, metadata) - - @classmethod - def _from_domain_data(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: - """Reconstruct a message from domain data and metadata. - - Subclasses override this to provide custom reconstruction logic. - By default, calls ``_from_payload_fields`` if available, otherwise raises - ``NotImplementedError``. - - Args: - data: The domain data dictionary. - metadata: The message metadata. - - Returns: - A new message instance. - - Raises: - NotImplementedError: If the subclass has not overridden this method or - does not have a ``_from_payload_fields`` method. - - """ - method = getattr(cls, "_from_payload_fields", None) - if method is not None: - return cast(Callable[..., Self], method)(data, metadata) - raise NotImplementedError diff --git a/src/forging_blocks/foundation/messages/message/__init__.py b/src/forging_blocks/foundation/messages/message/__init__.py new file mode 100644 index 00000000..a00452fb --- /dev/null +++ b/src/forging_blocks/foundation/messages/message/__init__.py @@ -0,0 +1,6 @@ +"""Message package — base Message class and MessageMetadata.""" + +from ._message import Message +from ._metadata import MessageMetadata + +__all__ = ["Message", "MessageMetadata"] diff --git a/src/forging_blocks/foundation/messages/message/_message.py b/src/forging_blocks/foundation/messages/message/_message.py new file mode 100644 index 00000000..a43099e0 --- /dev/null +++ b/src/forging_blocks/foundation/messages/message/_message.py @@ -0,0 +1,124 @@ +"""Base Message class for messaging patterns.""" + +from abc import ABC, abstractmethod +from collections.abc import Hashable +from datetime import datetime +from typing import Self +from uuid import UUID + +from forging_blocks.foundation.value_object import ValueObject + +from ._metadata import MessageMetadata + + +class Message[MessageRawType](ValueObject[MessageRawType], ABC): + """Base class for all foundation messages. + + Messages are immutable value objects that represent intent or facts in the application. + This is the base class for Events (something that happened) and Commands + (something to do). + + Features: + - Immutable by design (inherits from ValueObject) + - Contains MessageMetadata for infrastructure concerns + - Focus on data in subclasses + - Each message instance is unique (based on metadata.message_id) + + This class should not be used directly. Use Event or Command instead. + """ + + def __init__(self, metadata: MessageMetadata | None = None) -> None: + """Initialize the message with metadata. + + Args: + metadata: Message metadata. If None, creates new metadata with + generated ID and current timestamp. + + """ + super().__init__() + effective_type = type(self).__name__ + self._metadata = metadata or MessageMetadata(message_type=effective_type) + + def __eq__(self, other: object) -> bool: + """Check equality based on _equality_components.""" + if not isinstance(other, Message): + return False + return self._equality_components == other._equality_components + + def __hash__(self) -> int: + return hash(self._equality_components) + + @property + def metadata(self) -> MessageMetadata: + """Get the message metadata. + + Returns: + The message metadata containing ID, timestamp, etc. + + """ + return self._metadata + + @property + def message_id(self) -> UUID: + """Convenience property to get the message ID. + + Returns: + The unique message identifier. + + """ + return self._metadata.message_id + + @property + def created_at(self) -> datetime: + """Convenience property to get when the message was created. + + Returns: + When the message was created. + + """ + return self._metadata.created_at + + @property + @abstractmethod + def _payload(self) -> MessageRawType: + """Get the data carried by this message. + + Subclasses must implement this property to provide their specific message + data. This makes the Message class truly abstract. + + Returns: + The message payload. + + """ + + @classmethod + @abstractmethod + def from_payload_fields( + cls, + data: MessageRawType, + metadata: MessageMetadata, + ) -> Self: + """Reconstruct a message instance from payload fields and metadata. + + Abstract classmethod that subclasses must implement. The + ``@message_dataclass`` decorator provides a concrete implementation + automatically; manual subclasses that need codec support must override + this method themselves. + + Returns: + A new message instance reconstructed from the given payload + fields and metadata. + + """ + + @property + def _equality_components(self) -> tuple[Hashable, ...]: + """Messages are equal if they have the same message ID. + + Each message instance is unique, even if they have the same data. + + Returns: + Tuple containing the message ID for equality comparison. + + """ + return (self._metadata.message_id,) diff --git a/src/forging_blocks/foundation/messages/message/_metadata.py b/src/forging_blocks/foundation/messages/message/_metadata.py new file mode 100644 index 00000000..00fec3be --- /dev/null +++ b/src/forging_blocks/foundation/messages/message/_metadata.py @@ -0,0 +1,143 @@ +"""MessageMetadata value object for messaging patterns.""" + +from collections.abc import Hashable +from datetime import datetime, timezone +from uuid import UUID, uuid7 + +from forging_blocks.foundation.value_object import ValueObject + + +class MessageMetadata(ValueObject[dict[str, object]]): + """Metadata associated with foundational messages. + + Contains technical-level information about messages such as: + - Unique message identifier + - When the message was created + - correlation_id is used to trace related messages across systems. + - correlation_id is used to link messages that belong to the same business process. + - causation_id is used to identify the immediate predecessor message that caused + + This separation allows messages to focus on foundational data while keeping + infrastructure handling concerns in metadata without foundation understand anything about + infrastructure rules. + + Example: + ```python + metadata = MessageMetadata(message_type="OrderCreated") + + # Or with custom values + custom_metadata = MessageMetadata( + message_type="UserCreated", + message_id=UUID("123e4567-e89b-12d3-a456-426614174000"), + created_at=datetime(2025, 6, 11, 19, 36, 6, tzinfo=timezone.utc), + ) + ``` + + """ + + __slots__ = ( + "_message_type", + "_message_id", + "_created_at", + "_correlation_id", + "_causation_id", + ) + + def __init__( + self, + message_type: str, + message_id: UUID | None = None, + created_at: datetime | None = None, + correlation_id: UUID | None = None, + causation_id: UUID | None = None, + ) -> None: + """Initialize message metadata. + + Args: + message_type: The type/name of the message. + message_id: Unique identifier for the message. If None, generates a + new UUID. + created_at: When the message was created. If None, uses current UTC time. + correlation_id: Identifier to correlate related messages. If None, + generates a new UUID. + causation_id: Identifier of the message that caused this one. If None, + generates a new UUID. + + """ + super().__init__() + self._message_type = message_type + self._message_id = message_id or uuid7() + self._created_at = created_at or datetime.now(timezone.utc) + self._correlation_id = correlation_id or uuid7() + self._causation_id = causation_id or uuid7() + + @property + def message_id(self) -> UUID: + """Get the unique identifier for this message. + + Returns: + The unique message identifier. + + """ + return self._message_id + + @property + def causation_id(self) -> UUID: + """Get the causation ID for this message. + + Returns: + The causation identifier. + + """ + return self._causation_id + + @property + def created_at(self) -> datetime: + """Get the timestamp when this message was created. + + Returns: + When the message was created (UTC timezone). + + """ + return self._created_at + + @property + def correlation_id(self) -> UUID: + """Get the correlation ID for this message. + + Returns: + The correlation identifier. + + """ + return self._correlation_id + + @property + def message_type(self) -> str: + """Get the type of this message. + + Returns: + The message type name. + + """ + return self._message_type + + @property + def value(self) -> dict[str, object]: + """Get the raw dictionary representation of the metadata.""" + return { + "created_at": self._created_at.isoformat(), + "correlation_id": str(self._correlation_id), + "causation_id": str(self._causation_id), + "message_id": str(self._message_id), + "message_type": self._message_type, + } + + @property + def _equality_components(self) -> tuple[Hashable, ...]: + """Message metadata equality is based on message ID and timestamp. + + Returns: + Tuple containing message_id and created_at. + + """ + return (self._message_id, self._created_at) diff --git a/src/forging_blocks/foundation/messages/query.py b/src/forging_blocks/foundation/messages/query.py index 73de4139..f26e2389 100644 --- a/src/forging_blocks/foundation/messages/query.py +++ b/src/forging_blocks/foundation/messages/query.py @@ -1,4 +1,4 @@ -"""Module defining the base Query class for domain queries.""" +"""Module defining the base Query class for queries.""" from abc import abstractmethod @@ -6,9 +6,9 @@ class Query[QueryPayloadType](Message[QueryPayloadType]): - """Base class for all domain queries. + """Base class for all queries. - Queries represent a request to retrieve data from the domain. + Queries represent a request to retrieve data from the system. They are handled by query handlers and should not modify state. Queries are named in interrogative mood (e.g., GetOrder, FindCustomer, @@ -30,7 +30,7 @@ def _payload(self) -> dict[str, object]: @property @abstractmethod - def _payload(self) -> dict[str, object]: + def _payload(self) -> QueryPayloadType: """Retrieve the query's payload as a dictionary. Subclasses MUST implement this property to return the query-specific From 0a35736e58c5306998473e92261039ab7b243a06 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 00:42:55 -0300 Subject: [PATCH 02/86] refactor: replace Serializable with MessageCodec/DictMessageCodec in infrastructure Remove the structural Serializable protocol. Introduce MessageCodec (abstract encode/decode) and DictMessageCodec (concrete dict-based implementation). Update infrastructure __init__.py exports to surface the new codec types. --- src/forging_blocks/infrastructure/__init__.py | 8 +-- .../infrastructure/serialization/__init__.py | 9 +-- .../serialization/_dict_message_codec.py | 49 ++++++++++++++ .../serialization/_message_codec.py | 64 +++++++++++++++++++ .../serialization/serializable.py | 39 ----------- 5 files changed, 122 insertions(+), 47 deletions(-) create mode 100644 src/forging_blocks/infrastructure/serialization/_dict_message_codec.py create mode 100644 src/forging_blocks/infrastructure/serialization/_message_codec.py delete mode 100644 src/forging_blocks/infrastructure/serialization/serializable.py diff --git a/src/forging_blocks/infrastructure/__init__.py b/src/forging_blocks/infrastructure/__init__.py index fa7bcc0e..ede4518a 100644 --- a/src/forging_blocks/infrastructure/__init__.py +++ b/src/forging_blocks/infrastructure/__init__.py @@ -3,8 +3,7 @@ Provides generic, reusable infrastructure building blocks implementing the outbound ports defined in the application layer. Includes in-memory adapters for repositories, event buses, event stores, message buses, -caching, logging, file system, and HTTP; plus Serializable, abstract -base classes (EventStoreBase, EventBusBase), and infrastructure-level +caching, logging, file system, and HTTP; plus MessageCodec/DictMessageCodec, abstract errors. """ @@ -33,7 +32,7 @@ InMemoryRepository, InMemoryWriteRepository, ) -from .serialization import Serializable +from .serialization import DictMessageCodec, MessageCodec from .unit_of_work.in_memory_unit_of_work import InMemoryUnitOfWork __all__ = [ @@ -56,7 +55,8 @@ "OSFileSystem", "RepositoryError", "RepositoryNotFoundError", - "Serializable", + "DictMessageCodec", + "MessageCodec", "StdlibLogger", "URLLibClient", ] diff --git a/src/forging_blocks/infrastructure/serialization/__init__.py b/src/forging_blocks/infrastructure/serialization/__init__.py index 0706126d..09a6159b 100644 --- a/src/forging_blocks/infrastructure/serialization/__init__.py +++ b/src/forging_blocks/infrastructure/serialization/__init__.py @@ -1,9 +1,10 @@ """Serialization infrastructure for the application. -Provides protocols and utilities for serializing and deserializing -domain objects to and from plain dictionaries. +Provides abstract and concrete codecs for encoding/decoding messages +to and from different representations. """ -from .serializable import Serializable +from ._dict_message_codec import DictMessageCodec +from ._message_codec import MessageCodec -__all__ = ["Serializable"] +__all__ = ["DictMessageCodec", "MessageCodec"] diff --git a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py new file mode 100644 index 00000000..c87a2853 --- /dev/null +++ b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py @@ -0,0 +1,49 @@ +"""Dict-based message codec that serializes messages to ``dict[str, object]``.""" + +from datetime import datetime +from typing import Any, cast +from uuid import UUID + +from forging_blocks.foundation.messages import Message, MessageMetadata + +from ._message_codec import MessageCodec + + +class DictMessageCodec[M: Message[Any]](MessageCodec[M, dict[str, object]]): + """Codec that serializes messages to ``dict[str, object]``. + + Uses the message's own ``value`` property for the payload and + ``message.metadata.value`` for the metadata section. Reconstruction + goes through the ``from_payload_fields`` classmethod that every + concrete ``Message`` subclass provides. + """ + + def _to_data(self, message: M) -> dict[str, object]: + """Serialize *message* to a dictionary. + + Returns a ``dict`` with ``metadata`` and ``payload`` keys. + """ + return { + "metadata": message.metadata.value, + "payload": cast(dict[str, object], message.value), + } + + def _from_data(self, data: dict[str, object], message_type: type[M]) -> M: + """Reconstruct a message of *message_type* from *data*. + + Expects a ``dict`` with ``metadata`` and ``payload`` keys. + """ + raw_metadata = cast(dict[str, object], data["metadata"]) + payload = cast(dict[str, object], data.get("payload", {})) + + metadata = MessageMetadata( + message_type=str(raw_metadata.get("message_type", message_type.__name__)), + message_id=UUID(str(raw_metadata["message_id"])), + created_at=datetime.fromisoformat( + str(raw_metadata.get("created_at", datetime.min.isoformat())), + ), + causation_id=UUID(str(raw_metadata["causation_id"])), + correlation_id=UUID(str(raw_metadata["correlation_id"])), + ) + + return message_type.from_payload_fields(payload, metadata) diff --git a/src/forging_blocks/infrastructure/serialization/_message_codec.py b/src/forging_blocks/infrastructure/serialization/_message_codec.py new file mode 100644 index 00000000..8788a535 --- /dev/null +++ b/src/forging_blocks/infrastructure/serialization/_message_codec.py @@ -0,0 +1,64 @@ +"""Message codec ABC for encoding/decoding foundation messages.""" + +from abc import abstractmethod + +from forging_blocks.foundation import FinalABCMeta, runtime_final + + +class MessageCodec[M, Raw](metaclass=FinalABCMeta): + """Abstract codec for encoding and decoding messages. + + A *codec* is a bidirectional transformation between a message + instance and a raw representation. Subclasses implement the + serialization format (dict, bytes, wire protocol, etc.). + + Type Parameters: + M: The message type this codec handles. + Raw: The raw representation produced/consumed (e.g., ``dict``, + ``bytes``, ``str``). + """ + + @runtime_final + def encode(self, message: M) -> Raw: + """Encode *message* to its raw representation. + + Delegates to :meth:`_to_data` which subclasses must implement. + """ + return self._to_data(message) + + @runtime_final + def decode(self, data: Raw, message_type: type[M]) -> M: + """Decode *data* back into a message of *message_type*. + + Delegates to :meth:`_from_data` which subclasses must implement. + """ + return self._from_data(data, message_type) + + @abstractmethod + def _to_data(self, message: M) -> Raw: + """Convert *message* to its raw representation. + + Subclasses implement the serialization logic. + + Args: + message: The message instance to encode. + + Returns: + The raw representation of the message. + """ + ... + + @abstractmethod + def _from_data(self, data: Raw, message_type: type[M]) -> M: + """Reconstruct a message from its raw representation. + + Subclasses implement the deserialization logic. + + Args: + data: The raw data to decode. + message_type: The target message class (used to dispatch). + + Returns: + A new message instance of type *message_type*. + """ + ... diff --git a/src/forging_blocks/infrastructure/serialization/serializable.py b/src/forging_blocks/infrastructure/serialization/serializable.py deleted file mode 100644 index 8dccf989..00000000 --- a/src/forging_blocks/infrastructure/serialization/serializable.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Serializable protocol for messages and domain events. - -Provides a ``Serializable`` protocol that allows types to declare they can be -converted to and from plain dictionaries. This enables generic serialization -infrastructure (JSON, database adapters, event stores) to work with any type -that satisfies the protocol structurally — no base class required. -""" - -from typing import Protocol, Self - - -class Serializable[T](Protocol): - """Protocol for serializable objects. - - Types satisfying this protocol can be: - - serialised to a dictionary via ``to_dict()``. - - deserialised from a dictionary via ``from_dict()``. - - The protocol is structural — any class that defines ``to_dict`` and - ``from_dict`` with matching signatures satisfies it automatically, - without explicit registration or inheritance. - """ - - def to_dict(self) -> dict[str, T]: - """Return a dictionary representation of this instance.""" - ... - - @classmethod - def from_dict(cls, data: dict[str, T]) -> Self: - """Create an instance from a dictionary representation. - - Args: - data: Dictionary containing the serialised representation. - - Returns: - A new instance reconstituted from *data*. - - """ - ... From f86f3744b934d007d7664191acf5c8e9f7d53ecf Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 00:43:06 -0300 Subject: [PATCH 03/86] feat: add reconstitute classmethod to AggregateRoot Add AggregateRoot.reconstitute(id, version, events) classmethod for rebuilding aggregates from event store history. This replaces the removed _validate_identity helper. Update AggregateRepository to use reconstitute() when loading aggregates, passing the aggregate type via constructor. --- .../domain/aggregate_root/aggregate_root.py | 72 +++++++++++++------ .../repositories/aggregate_repository.py | 19 ++++- 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/forging_blocks/domain/aggregate_root/aggregate_root.py b/src/forging_blocks/domain/aggregate_root/aggregate_root.py index 636f19da..679bc253 100644 --- a/src/forging_blocks/domain/aggregate_root/aggregate_root.py +++ b/src/forging_blocks/domain/aggregate_root/aggregate_root.py @@ -1,7 +1,8 @@ """Base AggregateRoot class for Domain-Driven Design.""" from abc import abstractmethod -from collections.abc import Hashable +from collections.abc import Hashable, Sequence +from typing import Self from forging_blocks.domain.entity import Entity from forging_blocks.domain.errors.entity_id_none_error import EntityIdNoneError @@ -31,26 +32,6 @@ def __init__(self, aggregate_id: TId, version: AggregateVersion | None = None) - self._uncommitted_events: list[Event[EventPayloadType]] = [] super().__init__(aggregate_id) - @runtime_final - def _validate_identity(self, aggregate_id: Hashable) -> None: - """Ensure the aggregate root identity is valid. - - Aggregate roots must always have a defined, non-falsy identity. - Draft state is intentionally prohibited — identity must exist before - construction, not after persistence. - - Raises: - EntityIdNoneError: If the identity is None, an empty string, - or the boolean False. - - """ - is_none = aggregate_id is None - is_empty_string = aggregate_id == "" - is_false = aggregate_id is False - - if is_none or is_empty_string or is_false: - raise EntityIdNoneError(self.__class__.__name__) - @property def version(self) -> AggregateVersion: """Return the current version of the aggregate.""" @@ -61,6 +42,31 @@ def uncommitted_changes(self) -> list[Event[EventPayloadType]]: """Return a copy of uncommitted domain events recorded by this aggregate.""" return self._uncommitted_events.copy() + @classmethod + def reconstitute( + cls, + aggregate_id: TId, + events: Sequence[Event[EventPayloadType]], + ) -> Self: + """Reconstitute an aggregate from a sequence of stored events. + + Creates a new aggregate instance identified by *aggregate_id* and + replays each event via :meth:`replay` to restore its state. The + caller is responsible for providing events in chronological order. + + Args: + aggregate_id: The identity for the reconstituted aggregate. + events: Stored domain events in chronological order. + + Returns: + A fully reconstituted aggregate with version equal to the number + of events replayed. + """ + instance = cls(aggregate_id) + for event in events: + instance.replay(event) + return instance + @runtime_final def collect_events(self) -> list[Event[EventPayloadType]]: """Drain uncommitted events for the dispatcher. @@ -120,10 +126,30 @@ def replay(self, event: Event[EventPayloadType]) -> None: self._handle(event) self._version = self._version.increment() + @runtime_final + def _validate_identity(self, aggregate_id: Hashable) -> None: + """Ensure the aggregate root identity is valid. + + Aggregate roots must always have a defined, non-falsy identity. + Draft state is intentionally prohibited — identity must exist before + construction, not after persistence. + + Raises: + EntityIdNoneError: If the identity is None, an empty string, + or the boolean False. + + """ + is_none = aggregate_id is None + is_empty_string = aggregate_id == "" + is_false = aggregate_id is False + + if is_none or is_empty_string or is_false: + raise EntityIdNoneError(self.__class__.__name__) + @abstractmethod def _handle(self, event: Event[EventPayloadType]) -> None: """Mutate aggregate state in response to an event. - Implemented by concrete subclasses. Called exclusively - by apply() — never directly. + Implemented by concrete subclasses. Called by apply() and + replay() — never directly. """ diff --git a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py index 10f1f047..b26b4297 100644 --- a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py +++ b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py @@ -40,17 +40,22 @@ class AggregateRepository[ def __init__( self, event_store: EventStoreBase[EventPayloadType], + aggregate_type: type[TAggregateRoot], storage: dict[TId, TAggregateRoot] | None = None, ) -> None: """Initialize the aggregate repository. Args: event_store: The event store for persisting domain events. + aggregate_type: The aggregate root class. Used via + its ``reconstitute`` classmethod when an aggregate + must be rebuilt from stored events. storage: Optional in-memory storage for aggregate snapshots. """ super().__init__(storage) self._event_store = event_store + self._aggregate_type = aggregate_type async def save(self, aggregate: TAggregateRoot) -> None: """Save an aggregate and its uncommitted events. @@ -104,7 +109,17 @@ async def get_by_id(self, id: TId) -> TAggregateRoot | None: aggregate = await super().get_by_id(id) if aggregate is not None: return aggregate + result = await self._event_store.get_events(cast(UUID, id)) - if result.is_ok: + + if not result.is_ok: + raise result.error + + events = result.value + + if not events: return None - raise result.error + + aggregate = self._aggregate_type.reconstitute(id, events) + + return aggregate From c706994e507faa6382b21a7ff3ec0f6992cf098a Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 00:43:21 -0300 Subject: [PATCH 04/86] refactor: rename _from_payload_fields to from_payload_fields across tests and scripts Match the renamed abstract method on Message. All test fakes and inline subclasses now implement from_payload_fields (no leading underscore). Scripts/release command updated identically. --- .../commands/open_pull_request_command.py | 2 +- tests/fixtures/fake_event_with_name.py | 6 + tests/fixtures/fake_event_with_value.py | 6 + tests/fixtures/simple_fake_command.py | 6 + .../simple_fake_command_with_value.py | 6 + .../ports/outbound/test_command_sender.py | 2 +- .../ports/outbound/test_event_publisher.py | 2 +- .../ports/outbound/test_query_fetcher.py | 2 +- .../foundation/messages/test_command.py | 35 +-- .../foundation/messages/test_event.py | 4 +- .../foundation/messages/test_message.py | 203 +++--------------- .../message_bus/test_in_memory_message_bus.py | 4 +- .../test_message_bus_command_sender.py | 2 +- .../test_message_bus_event_publisher.py | 2 +- .../test_message_bus_query_fetcher.py | 2 +- .../repositories/test_aggregate_repository.py | 53 ++++- .../infrastructure/test_event_bus.py | 4 +- .../test_in_memory_unit_of_work.py | 2 +- .../bus/test_release_event_bus.py | 15 +- 19 files changed, 133 insertions(+), 225 deletions(-) diff --git a/scripts/release/domain/commands/open_pull_request_command.py b/scripts/release/domain/commands/open_pull_request_command.py index 3a807265..58aa1bb9 100644 --- a/scripts/release/domain/commands/open_pull_request_command.py +++ b/scripts/release/domain/commands/open_pull_request_command.py @@ -42,7 +42,7 @@ def _payload(self) -> dict[str, object]: return cast(dict[str, object], self._value) @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: """Reconstruct from payload fields and metadata.""" return cls( version=str(data["version"]), diff --git a/tests/fixtures/fake_event_with_name.py b/tests/fixtures/fake_event_with_name.py index d05bc3a7..67b3633a 100644 --- a/tests/fixtures/fake_event_with_name.py +++ b/tests/fixtures/fake_event_with_name.py @@ -1,3 +1,5 @@ +from typing import Self + from forging_blocks.foundation.messages.event import Event from forging_blocks.foundation.messages.message import MessageMetadata @@ -19,3 +21,7 @@ def _payload(self) -> dict[str, object]: @property def value(self) -> dict[str, object]: return self._payload + + @classmethod + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + return cls(name=str(data.get("name", "")), metadata=metadata) diff --git a/tests/fixtures/fake_event_with_value.py b/tests/fixtures/fake_event_with_value.py index bd0a2b73..44411071 100644 --- a/tests/fixtures/fake_event_with_value.py +++ b/tests/fixtures/fake_event_with_value.py @@ -1,3 +1,5 @@ +from typing import Self + from forging_blocks.foundation.messages.event import Event from forging_blocks.foundation.messages.message import MessageMetadata @@ -19,3 +21,7 @@ def _payload(self) -> dict[str, object]: @property def value(self) -> dict[str, object]: return self._payload + + @classmethod + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + return cls(value=str(data.get("value", "")), metadata=metadata) diff --git a/tests/fixtures/simple_fake_command.py b/tests/fixtures/simple_fake_command.py index cf1be3ce..b0f39b6f 100644 --- a/tests/fixtures/simple_fake_command.py +++ b/tests/fixtures/simple_fake_command.py @@ -1,3 +1,5 @@ +from typing import Self + from forging_blocks.foundation.messages.command import Command from forging_blocks.foundation.messages.message import MessageMetadata @@ -16,3 +18,7 @@ def _payload(self) -> dict[str, object]: @property def value(self) -> dict[str, object]: return self._payload + + @classmethod + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + return cls(name=str(data.get("name", "")), metadata=metadata) diff --git a/tests/fixtures/simple_fake_command_with_value.py b/tests/fixtures/simple_fake_command_with_value.py index 66e8961d..b04f31ef 100644 --- a/tests/fixtures/simple_fake_command_with_value.py +++ b/tests/fixtures/simple_fake_command_with_value.py @@ -1,3 +1,5 @@ +from typing import Self + from forging_blocks.foundation.messages.command import Command from forging_blocks.foundation.messages.message import MessageMetadata @@ -16,3 +18,7 @@ def _payload(self) -> dict[str, object]: @property def value(self) -> dict[str, object]: return self._payload + + @classmethod + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + return cls(value=str(data.get("value", "")), metadata=metadata) diff --git a/tests/forging_blocks/application/ports/outbound/test_command_sender.py b/tests/forging_blocks/application/ports/outbound/test_command_sender.py index 212ced42..456a6856 100644 --- a/tests/forging_blocks/application/ports/outbound/test_command_sender.py +++ b/tests/forging_blocks/application/ports/outbound/test_command_sender.py @@ -27,7 +27,7 @@ def _payload(self) -> dict[str, object]: return {"foo": "foo"} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls() diff --git a/tests/forging_blocks/application/ports/outbound/test_event_publisher.py b/tests/forging_blocks/application/ports/outbound/test_event_publisher.py index abc006ca..fcd9bbf4 100644 --- a/tests/forging_blocks/application/ports/outbound/test_event_publisher.py +++ b/tests/forging_blocks/application/ports/outbound/test_event_publisher.py @@ -27,7 +27,7 @@ def _payload(self) -> dict[str, object]: return {"foo": "bar"} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls() diff --git a/tests/forging_blocks/application/ports/outbound/test_query_fetcher.py b/tests/forging_blocks/application/ports/outbound/test_query_fetcher.py index 39281ea4..eeb1a713 100644 --- a/tests/forging_blocks/application/ports/outbound/test_query_fetcher.py +++ b/tests/forging_blocks/application/ports/outbound/test_query_fetcher.py @@ -27,7 +27,7 @@ def _payload(self) -> dict[str, object]: return {"foo": "bar"} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls() diff --git a/tests/forging_blocks/foundation/messages/test_command.py b/tests/forging_blocks/foundation/messages/test_command.py index 9d97ff49..2d999e50 100644 --- a/tests/forging_blocks/foundation/messages/test_command.py +++ b/tests/forging_blocks/foundation/messages/test_command.py @@ -4,9 +4,7 @@ """ # pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false -from datetime import datetime, timezone from typing import Any, Self -from uuid import uuid4 import pytest @@ -15,7 +13,7 @@ class PayloadAndValueNotImplCommand(Command): @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls() @@ -47,7 +45,7 @@ def _payload(self) -> dict[str, Any]: return {"customer_id": self._customer_id, "amount": self._amount} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls( customer_id=str(data.get("customer_id", "")), amount=float(str(data.get("amount", 0.0))), @@ -77,35 +75,6 @@ def test_issued_at_when_called_then_returns_created_at(self): assert issued_at == command.created_at - def test_to_dict_when_called_then_includes_command_payload(self): - message_id = uuid4() - created_at = datetime(2025, 6, 11, 19, 36, 6, tzinfo=timezone.utc) - metadata = MessageMetadata( - created_at=created_at, message_id=message_id, message_type="FakeUserCommand" - ) - command = FakeUserCommand("customer_123", 99.99, metadata=metadata) - - result = command.to_dict() - - expected = { - "metadata": { - "message_id": str(message_id), - "created_at": "2025-06-11T19:36:06+00:00", - "message_type": "FakeUserCommand", - "correlation_id": str(metadata.correlation_id), - "causation_id": str(metadata.causation_id), - }, - "payload": { - "customer_id": "customer_123", - "amount": 99.99, - }, - "data": { - "customer_id": "customer_123", - "amount": 99.99, - }, - } - assert result == expected - def test_payload_when_called_then_returns_command_data(self): command = FakeUserCommand("customer_123", 99.99) diff --git a/tests/forging_blocks/foundation/messages/test_event.py b/tests/forging_blocks/foundation/messages/test_event.py index 0f475e18..32a1ca2a 100644 --- a/tests/forging_blocks/foundation/messages/test_event.py +++ b/tests/forging_blocks/foundation/messages/test_event.py @@ -8,7 +8,7 @@ class PayloadNotImplementEvent(Event): @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls() @@ -50,7 +50,7 @@ def _payload(self) -> dict[str, Any]: } @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls( order_id=str(data.get("order_id", "")), customer_id=str(data.get("customer_id", "")), diff --git a/tests/forging_blocks/foundation/messages/test_message.py b/tests/forging_blocks/foundation/messages/test_message.py index 2f8a7fb5..6bb15900 100644 --- a/tests/forging_blocks/foundation/messages/test_message.py +++ b/tests/forging_blocks/foundation/messages/test_message.py @@ -1,10 +1,7 @@ -"""Unit tests for the Message module. - -Tests for MessageMetadata and Message classes. -""" +"""Unit tests for the Message base class.""" from datetime import datetime, timezone -from typing import Any, Self, cast +from typing import Any, Self from uuid import UUID, uuid7 import pytest @@ -28,132 +25,10 @@ def _payload(self) -> dict[str, Any]: return {"data": self._data} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls(data=str(data.get("data", "")), metadata=metadata) -@pytest.mark.unit -class TestMessageMetadata: - """Tests for MessageMetadata class.""" - - causation_id = uuid7() - created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) - correlation_id = uuid7() - message_id = uuid7() - message_type = "FakeMessage" - - def test_init_when_no_params_then_generates_id_and_timestamp(self): - metadata = MessageMetadata(message_type=self.message_type) - - assert isinstance(metadata.message_id, UUID) - assert isinstance(metadata.created_at, datetime) - assert metadata.created_at.tzinfo == timezone.utc - - def test_init_when_custom_params_then_uses_provided_values(self): - metadata = MessageMetadata( - message_type=self.message_type, - message_id=self.message_id, - created_at=self.created_at, - correlation_id=self.correlation_id, - causation_id=self.causation_id, - ) - - assert metadata.causation_id == self.causation_id - assert metadata.correlation_id == self.correlation_id - assert metadata.message_type == self.message_type - assert metadata.message_id == self.message_id - assert metadata.created_at == self.created_at - - def test_init_when_partial_params_then_generates_missing_values(self): - metadata = MessageMetadata(message_type=self.message_type, message_id=self.message_id) - - assert metadata.message_id == self.message_id - assert isinstance(metadata.created_at, datetime) - assert metadata.created_at.tzinfo == timezone.utc - assert isinstance(metadata.correlation_id, UUID) - assert isinstance(metadata.causation_id, UUID) - - def test_eq_when_same_id_and_timestamp_then_true(self): - metadata1 = MessageMetadata( - message_type=self.message_type, - message_id=self.message_id, - created_at=self.created_at, - ) - metadata2 = MessageMetadata( - message_type=self.message_type, - message_id=self.message_id, - created_at=self.created_at, - ) - - assert metadata1 == metadata2 - - def test_eq_when_different_id_then_false(self): - created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) - - metadata1 = MessageMetadata( - created_at=created_at, - message_id=uuid7(), - message_type=self.message_type, - ) - metadata2 = MessageMetadata( - created_at=created_at, - message_id=uuid7(), - message_type=self.message_type, - ) - - assert metadata1 != metadata2 - - def test_eq_when_different_timestamp_then_false(self): - message_id = uuid7() - - metadata1 = MessageMetadata( - message_type=self.message_type, - message_id=message_id, - created_at=datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc), - ) - metadata2 = MessageMetadata( - message_type=self.message_type, - message_id=message_id, - created_at=datetime(2025, 6, 11, 19, 44, 15, tzinfo=timezone.utc), - ) - - assert metadata1 != metadata2 - - def test_to_dict_when_called_then_returns_serializable_dict(self): - message_id = uuid7() - created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) - - metadata = MessageMetadata( - message_type=self.message_type, message_id=message_id, created_at=created_at - ) - result = metadata.to_dict() - - assert result["message_id"] == str(message_id) - assert result["created_at"] == "2025-06-11T19:44:14+00:00" - assert result["message_type"] == self.message_type - UUID(str(result["correlation_id"])) - UUID(str(result["causation_id"])) - - def test_hash_when_same_values_then_same_hash(self): - message_id = uuid7() - created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) - - metadata1 = MessageMetadata( - message_type=self.message_type, message_id=message_id, created_at=created_at - ) - metadata2 = MessageMetadata( - message_type=self.message_type, message_id=message_id, created_at=created_at - ) - - assert hash(metadata1) == hash(metadata2) - - def test_hash_when_different_values_then_different_hash(self): - metadata1 = MessageMetadata(message_type="Message") - metadata2 = MessageMetadata(message_type="AnotherMessage") - - assert hash(metadata1) != hash(metadata2) - - @pytest.mark.unit class TestMessage: """Tests for Message class.""" @@ -215,25 +90,6 @@ def test_eq_when_type_different_than_message_then_false(self) -> None: assert message != another_type_instance - def test_to_dict_when_called_then_includes_metadata_type_and_payload(self): - message_id = uuid7() - created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) - metadata = MessageMetadata( - message_type="FakeMessage", message_id=message_id, created_at=created_at - ) - - message = FakeMessage("test_data", metadata=metadata) - result = message.to_dict() - - meta = result["metadata"] - assert isinstance(meta, dict) - assert meta["message_id"] == str(message_id) - assert meta["created_at"] == "2025-06-11T19:44:14+00:00" - assert meta["message_type"] == "FakeMessage" - UUID(str(cast(str, meta["correlation_id"]))) - UUID(str(cast(str, meta["causation_id"]))) - assert result["payload"] == {"data": "test_data"} - def test_hash_when_same_message_id_then_same_hash(self): metadata = MessageMetadata("FakeMessage", message_id=uuid7()) @@ -252,34 +108,39 @@ def test_cannot_instantiate_abstract_message_directly(self): with pytest.raises(TypeError, match="abstract"): type.__call__(Message) + def test_metadata_property_when_called_then_returns_stored_metadata(self) -> None: + metadata = MessageMetadata(message_type="FakeMessage") -@pytest.mark.unit -class TestMessageDataclassDecorator: - """Tests for the message_dataclass decorator internals.""" + message = FakeMessage("test_data", metadata=metadata) - def test_message_dataclass_when_patched_message_check_fails_then_type_error( - self, - ) -> None: - from unittest.mock import patch + assert message.metadata is metadata - from forging_blocks.foundation.messages.decorators import ( - _PatchedMessage, # pyright: ignore[reportPrivateUsage] - message_dataclass, - ) + def test_eq_when_same_instance_then_true(self) -> None: + message = FakeMessage("test_data") + + assert message == message + + def test_eq_when_different_message_type_then_false(self) -> None: + class _(Message[str]): + def __init__(self, data: str, metadata: MessageMetadata | None = None): + super().__init__(metadata) + self._data = data + + @property + def value(self) -> str: + return self._data - class NotAMessage: - x: int + @property + def _payload(self) -> str: + return self._data - original_isinstance = isinstance + @classmethod + def from_payload_fields( + cls, data: dict[str, object], metadata: MessageMetadata + ) -> Self: + return cls(str(data.get("data", "")), metadata=metadata) - def _selective_isinstance(obj: object, cls: type) -> bool: - if cls is _PatchedMessage: - return False - return original_isinstance(obj, cls) + fake = FakeMessage("data") + other = _("data") - with patch( - "forging_blocks.foundation.messages.decorators.isinstance", - side_effect=_selective_isinstance, - ): - with pytest.raises(TypeError, match="does not satisfy _PatchedMessage"): - message_dataclass(NotAMessage) # type: ignore[reportArgumentType] + assert fake != other diff --git a/tests/forging_blocks/infrastructure/message_bus/test_in_memory_message_bus.py b/tests/forging_blocks/infrastructure/message_bus/test_in_memory_message_bus.py index 01ca754f..3ea8ea7c 100644 --- a/tests/forging_blocks/infrastructure/message_bus/test_in_memory_message_bus.py +++ b/tests/forging_blocks/infrastructure/message_bus/test_in_memory_message_bus.py @@ -26,7 +26,7 @@ def _payload(self) -> dict[str, Any]: return {"data": self._data} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls(data=str(data.get("data", "")), metadata=metadata) @@ -44,7 +44,7 @@ def _payload(self) -> dict[str, Any]: return {"data": self._data} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls(data=str(data.get("data", "")), metadata=metadata) diff --git a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_command_sender.py b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_command_sender.py index 1429f8d5..cdfc2915 100644 --- a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_command_sender.py +++ b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_command_sender.py @@ -17,7 +17,7 @@ def _payload(self) -> dict[str, Any]: # type: ignore[override] return {"foo": "foo"} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls() diff --git a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_event_publisher.py b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_event_publisher.py index 5b9e361e..e48dafb3 100644 --- a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_event_publisher.py +++ b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_event_publisher.py @@ -18,7 +18,7 @@ def _payload(self) -> dict[str, str]: return {"foo": "bar"} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls() diff --git a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_query_fetcher.py b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_query_fetcher.py index c587e7fa..22440821 100644 --- a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_query_fetcher.py +++ b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_query_fetcher.py @@ -18,7 +18,7 @@ def _payload(self) -> dict[str, Any]: return {"foo": "bar"} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls() diff --git a/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py b/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py index 80676b70..bbc46b9a 100644 --- a/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py +++ b/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py @@ -9,6 +9,7 @@ from forging_blocks.application.errors.event_store_error import EventStoreError from forging_blocks.domain.aggregate_root.aggregate_root import AggregateRoot from forging_blocks.foundation.messages.event import Event +from forging_blocks.foundation.messages.message import MessageMetadata from forging_blocks.foundation.result import Err, Result from forging_blocks.infrastructure.event_stores.in_memory_event_store_base import ( InMemoryEventStoreBase, @@ -31,6 +32,12 @@ def _payload(self) -> dict[str, object]: def value(self) -> dict[str, object]: return self._payload + @classmethod + def from_payload_fields(cls, data: object, metadata: MessageMetadata) -> "FakeEvent": + if isinstance(data, dict): + return cls(name=str(data.get("name", ""))) + return cls(name="") + class FakeAggregate(AggregateRoot[UUID, object]): def __init__(self, aggregate_id: UUID) -> None: @@ -74,6 +81,7 @@ class TestAggregateRepository: async def test_save_and_retrieve_aggregate(self) -> None: repo = AggregateRepository[object, FakeAggregate, UUID]( event_store=InMemoryEventStoreBase[object](), + aggregate_type=FakeAggregate, ) aggregate = FakeAggregate(uuid7()) @@ -85,7 +93,9 @@ async def test_save_and_retrieve_aggregate(self) -> None: async def test_save_persists_events_to_event_store(self) -> None: event_store = InMemoryEventStoreBase[object]() - repo = AggregateRepository[object, FakeAggregate, UUID](event_store=event_store) + repo = AggregateRepository[object, FakeAggregate, UUID]( + event_store=event_store, aggregate_type=FakeAggregate + ) aggregate = FakeAggregate(uuid7()) aggregate.add_item("widget") @@ -98,6 +108,7 @@ async def test_save_persists_events_to_event_store(self) -> None: async def test_get_by_id_returns_none_for_unknown_aggregate(self) -> None: repo = AggregateRepository[object, FakeAggregate, UUID]( event_store=InMemoryEventStoreBase[object](), + aggregate_type=FakeAggregate, ) retrieved = await repo.get_by_id(uuid7()) @@ -106,7 +117,9 @@ async def test_get_by_id_returns_none_for_unknown_aggregate(self) -> None: async def test_save_with_multiple_events_persists_all(self) -> None: event_store = InMemoryEventStoreBase[object]() - repo = AggregateRepository[object, FakeAggregate, UUID](event_store=event_store) + repo = AggregateRepository[object, FakeAggregate, UUID]( + event_store=event_store, aggregate_type=FakeAggregate + ) aggregate = FakeAggregate(uuid7()) aggregate.add_item("a") aggregate.add_item("b") @@ -120,7 +133,9 @@ async def test_save_with_multiple_events_persists_all(self) -> None: async def test_save_without_events_does_not_append_to_event_store(self) -> None: event_store = InMemoryEventStoreBase[object]() - repo = AggregateRepository[object, FakeAggregate, UUID](event_store=event_store) + repo = AggregateRepository[object, FakeAggregate, UUID]( + event_store=event_store, aggregate_type=FakeAggregate + ) aggregate = FakeAggregate(uuid7()) await repo.save(aggregate) @@ -134,6 +149,7 @@ async def test_init_with_prefilled_storage_finds_existing(self) -> None: storage = {cast(UUID, aggregate.id): aggregate} repo = AggregateRepository[object, FakeAggregate, UUID]( event_store=InMemoryEventStoreBase[object](), + aggregate_type=FakeAggregate, storage=storage, ) @@ -142,19 +158,43 @@ async def test_init_with_prefilled_storage_finds_existing(self) -> None: assert retrieved is not None assert retrieved.id == aggregate.id - async def test_get_by_id_returns_none_when_only_event_store_has_events(self) -> None: + async def test_get_by_id_when_events_in_store_only_then_reconstitutes_aggregate(self) -> None: aggregate_id = uuid7() event_store = InMemoryEventStoreBase[object]() await event_store.append_events(aggregate_id, [FakeEvent("stale")]) - repo = AggregateRepository[object, FakeAggregate, UUID](event_store=event_store) + repo = AggregateRepository[object, FakeAggregate, UUID]( + event_store=event_store, + aggregate_type=FakeAggregate, + ) retrieved = await repo.get_by_id(aggregate_id) - assert retrieved is None + assert retrieved is not None + assert retrieved.id == aggregate_id + assert retrieved.items == ["stale"] + assert retrieved.version.value == 1 + + async def test_get_by_id_when_multiple_events_in_store_then_replays_all_in_order(self) -> None: + aggregate_id = uuid7() + event_store = InMemoryEventStoreBase[object]() + await event_store.append_events( + aggregate_id, [FakeEvent("a"), FakeEvent("b"), FakeEvent("c")] + ) + repo = AggregateRepository[object, FakeAggregate, UUID]( + event_store=event_store, + aggregate_type=FakeAggregate, + ) + + retrieved = await repo.get_by_id(aggregate_id) + + assert retrieved is not None + assert retrieved.items == ["a", "b", "c"] + assert retrieved.version.value == 3 async def test_save_when_event_store_append_fails_then_raises_error(self) -> None: repo = AggregateRepository[object, FakeAggregate, UUID]( event_store=FailingAppendEventStore(), + aggregate_type=FakeAggregate, ) aggregate = FakeAggregate(uuid7()) aggregate.add_item("widget") @@ -167,6 +207,7 @@ async def test_get_by_id_when_event_store_get_events_fails_then_raises_error( ) -> None: repo = AggregateRepository[object, FakeAggregate, UUID]( event_store=FailingGetEventsStore(), + aggregate_type=FakeAggregate, ) with pytest.raises(EventStoreError, match="Connection lost"): diff --git a/tests/forging_blocks/infrastructure/test_event_bus.py b/tests/forging_blocks/infrastructure/test_event_bus.py index 7a4a0418..3860de11 100644 --- a/tests/forging_blocks/infrastructure/test_event_bus.py +++ b/tests/forging_blocks/infrastructure/test_event_bus.py @@ -161,7 +161,7 @@ def value(self) -> TestPayload: return self._payload @classmethod - def _from_payload_fields(cls, data: TestPayload, metadata: MessageMetadata) -> EventA: + def from_payload_fields(cls, data: TestPayload, metadata: MessageMetadata) -> EventA: return cls() class EventB(Event[TestPayload]): @@ -174,7 +174,7 @@ def value(self) -> TestPayload: return self._payload @classmethod - def _from_payload_fields(cls, data: TestPayload, metadata: MessageMetadata) -> EventB: + def from_payload_fields(cls, data: TestPayload, metadata: MessageMetadata) -> EventB: return cls() event_bus.register_handler(EventA, HandlerA()) diff --git a/tests/forging_blocks/infrastructure/unit_of_work/test_in_memory_unit_of_work.py b/tests/forging_blocks/infrastructure/unit_of_work/test_in_memory_unit_of_work.py index a0807f14..0dbda5d8 100644 --- a/tests/forging_blocks/infrastructure/unit_of_work/test_in_memory_unit_of_work.py +++ b/tests/forging_blocks/infrastructure/unit_of_work/test_in_memory_unit_of_work.py @@ -29,7 +29,7 @@ def _payload(self) -> dict[str, Any]: return {"data": self._data} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls(data=str(data.get("data", "")), metadata=metadata) diff --git a/tests/scripts/release/infrastructure/bus/test_release_event_bus.py b/tests/scripts/release/infrastructure/bus/test_release_event_bus.py index 7caa2be6..8798eb62 100644 --- a/tests/scripts/release/infrastructure/bus/test_release_event_bus.py +++ b/tests/scripts/release/infrastructure/bus/test_release_event_bus.py @@ -1,5 +1,5 @@ # pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false -from typing import Any +from typing import Any, Self import pytest from scripts.release.infrastructure.bus.in_memory_release_command_bus import ( @@ -8,6 +8,7 @@ from forging_blocks.application.ports.inbound.message_handler_port import MessageHandlerPort from forging_blocks.foundation.messages.command import Command +from forging_blocks.foundation.messages.message import MessageMetadata class FakeCommand(Command): @@ -21,9 +22,14 @@ def __init__(self, val: str = "test") -> None: def value(self) -> str: return self._value + @property def _payload(self) -> dict[str, Any]: return {"value": self._value} + @classmethod + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + return cls(val=str(data.get("value", "test"))) + class FakeHandler(MessageHandlerPort[FakeCommand, None]): """State-based handler fake — records handled commands.""" @@ -97,8 +103,15 @@ def __init__(self, val: str = "other") -> None: def value(self) -> str: return self._value + @property def _payload(self) -> dict[str, Any]: return {"value": self._value} + @classmethod + def from_payload_fields( + cls, data: dict[str, object], metadata: MessageMetadata + ) -> "OtherCommand": + return cls(val=str(data.get("value", "other"))) + with pytest.raises(KeyError): await bus.send(OtherCommand()) From 270abc32ba4078d2be4b67a5234b8c8ef3f098a0 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 00:43:31 -0300 Subject: [PATCH 05/86] test: reorganize message tests into decorators, metadata, and serialization modules Split test_serializable.py into focused test modules: test_decorators.py (message dataclass decorator behavior), test_metadata.py (MessageMetadata), and new infrastructure/serialization/ tests. Update test_aggregate_root.py for reconstitute classmethod. --- .../aggregate_root/test_aggregate_root.py | 132 +++++----- .../foundation/messages/test_decorators.py | 35 +++ .../foundation/messages/test_metadata.py | 207 +++++++++++++++ .../foundation/messages/test_serializable.py | 247 ++---------------- .../infrastructure/serialization/__init__.py | 0 .../serialization/test_dict_message_codec.py | 140 ++++++++++ 6 files changed, 466 insertions(+), 295 deletions(-) create mode 100644 tests/forging_blocks/foundation/messages/test_decorators.py create mode 100644 tests/forging_blocks/foundation/messages/test_metadata.py create mode 100644 tests/forging_blocks/infrastructure/serialization/__init__.py create mode 100644 tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py diff --git a/tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py b/tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py index 03268fc0..87820b72 100644 --- a/tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py +++ b/tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py @@ -1,4 +1,3 @@ -# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false from typing import Self import pytest @@ -28,7 +27,7 @@ def _payload(self) -> raw_event: return {"name": self.name} @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: + def from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: return cls(name=str(data.get("name", "")), metadata=metadata) @@ -60,7 +59,7 @@ def _handle(self, _: Event) -> None: class TestAggregateRoot: def test___init___when_id_is_none_then_raises_entity_id_none_error(self) -> None: with pytest.raises(EntityIdNoneError): - OrderAggregate(None) # type: ignore + OrderAggregate(None) def test___init___when_id_is_valid_then_initializes_with_default_version_zero( self, @@ -77,13 +76,25 @@ def test___init___when_version_is_provided_then_sets_custom_version(self) -> Non assert aggregate.version == custom_version - def test___init___when_version_is_not_provided_then_sets_version_zero(self) -> None: - id = 1 - aggregate = OrderAggregate(id) + def test___init___when_id_is_zero_then_initializes_successfully(self) -> None: + aggregate = OrderAggregate(0) - expected_version_value = 0 + assert aggregate.id == 0 + assert aggregate.version.value == 0 - assert aggregate.version.value == expected_version_value + def test___init___when_id_is_empty_string_then_raises_entity_id_none_error(self) -> None: + with pytest.raises(EntityIdNoneError): + StringAggregate("") + + def test___init___when_id_is_false_then_raises_entity_id_none_error(self) -> None: + with pytest.raises(EntityIdNoneError): + BoolAggregate(False) + + def test___init___when_id_is_true_then_initializes_successfully(self) -> None: + aggregate = BoolAggregate(True) + + assert aggregate.id is True + assert aggregate.version.value == 0 def test_version_property_when_accessed_then_returns_current_version(self) -> None: aggregate = OrderAggregate(1) @@ -107,11 +118,10 @@ def test_record_event_when_event_recorded_then_event_is_stored_in_uncommitted_ev ) -> None: aggregate = OrderAggregate(1) event = DummyEvent("created") - aggregate.record_event(event) - result = aggregate.uncommitted_changes + aggregate.record_event(event) - assert result == [event] + assert aggregate.uncommitted_changes == [event] def test_collect_events_when_called_then_clears_uncommitted_events( self, @@ -123,7 +133,7 @@ def test_collect_events_when_called_then_clears_uncommitted_events( assert aggregate.uncommitted_changes == [] - def test_collect_events_does_not_increment_version(self) -> None: + def test_collect_events_when_called_then_does_not_increment_version(self) -> None: aggregate = OrderAggregate(1) aggregate.record_event(DummyEvent("x")) @@ -143,21 +153,6 @@ def test_discard_events_when_called_then_clears_uncommitted_events_without_versi assert aggregate.uncommitted_changes == [] assert aggregate.version == old_version - def test___init___when_id_is_zero_then_initializes_successfully(self) -> None: - aggregate = OrderAggregate(0) - - actual_id = aggregate.id - actual_version_value = aggregate.version.value - - expected_id = 0 - expected_version_value = 0 - assert actual_id == expected_id - assert actual_version_value == expected_version_value - - def test___init___when_id_is_empty_string_then_raises_entity_id_none_error(self) -> None: - with pytest.raises(EntityIdNoneError): - StringAggregate("") - def test_apply_when_called_then_records_event_in_uncommitted(self) -> None: aggregate = OrderAggregate(1) event = DummyEvent("created") @@ -174,35 +169,6 @@ def test_apply_when_called_then_increments_version(self) -> None: assert aggregate.version == AggregateVersion(1) - def test_apply_is_runtime_final_on_aggregate_root(self) -> None: - assert getattr(AggregateRoot.apply, "__is_runtime_final__", False) is True - - def test__handle_is_abstract_on_aggregate_root(self) -> None: - assert getattr(AggregateRoot._handle, "__isabstractmethod__", False) is True - - def test_discard_events_is_runtime_final_on_aggregate_root(self) -> None: - assert getattr(AggregateRoot.discard_events, "__is_runtime_final__", False) is True - - def test_overriding_discard_events_raises_type_error(self) -> None: - with pytest.raises(TypeError, match="runtime-final"): - - class BadAggregate(OrderAggregate): # type: ignore[misc] - def discard_events(self) -> None: - pass - - def test___init___when_id_is_false_then_raises_entity_id_none_error(self) -> None: - with pytest.raises(EntityIdNoneError): - BoolAggregate(False) - - def test___init___when_id_is_true_then_initializes_successfully(self) -> None: - aggregate = BoolAggregate(True) - - assert aggregate.id is True - assert aggregate.version.value == 0 - - -@pytest.mark.unit -class TestReplay: def test_replay_when_called_then_does_not_record_event_in_uncommitted( self, ) -> None: @@ -221,12 +187,52 @@ def test_replay_when_called_then_increments_version(self) -> None: assert aggregate.version == AggregateVersion(1) - def test_replay_is_runtime_final_on_aggregate_root(self) -> None: - assert getattr(AggregateRoot.replay, "__is_runtime_final__", False) is True + def test_reconstitute_when_called_then_creates_aggregate_with_given_id( + self, + ) -> None: + events = [DummyEvent("a")] + + aggregate = OrderAggregate.reconstitute(42, events) + + assert aggregate.id == 42 + + def test_reconstitute_when_called_then_replays_all_events_in_order( + self, + ) -> None: + events = [DummyEvent("a"), DummyEvent("b"), DummyEvent("c")] + + aggregate = OrderAggregate.reconstitute(1, events) + + assert aggregate.version.value == 3 + + def test_reconstitute_when_called_then_does_not_record_events_as_uncommitted( + self, + ) -> None: + events = [DummyEvent("a")] + + aggregate = OrderAggregate.reconstitute(1, events) + + assert aggregate.uncommitted_changes == [] + + def test_reconstitute_when_empty_event_list_then_has_version_zero_and_returns_aggregate( + self, + ) -> None: + aggregate = OrderAggregate.reconstitute(1, []) + + assert aggregate is not None + assert aggregate.id == 1 + assert aggregate.version.value == 0 + + def test_reconstitute_when_handle_raises_then_exception_propagates(self) -> None: + class _(AggregateRoot[int, raw_event]): + def __init__(self, aggregate_id: int) -> None: + super().__init__(aggregate_id) + + def _handle(self, event: Event[raw_event]) -> None: + msg = "Replay failed" + raise RuntimeError(msg) - def test_overriding_replay_raises_type_error(self) -> None: - with pytest.raises(TypeError, match="runtime-final"): + events = [DummyEvent("boom")] - class _(OrderAggregate): - def replay(self, _: Event) -> None: - pass + with pytest.raises(RuntimeError, match="Replay failed"): + _.reconstitute(1, events) diff --git a/tests/forging_blocks/foundation/messages/test_decorators.py b/tests/forging_blocks/foundation/messages/test_decorators.py new file mode 100644 index 00000000..3e98e05c --- /dev/null +++ b/tests/forging_blocks/foundation/messages/test_decorators.py @@ -0,0 +1,35 @@ +"""Unit tests for message dataclass decorators.""" + +import pytest + + +@pytest.mark.unit +class TestMessageDataclassDecorator: + """Tests for the message_dataclass decorator internals.""" + + def test_message_dataclass_when_patched_message_check_fails_then_type_error( + self, + ) -> None: + from unittest.mock import patch + + from forging_blocks.foundation.messages.decorators import ( + _PatchedMessage, # pyright: ignore[reportPrivateUsage] + message_dataclass, + ) + + class NotAMessage: + x: int + + original_isinstance = isinstance + + def _selective_isinstance(obj: object, cls: type) -> bool: + if cls is _PatchedMessage: + return False + return original_isinstance(obj, cls) + + with patch( + "forging_blocks.foundation.messages.decorators.isinstance", + side_effect=_selective_isinstance, + ): + with pytest.raises(TypeError, match="does not satisfy _PatchedMessage"): + message_dataclass(NotAMessage) # type: ignore[reportArgumentType] diff --git a/tests/forging_blocks/foundation/messages/test_metadata.py b/tests/forging_blocks/foundation/messages/test_metadata.py new file mode 100644 index 00000000..f5b89aba --- /dev/null +++ b/tests/forging_blocks/foundation/messages/test_metadata.py @@ -0,0 +1,207 @@ +"""Unit tests for MessageMetadata.""" + +from datetime import datetime, timezone +from uuid import UUID, uuid7 + +import pytest + +from forging_blocks.foundation import CantModifyImmutableAttributeError +from forging_blocks.foundation.messages import MessageMetadata + + +@pytest.mark.unit +class TestMessageMetadata: + """Tests for MessageMetadata value object.""" + + causation_id = uuid7() + created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) + correlation_id = uuid7() + message_id = uuid7() + message_type = "FakeMessage" + + def test_init_when_no_params_then_generates_id_and_timestamp(self) -> None: + """Only message_type provided; id and timestamp are auto-generated.""" + metadata = MessageMetadata(message_type=self.message_type) + + assert isinstance(metadata.message_id, UUID) + assert isinstance(metadata.created_at, datetime) + assert metadata.created_at.tzinfo == timezone.utc + + def test_init_when_custom_params_then_uses_provided_values(self) -> None: + """All parameters provided explicitly; every field matches input.""" + metadata = MessageMetadata( + message_type=self.message_type, + message_id=self.message_id, + created_at=self.created_at, + correlation_id=self.correlation_id, + causation_id=self.causation_id, + ) + + assert metadata.message_type == self.message_type + assert metadata.message_id == self.message_id + assert metadata.created_at == self.created_at + assert metadata.correlation_id == self.correlation_id + assert metadata.causation_id == self.causation_id + + def test_init_when_partial_params_then_generates_missing_values(self) -> None: + """Only message_type and message_id provided; missing fields auto-generated.""" + metadata = MessageMetadata(message_type=self.message_type, message_id=self.message_id) + + assert metadata.message_id == self.message_id + assert isinstance(metadata.created_at, datetime) + assert metadata.created_at.tzinfo == timezone.utc + assert isinstance(metadata.correlation_id, UUID) + assert isinstance(metadata.causation_id, UUID) + + def test_init_when_causation_id_provided_then_stores_it(self) -> None: + """Explicit causation_id is stored rather than auto-generated.""" + explicit_causation = uuid7() + + metadata = MessageMetadata(message_type=self.message_type, causation_id=explicit_causation) + + assert metadata.causation_id == explicit_causation + + def test_causation_id_property_when_called_then_returns_uuid(self) -> None: + """causation_id always returns a UUID instance.""" + metadata = MessageMetadata(message_type=self.message_type) + + assert isinstance(metadata.causation_id, UUID) + + def test_correlation_id_property_when_called_then_returns_uuid(self) -> None: + """correlation_id always returns a UUID instance.""" + metadata = MessageMetadata(message_type=self.message_type) + + assert isinstance(metadata.correlation_id, UUID) + + def test_message_id_property_when_called_then_returns_uuid(self) -> None: + """message_id always returns a UUID instance.""" + metadata = MessageMetadata(message_type=self.message_type) + + assert isinstance(metadata.message_id, UUID) + + def test_created_at_property_when_default_then_has_utc_timezone(self) -> None: + """Auto-generated created_at carries UTC timezone info.""" + metadata = MessageMetadata(message_type=self.message_type) + + assert metadata.created_at.tzinfo == timezone.utc + + def test_created_at_when_provided_without_timezone_then_preserves_value(self) -> None: + """Naive datetime passed as-is — no timezone coercion applied.""" + naive = datetime(2025, 6, 11, 19, 44, 14) + + metadata = MessageMetadata(message_type=self.message_type, created_at=naive) + + assert metadata.created_at == naive + assert metadata.created_at.tzinfo is None + + def test_eq_when_same_id_and_timestamp_then_true(self) -> None: + """Two instances with identical message_id and created_at are equal.""" + metadata1 = MessageMetadata( + message_type=self.message_type, + message_id=self.message_id, + created_at=self.created_at, + ) + metadata2 = MessageMetadata( + message_type=self.message_type, + message_id=self.message_id, + created_at=self.created_at, + ) + + assert metadata1 == metadata2 + + def test_eq_when_different_id_then_false(self) -> None: + """Same timestamp, different message_id — not equal.""" + created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) + + metadata1 = MessageMetadata( + message_type=self.message_type, + message_id=uuid7(), + created_at=created_at, + ) + metadata2 = MessageMetadata( + message_type=self.message_type, + message_id=uuid7(), + created_at=created_at, + ) + + assert metadata1 != metadata2 + + def test_eq_when_different_timestamp_then_false(self) -> None: + """Same message_id, different created_at — not equal.""" + message_id = uuid7() + + metadata1 = MessageMetadata( + message_type=self.message_type, + message_id=message_id, + created_at=datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc), + ) + metadata2 = MessageMetadata( + message_type=self.message_type, + message_id=message_id, + created_at=datetime(2025, 6, 11, 19, 44, 15, tzinfo=timezone.utc), + ) + + assert metadata1 != metadata2 + + def test_eq_when_different_type_then_false(self) -> None: + """A MessageMetadata is never equal to a non-ValueObject instance.""" + metadata = MessageMetadata(message_type=self.message_type) + + assert metadata != "not metadata" + assert metadata != 42 + + def test_hash_when_same_values_then_same_hash(self) -> None: + """Equal instances produce identical hashes.""" + message_id = uuid7() + created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) + + metadata1 = MessageMetadata( + message_type=self.message_type, + message_id=message_id, + created_at=created_at, + ) + metadata2 = MessageMetadata( + message_type=self.message_type, + message_id=message_id, + created_at=created_at, + ) + + assert hash(metadata1) == hash(metadata2) + + def test_hash_when_different_values_then_different_hash(self) -> None: + """Instances with different ids produce different hashes.""" + metadata1 = MessageMetadata(message_type="Message") + metadata2 = MessageMetadata(message_type="AnotherMessage") + + assert hash(metadata1) != hash(metadata2) + + def test_hash_consistency_when_same_instance_then_same_hash(self) -> None: + """Hash called multiple times on the same instance returns same value.""" + metadata = MessageMetadata(message_type=self.message_type) + + assert hash(metadata) == hash(metadata) + + def test_value_property_when_called_then_returns_all_fields(self) -> None: + """value dict contains every metadata field with correct serialized values.""" + message_id = uuid7() + created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) + + metadata = MessageMetadata( + message_type=self.message_type, + message_id=message_id, + created_at=created_at, + ) + value = metadata.value + + assert value["message_type"] == self.message_type + assert value["message_id"] == str(message_id) + assert value["created_at"] == "2025-06-11T19:44:14+00:00" + UUID(str(value["correlation_id"])) + UUID(str(value["causation_id"])) + + def test_modification_when_setting_attribute_then_raises(self) -> None: + """MessageMetadata is immutable — attribute assignment raises.""" + metadata = MessageMetadata(message_type=self.message_type) + + with pytest.raises(CantModifyImmutableAttributeError): + metadata._message_id = uuid7() # type: ignore[reportAttributeAccessIssue] diff --git a/tests/forging_blocks/foundation/messages/test_serializable.py b/tests/forging_blocks/foundation/messages/test_serializable.py index 621edc1d..6f782e9d 100644 --- a/tests/forging_blocks/foundation/messages/test_serializable.py +++ b/tests/forging_blocks/foundation/messages/test_serializable.py @@ -1,9 +1,7 @@ -"""Tests for the Serializable protocol and message serialisation support.""" +"""Tests for message decorators and frozen instance behavior.""" import dataclasses -from datetime import datetime, timezone -from typing import Any, Self, cast -from uuid import uuid7 +from typing import Any, cast import pytest @@ -16,137 +14,11 @@ from forging_blocks.foundation.messages.message import MessageMetadata -class SimpleEvent(Event[dict[str, object]]): - """Simple event dummy for testing.""" - - @property - def _payload(self) -> dict[str, object]: - return {"key": "value"} - - @property - def value(self) -> dict[str, object]: - return self._payload - - @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: - return cls() - - -class EventWithoutFromPayloadFields(Event[dict[str, object]]): - """Event dummy without _from_payload_fields for testing.""" - - @property - def _payload(self) -> dict[str, object]: - return {} - - @property - def value(self) -> dict[str, object]: - return self._payload - - -class CustomEvent(Event[dict[str, object]]): - """Event dummy with custom _from_payload_fields for testing.""" - - def __init__(self, value: str, metadata: MessageMetadata | None = None) -> None: - super().__init__(metadata) - self._value = value - - @property - def _payload(self) -> dict[str, object]: - return {"value": self._value} - - @property - def value(self) -> dict[str, object]: - return self._payload - - @classmethod - def _from_payload_fields(cls, data: dict[str, object], metadata: MessageMetadata) -> Self: - return cls(value=str(data["value"]), metadata=metadata) - - -class TestMessageMetadataSerialization: - """MessageMetadata.from_dict() round-trip.""" - - def test_from_dict_round_trip(self) -> None: - """A MessageMetadata serialised to dict and back should be equivalent.""" - original = MessageMetadata(message_type="TestEvent") - data = original.to_dict() - restored = MessageMetadata.from_dict(data) - assert restored.message_id == original.message_id - assert restored.created_at == original.created_at - assert restored.correlation_id == original.correlation_id - assert restored.causation_id == original.causation_id - assert restored.message_type == original.message_type - - def test_from_dict_with_explicit_values(self) -> None: - """from_dict() handles explicit, non-None values correctly.""" - mid = uuid7() - ts = datetime(2025, 6, 11, 19, 36, 6, tzinfo=timezone.utc) - cid = uuid7() - caid = uuid7() - data: dict[str, object] = { - "message_type": "CustomEvent", - "message_id": str(mid), - "created_at": ts.isoformat(), - "correlation_id": str(cid), - "causation_id": str(caid), - } - meta = MessageMetadata.from_dict(data) - assert meta.message_id == mid - assert meta.created_at == ts - assert meta.correlation_id == cid - assert meta.causation_id == caid - assert meta.message_type == "CustomEvent" - - -class TestMessageSerialization: - """Message.from_dict() and get_domain_data().""" - - def test_get_domain_data_delegates_to_payload(self) -> None: - """Default get_domain_data() returns the same as _payload.""" - event = SimpleEvent() - assert event.get_domain_data() == event.value - - def test_to_dict_includes_data_key(self) -> None: - """to_dict() includes both 'payload' and 'data' keys.""" - event = SimpleEvent() - d = event.to_dict() - assert "payload" in d - assert "data" in d - assert d["payload"] == d["data"] - - def test_from_dict_calls_from_domain_data(self) -> None: - """from_dict() on the base Message class raises NotImplementedError.""" - meta_dict = { - "message_type": "EventWithoutFromPayloadFields", - "message_id": str(uuid7()), - "created_at": datetime.now(timezone.utc).isoformat(), - } - with pytest.raises(NotImplementedError): - EventWithoutFromPayloadFields.from_dict({"metadata": meta_dict, "payload": {}}) - - def test_from_dict_with_custom_from_domain_data(self) -> None: - """A subclass that overrides _from_payload_fields can round-trip.""" - _uuid = uuid7() - _ts = datetime.now(timezone.utc) - meta_dict = { - "message_type": "CustomEvent", - "message_id": str(_uuid), - "created_at": _ts.isoformat(), - "correlation_id": str(uuid7()), - "causation_id": str(uuid7()), - } - restored = CustomEvent.from_dict({"metadata": meta_dict, "payload": {"value": "hello"}}) - assert isinstance(restored, CustomEvent) - assert restored.value["value"] == "hello" - assert restored.metadata.message_id == _uuid - - class TestMessageDataclassDecorator: """@event_dataclass, @command_dataclass, @query_dataclass.""" - def test_event_dataclass_get_domain_data(self) -> None: - """Decorated event returns domain data from its fields.""" + def test_event_dataclass_produces_payload_from_fields(self) -> None: + """Decorated event _payload reflects its fields.""" @event_dataclass class OrderCreated(Event[dict[str, object]]): @@ -179,89 +51,11 @@ def value(self) -> dict[str, object]: return self._payload event = OrderCreated(order_id="O1", customer_id="C1", total=99.95) - data = event.get_domain_data() - assert data == {"order_id": "O1", "customer_id": "C1", "total": 99.95} - - def test_event_dataclass_to_dict_contains_data(self) -> None: - """to_dict() on a decorated event includes both keys.""" - - @event_dataclass - class OrderCreated(Event[dict[str, object]]): - order_id: str - - def __init__(self, order_id: str, metadata: MessageMetadata | None = None) -> None: - super().__init__(metadata) - object.__setattr__(self, "order_id", order_id) - - @property - def _payload(self) -> dict[str, object]: - return {"order_id": self.order_id} - - @property - def value(self) -> dict[str, object]: - return self._payload - - event = OrderCreated(order_id="O1") - d = event.to_dict() - assert "payload" in d - assert "data" in d - assert d["data"] == {"order_id": "O1"} - - def test_event_dataclass_from_dict_round_trip(self) -> None: - """A decorated event round-trips through from_dict().""" - - @event_dataclass - class OrderCreated(Event[dict[str, object]]): - order_id: str - total: float - - def __init__( - self, order_id: str, total: float, metadata: MessageMetadata | None = None - ) -> None: - super().__init__(metadata) - object.__setattr__(self, "order_id", order_id) - object.__setattr__(self, "total", total) - - @property - def _payload(self) -> dict[str, object]: - return {"order_id": self.order_id, "total": self.total} - - @property - def value(self) -> dict[str, object]: - return self._payload - - original = OrderCreated(order_id="O1", total=99.95) - d = original.to_dict() - restored = OrderCreated.from_dict(d) - assert isinstance(restored, OrderCreated) - assert restored.order_id == "O1" - assert restored.total == 99.95 - assert restored.metadata.message_id == original.metadata.message_id - - def test_event_dataclass_from_dict_without_payload(self) -> None: - """from_dict() falls back to 'data' key when 'payload' is absent.""" - - @event_dataclass - class OrderCreated(Event[dict[str, object]]): - order_id: str - - def __init__(self, order_id: str, metadata: MessageMetadata | None = None) -> None: - super().__init__(metadata) - object.__setattr__(self, "order_id", order_id) - - @property - def _payload(self) -> dict[str, object]: - return {"order_id": self.order_id} - - @property - def value(self) -> dict[str, object]: - return self._payload - - original = OrderCreated(order_id="O1") - d = original.to_dict() - del d["payload"] - restored = OrderCreated.from_dict(d) - assert restored.order_id == "O1" + assert event._payload == { + "order_id": "O1", + "customer_id": "C1", + "total": 99.95, + } def test_command_dataclass_alias(self) -> None: """@command_dataclass is an alias for @message_dataclass.""" @@ -287,7 +81,7 @@ def value(self) -> dict[str, object]: return self._payload cmd = CreateOrder(product="Widget", quantity=3) - assert cmd.get_domain_data() == {"product": "Widget", "quantity": 3} + assert cmd._payload == {"product": "Widget", "quantity": 3} def test_query_dataclass_alias(self) -> None: """@query_dataclass is an alias for @message_dataclass.""" @@ -309,18 +103,17 @@ def value(self) -> dict[str, object]: return self._payload q = GetOrder(order_id="O1") - assert q.get_domain_data() == {"order_id": "O1"} + assert q._payload == {"order_id": "O1"} - def test_metadata_excluded_from_domain_data(self) -> None: - """Private and reserved fields are excluded from get_domain_data().""" + def test_metadata_excluded_from_payload(self) -> None: + """Private and reserved fields are excluded from decorator-generated payload.""" @event_dataclass class OrderEvent(Event[dict[str, object]]): order_id: str evt: Event[dict[str, object]] = cast(Any, OrderEvent)(order_id="O1") - data: dict[str, object] = evt.get_domain_data() - assert data == {"order_id": "O1"} + assert evt._payload == {"order_id": "O1"} def test_decorated_instance_is_frozen_after_init(self) -> None: """Assigning to a field after construction raises FrozenInstanceError.""" @@ -330,17 +123,7 @@ class OrderEvent(Event[dict[str, object]]): order_id: str evt: Event[dict[str, object]] = cast(Any, OrderEvent)(order_id="O1") - assert evt.get_domain_data() == {"order_id": "O1"} + assert evt._payload == {"order_id": "O1"} with pytest.raises(dataclasses.FrozenInstanceError): cast(Any, evt).order_id = "mutated" - - -class TestSerializableProtocol: - """Verify structural protocol conformance for message types.""" - - def test_message_metadata_is_serializable(self) -> None: - """MessageMetadata satisfies the Serializable protocol structurally.""" - meta = MessageMetadata(message_type="Test") - assert isinstance(meta.to_dict(), dict) - assert hasattr(MessageMetadata, "from_dict") diff --git a/tests/forging_blocks/infrastructure/serialization/__init__.py b/tests/forging_blocks/infrastructure/serialization/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py b/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py new file mode 100644 index 00000000..3748b417 --- /dev/null +++ b/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py @@ -0,0 +1,140 @@ +"""Unit tests for DictMessageCodec round-trip encoding/decoding.""" + +import pytest + +from forging_blocks.foundation.messages import ( + MessageMetadata, + command_dataclass, + event_dataclass, + query_dataclass, +) +from forging_blocks.foundation.messages.command import Command +from forging_blocks.foundation.messages.event import Event +from forging_blocks.foundation.messages.query import Query +from forging_blocks.infrastructure.serialization import DictMessageCodec + +PAYLOAD_MSG: dict[str, object] = {"_name": "write_in", "_value": 42} + + +@event_dataclass +class SimpleEvent(Event[dict[str, object]]): + name: str + + +@command_dataclass +class SimpleCommand(Command[dict[str, object]]): + action: str + target: str + + +@query_dataclass +class SimpleQuery(Query[dict[str, object]]): + resource: str + filters: dict[str, object] + + +@pytest.mark.unit +class TestDictMessageCodecRoundTrip: + """Round-trip encode/decode for message types.""" + + codec: DictMessageCodec[SimpleEvent] = DictMessageCodec() + generic_codec: DictMessageCodec = DictMessageCodec() + + def test_encode_event_returns_dict_with_metadata_and_payload(self) -> None: + event = SimpleEvent(name="test_event") + + result = self.codec.encode(event) + + assert "metadata" in result + assert "payload" in result + assert result["payload"]["name"] == "test_event" + + def test_decode_event_from_dict_restores_fields(self) -> None: + original = SimpleEvent(name="test_event") + encoded = self.codec.encode(original) + + decoded = self.codec.decode(encoded, SimpleEvent) + + assert decoded.name == original.name + assert decoded.metadata.message_type == original.metadata.message_type + + def test_encode_decode_round_trip_preserves_message_metadata(self) -> None: + """Metadata values survive round-trip.""" + original = SimpleEvent(name="meta_check") + encoded = self.codec.encode(original) + + decoded = self.codec.decode(encoded, SimpleEvent) + + assert decoded.metadata.message_id == original.metadata.message_id + assert decoded.metadata.message_type == original.metadata.message_type + assert decoded.metadata.correlation_id == original.metadata.correlation_id + assert decoded.metadata.causation_id == original.metadata.causation_id + + def test_round_trip_with_compound_payload_fields(self) -> None: + """Message with compound fields (dict) round-trips correctly.""" + original = SimpleQuery(resource="orders", filters={"status": "active"}) + encoded = self.generic_codec.encode(original) + + decoded = self.generic_codec.decode(encoded, SimpleQuery) + + assert decoded.resource == "orders" + assert decoded.filters == {"status": "active"} + + def test_decode_constructs_metadata_from_raw_dict(self) -> None: + """Metadata fields are reconstructed from serialized form.""" + encoded = self.codec.encode(SimpleEvent(name="meta")) + decoded = self.codec.decode(encoded, SimpleEvent) + + assert decoded.metadata is not encoded["metadata"] + assert isinstance(decoded.metadata, MessageMetadata) + + def test_encode_command_round_trip(self) -> None: + original = SimpleCommand(action="ship", target="order-42") + encoded = self.generic_codec.encode(original) + + decoded = self.generic_codec.decode(encoded, SimpleCommand) + + assert decoded.action == "ship" + assert decoded.target == "order-42" + assert isinstance(decoded, Command) + + def test_encode_query_round_trip(self) -> None: + original = SimpleQuery(resource="users", filters={}) + encoded = self.generic_codec.encode(original) + + decoded = self.generic_codec.decode(encoded, SimpleQuery) + + assert decoded.resource == "users" + assert isinstance(decoded, Query) + + +@pytest.mark.unit +class TestDictMessageCodecEdgeCases: + """Edge cases for DictMessageCodec.""" + + codec: DictMessageCodec[SimpleEvent] = DictMessageCodec() + + def test_encode_empty_payload_event(self) -> None: + """Event with no user-defined fields still encodes.""" + + @event_dataclass + class EmptyEvent(Event[dict[str, object]]): + pass + + event = EmptyEvent() + e_codec: DictMessageCodec[EmptyEvent] = DictMessageCodec() + encoded = e_codec.encode(event) + + assert "metadata" in encoded + assert "payload" in encoded + assert encoded["payload"] == {} + + def test_decode_preserves_message_type_in_metadata(self) -> None: + """After round-trip, metadata.message_type matches the original class name.""" + original = SimpleEvent(name="type_check") + e_codec: DictMessageCodec[SimpleEvent] = DictMessageCodec() + encoded = e_codec.encode(original) + + decoded = e_codec.decode(encoded, SimpleEvent) + + assert decoded.metadata.message_type == "SimpleEvent" From d78318893c6e035b8a56fcfa8890e2a38474d6f9 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 00:43:36 -0300 Subject: [PATCH 06/86] docs: update reference docs for message and serialization API changes Update messages.md to reflect abstract from_payload_fields and restructured message module. Update index.md and adapters.md to reference MessageCodec/DictMessageCodec instead of Serializable. --- docs/reference/foundation/messages.md | 6 +++--- docs/reference/index.md | 4 ++-- docs/reference/infrastructure/adapters.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/reference/foundation/messages.md b/docs/reference/foundation/messages.md index ca2750b4..fffd4297 100644 --- a/docs/reference/foundation/messages.md +++ b/docs/reference/foundation/messages.md @@ -2,7 +2,7 @@ Messages are immutable, architecture-neutral data carriers — **Command**, **Event**, and **Query**. -All messages are frozen dataclasses with automatic serialization support (`to_dict`). Their type communicates intent: a Command asks for action, an Event records a fact, a Query requests data. +All messages are frozen dataclasses. Their type communicates intent: a Command asks for action, an Event records a fact, a Query requests data. Serialization is handled by codecs in the infrastructure layer (see :class:`~forging_blocks.infrastructure.serialization.DictMessageCodec`). ## Types @@ -12,7 +12,7 @@ All messages are frozen dataclasses with automatic serialization support (`to_di ## Message dataclass decorator -The `@message_dataclass` decorator creates boilerplate-free, frozen message types with automatic `to_dict()` and `from_dict()` support. Type aliases clarify intent: +The ``@message_dataclass`` decorator creates boilerplate-free, frozen message types. Type aliases clarify intent: - `@command_dataclass` — For commands - `@event_dataclass` — For domain events @@ -22,7 +22,7 @@ All aliases are the same decorator; the name signals intent. Instances are froze ## When to use -Annotate a class with `@command_dataclass`, `@event_dataclass`, or `@query_dataclass`. The decorator handles freezing, `to_dict()`, and `from_dict()`. Choose the alias that matches the message's role — command for intent, event for facts, query for data requests. +Annotate a class with ``@command_dataclass``, ``@event_dataclass``, or ``@query_dataclass``. The decorator handles freezing and wires up reconstruction via ``from_payload_fields``. Choose the alias that matches the message's role — command for intent, event for facts, query for data requests. !!! note "Related" See [Application Use Cases & Handlers](../application/use-cases.md) for how messages are processed. diff --git a/docs/reference/index.md b/docs/reference/index.md index 03c8cbe2..4fd76f8c 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -8,7 +8,7 @@ Components: - **Foundation** — Core abstractions (Result, Port, Identified, Messages, Specification) - **Domain** — Domain modeling (Entity, ValueObject, AggregateRoot, Specification) - **Application** — Application layer patterns (ApplicationServicePort, CommandHandlerPort, RepositoryPort, EventStorePort) -- **Infrastructure** — Technical adapters and implementations (Repositories, Serializable) +- **Infrastructure** — Technical adapters and implementations (Repositories, MessageCodec/DictMessageCodec) - **Presentation** — Input/output boundaries Dependencies point inward: Infrastructure/Presentation → Application → Domain, all depending on Foundation. @@ -31,5 +31,5 @@ flowchart LR - **[API Stability](api-stability.md)** - SemVer policy and public API stability guarantees - **[Domain](domain.md)** - Domain modeling abstractions (Entity, ValueObject, AggregateRoot, Specification) - **[Application](application.md)** - Application layer patterns (ApplicationServicePort, CommandHandlerPort, RepositoryPort, EventStorePort, SpecificationRepositoryPort) -- **[Infrastructure](infrastructure.md)** - Infrastructure adapters and implementations (Repositories, Serializable) +- **[Infrastructure](infrastructure.md)** - Infrastructure adapters and implementations (Repositories, MessageCodec/DictMessageCodec) - **[Presentation](presentation.md)** - Input/output boundaries and presentation layer diff --git a/docs/reference/infrastructure/adapters.md b/docs/reference/infrastructure/adapters.md index c72f41fe..188cd331 100644 --- a/docs/reference/infrastructure/adapters.md +++ b/docs/reference/infrastructure/adapters.md @@ -14,7 +14,7 @@ A dictionary-backed key-value cache implementing `CachePort`. Supports `get`, `s ## Serialization -`Serializable` is structural — any class with `to_dict()` / `from_dict()` methods satisfies the contract, enabling generic serialization infrastructure. +`MessageCodec` is an abstract codec base that defines `encode` / `decode` for bidirectional message serialization. `DictMessageCodec` is the concrete ``dict[str, object]`` implementation that ships with Forging Blocks. ## When to use From 47e8c9197c74c4e91e53a74020ffba3d8e03eb1f Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 01:43:07 -0300 Subject: [PATCH 07/86] docs: document ABC interaction in message_dataclass decorator Explicitly describe how the decorator patches abstract methods (_payload, value, from_payload_fields) inherited from Event, Command, and Query bases, removing them from __abstractmethods__ so decorated subclasses are instantiable without manual stubs. --- src/forging_blocks/foundation/messages/decorators.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/forging_blocks/foundation/messages/decorators.py b/src/forging_blocks/foundation/messages/decorators.py index 3f7468fb..a52f762b 100644 --- a/src/forging_blocks/foundation/messages/decorators.py +++ b/src/forging_blocks/foundation/messages/decorators.py @@ -74,6 +74,12 @@ def message_dataclass( ``get_payload_fields`` and ``from_payload_fields`` onto the class so that payload data is automatically derived from its fields. + **ABC interaction:** When the decorated class inherits from an abstract + base (e.g. :class:`Event`, :class:`Command`, :class:`Query`), the + decorator automatically patches ``_payload``, ``value``, and + ``from_payload_fields`` — and removes them from ``__abstractmethods__`` + — so the concrete subclass is instantiable without manual stubs. + Args: cls: The class to decorate (when used without arguments). frozen: Whether the dataclass should be frozen (default ``True``). From c15d682541001021cf9463fe14f3d64d3716e988 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 02:01:07 -0300 Subject: [PATCH 08/86] style(docs): use plain prose for ABC interaction paragraph in message_dataclass Replace markdown bold **ABC interaction:** header with plain prose paragraph, matching the project docstring style shown in value_object.py. --- src/forging_blocks/foundation/messages/decorators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/forging_blocks/foundation/messages/decorators.py b/src/forging_blocks/foundation/messages/decorators.py index a52f762b..6471bd49 100644 --- a/src/forging_blocks/foundation/messages/decorators.py +++ b/src/forging_blocks/foundation/messages/decorators.py @@ -74,9 +74,9 @@ def message_dataclass( ``get_payload_fields`` and ``from_payload_fields`` onto the class so that payload data is automatically derived from its fields. - **ABC interaction:** When the decorated class inherits from an abstract - base (e.g. :class:`Event`, :class:`Command`, :class:`Query`), the - decorator automatically patches ``_payload``, ``value``, and + When the decorated class inherits from an abstract base (e.g. + :class:`Event`, :class:`Command`, :class:`Query`), the decorator + automatically patches ``_payload``, ``value``, and ``from_payload_fields`` — and removes them from ``__abstractmethods__`` — so the concrete subclass is instantiable without manual stubs. From 972a2029425a6c4fbb5e9dcba0514a42812ffcdb Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 02:46:21 -0300 Subject: [PATCH 09/86] fix(infrastructure): tighten DictMessageCodec type constraint and fix created_at fallback - Constrain DictMessageCodec generic from Message[Any] to Message[dict[str, object]] to match the actual cast on message.value - Remove .get() fallback to datetime.min for created_at metadata field; use direct key access consistent with message_id, causation_id, and correlation_id - Fix _PatchedMessage.from_payload_fields return annotation from type[Self] to Self (classmethod returns an instance, not a type) --- src/forging_blocks/foundation/messages/decorators.py | 2 +- .../infrastructure/serialization/_dict_message_codec.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/forging_blocks/foundation/messages/decorators.py b/src/forging_blocks/foundation/messages/decorators.py index 6471bd49..f8b638f7 100644 --- a/src/forging_blocks/foundation/messages/decorators.py +++ b/src/forging_blocks/foundation/messages/decorators.py @@ -48,7 +48,7 @@ def from_payload_fields( cls, data: dict[str, object], metadata: MessageMetadata, - ) -> type[Self]: ... + ) -> Self: ... @overload diff --git a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py index c87a2853..1ca81eb7 100644 --- a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py +++ b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py @@ -1,7 +1,7 @@ """Dict-based message codec that serializes messages to ``dict[str, object]``.""" from datetime import datetime -from typing import Any, cast +from typing import cast from uuid import UUID from forging_blocks.foundation.messages import Message, MessageMetadata @@ -9,7 +9,7 @@ from ._message_codec import MessageCodec -class DictMessageCodec[M: Message[Any]](MessageCodec[M, dict[str, object]]): +class DictMessageCodec[M: Message[dict[str, object]]](MessageCodec[M, dict[str, object]]): """Codec that serializes messages to ``dict[str, object]``. Uses the message's own ``value`` property for the payload and @@ -39,9 +39,7 @@ def _from_data(self, data: dict[str, object], message_type: type[M]) -> M: metadata = MessageMetadata( message_type=str(raw_metadata.get("message_type", message_type.__name__)), message_id=UUID(str(raw_metadata["message_id"])), - created_at=datetime.fromisoformat( - str(raw_metadata.get("created_at", datetime.min.isoformat())), - ), + created_at=datetime.fromisoformat(str(raw_metadata["created_at"])), causation_id=UUID(str(raw_metadata["causation_id"])), correlation_id=UUID(str(raw_metadata["correlation_id"])), ) From 661a9f65bf320b3bc12179167fdb8716c7ab1cc9 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 02:46:27 -0300 Subject: [PATCH 10/86] docs(foundation): fix MessageMetadata class docstring and _payload docs - Deduplicate correlation_id bullets in MessageMetadata docstring, merge into single item covering tracing and process linking - Complete truncated causation_id sentence: 'that caused this message' - Fix grammar: 'without needing to understand' instead of 'without foundation understand anything about' - Update Event._payload and Query._payload docstrings from stale 'as a dictionary' to generic 'event/query-specific payload data' --- src/forging_blocks/foundation/messages/event.py | 2 +- .../foundation/messages/message/_metadata.py | 7 ++++--- src/forging_blocks/foundation/messages/query.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/forging_blocks/foundation/messages/event.py b/src/forging_blocks/foundation/messages/event.py index f2c5b8da..2ed31ee5 100644 --- a/src/forging_blocks/foundation/messages/event.py +++ b/src/forging_blocks/foundation/messages/event.py @@ -44,7 +44,7 @@ def occurred_at(self) -> datetime: @property @abstractmethod def _payload(self) -> RawEventType: - """Retrieve the event's payload as a dictionary. + """Return the event-specific payload data. Subclasses MUST implement this property to return the event-specific data. diff --git a/src/forging_blocks/foundation/messages/message/_metadata.py b/src/forging_blocks/foundation/messages/message/_metadata.py index 00fec3be..3d31af8e 100644 --- a/src/forging_blocks/foundation/messages/message/_metadata.py +++ b/src/forging_blocks/foundation/messages/message/_metadata.py @@ -13,12 +13,13 @@ class MessageMetadata(ValueObject[dict[str, object]]): Contains technical-level information about messages such as: - Unique message identifier - When the message was created - - correlation_id is used to trace related messages across systems. - - correlation_id is used to link messages that belong to the same business process. + - correlation_id is used to trace related messages across systems + and link messages that belong to the same business process. - causation_id is used to identify the immediate predecessor message that caused + this message. This separation allows messages to focus on foundational data while keeping - infrastructure handling concerns in metadata without foundation understand anything about + infrastructure handling concerns in metadata without needing to understand anything about infrastructure rules. Example: diff --git a/src/forging_blocks/foundation/messages/query.py b/src/forging_blocks/foundation/messages/query.py index f26e2389..299c84d6 100644 --- a/src/forging_blocks/foundation/messages/query.py +++ b/src/forging_blocks/foundation/messages/query.py @@ -31,7 +31,7 @@ def _payload(self) -> dict[str, object]: @property @abstractmethod def _payload(self) -> QueryPayloadType: - """Retrieve the query's payload as a dictionary. + """Return the query-specific payload data. Subclasses MUST implement this property to return the query-specific data. From 8cc3196cc344c676840b259afe3369b572e303b6 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 02:48:19 -0300 Subject: [PATCH 11/86] fix(infrastructure): remove unnecessary cast after type constraint tightening The Message[Any] -> Message[dict[str, object]] change made message.value already dict[str, object], so the cast became redundant. --- .../infrastructure/serialization/_dict_message_codec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py index 1ca81eb7..a883c929 100644 --- a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py +++ b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py @@ -25,7 +25,7 @@ def _to_data(self, message: M) -> dict[str, object]: """ return { "metadata": message.metadata.value, - "payload": cast(dict[str, object], message.value), + "payload": message.value, } def _from_data(self, data: dict[str, object], message_type: type[M]) -> M: From cc745cf97b1f7188d11d1ab808a5769e9e4cd638 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 02:54:01 -0300 Subject: [PATCH 12/86] refactor(infrastructure): promote encode/decode to public abstract methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the internal _to_data/_from_data indirection in MessageCodec. encode and decode are now @abstractmethod directly — subclasses implement them instead of the private helpers. DictMessageCodec renamed its methods accordingly. Removed unused runtime_final import. --- .../serialization/_dict_message_codec.py | 14 +++------- .../serialization/_message_codec.py | 28 +++---------------- 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py index a883c929..e47d0f81 100644 --- a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py +++ b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py @@ -18,21 +18,15 @@ class DictMessageCodec[M: Message[dict[str, object]]](MessageCodec[M, dict[str, concrete ``Message`` subclass provides. """ - def _to_data(self, message: M) -> dict[str, object]: - """Serialize *message* to a dictionary. - - Returns a ``dict`` with ``metadata`` and ``payload`` keys. - """ + def encode(self, message: M) -> dict[str, object]: + """Encode *message* to a dictionary with ``metadata`` and ``payload`` keys.""" return { "metadata": message.metadata.value, "payload": message.value, } - def _from_data(self, data: dict[str, object], message_type: type[M]) -> M: - """Reconstruct a message of *message_type* from *data*. - - Expects a ``dict`` with ``metadata`` and ``payload`` keys. - """ + def decode(self, data: dict[str, object], message_type: type[M]) -> M: + """Decode *data* back into a message of *message_type*.""" raw_metadata = cast(dict[str, object], data["metadata"]) payload = cast(dict[str, object], data.get("payload", {})) diff --git a/src/forging_blocks/infrastructure/serialization/_message_codec.py b/src/forging_blocks/infrastructure/serialization/_message_codec.py index 8788a535..d6251d06 100644 --- a/src/forging_blocks/infrastructure/serialization/_message_codec.py +++ b/src/forging_blocks/infrastructure/serialization/_message_codec.py @@ -2,7 +2,7 @@ from abc import abstractmethod -from forging_blocks.foundation import FinalABCMeta, runtime_final +from forging_blocks.foundation import FinalABCMeta class MessageCodec[M, Raw](metaclass=FinalABCMeta): @@ -18,28 +18,10 @@ class MessageCodec[M, Raw](metaclass=FinalABCMeta): ``bytes``, ``str``). """ - @runtime_final + @abstractmethod def encode(self, message: M) -> Raw: """Encode *message* to its raw representation. - Delegates to :meth:`_to_data` which subclasses must implement. - """ - return self._to_data(message) - - @runtime_final - def decode(self, data: Raw, message_type: type[M]) -> M: - """Decode *data* back into a message of *message_type*. - - Delegates to :meth:`_from_data` which subclasses must implement. - """ - return self._from_data(data, message_type) - - @abstractmethod - def _to_data(self, message: M) -> Raw: - """Convert *message* to its raw representation. - - Subclasses implement the serialization logic. - Args: message: The message instance to encode. @@ -49,10 +31,8 @@ def _to_data(self, message: M) -> Raw: ... @abstractmethod - def _from_data(self, data: Raw, message_type: type[M]) -> M: - """Reconstruct a message from its raw representation. - - Subclasses implement the deserialization logic. + def decode(self, data: Raw, message_type: type[M]) -> M: + """Decode *data* back into a message of *message_type*. Args: data: The raw data to decode. From daa341116cf2f7e7b33524c89bd0a643bf6759d3 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 03:06:01 -0300 Subject: [PATCH 13/86] refactor(infrastructure): use ABC base class instead of FinalABCMeta metaclass Since encode/decode are now the public abstract methods and no @runtime_final methods remain, FinalABCMeta adds no value. Using ABC directly is the idiomatic pattern for non-port ABCs. --- .../infrastructure/serialization/_message_codec.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/forging_blocks/infrastructure/serialization/_message_codec.py b/src/forging_blocks/infrastructure/serialization/_message_codec.py index d6251d06..10ca79e3 100644 --- a/src/forging_blocks/infrastructure/serialization/_message_codec.py +++ b/src/forging_blocks/infrastructure/serialization/_message_codec.py @@ -1,11 +1,9 @@ """Message codec ABC for encoding/decoding foundation messages.""" -from abc import abstractmethod +from abc import ABC, abstractmethod -from forging_blocks.foundation import FinalABCMeta - -class MessageCodec[M, Raw](metaclass=FinalABCMeta): +class MessageCodec[M, Raw](ABC): """Abstract codec for encoding and decoding messages. A *codec* is a bidirectional transformation between a message From b119b3765a993ea213c4c7ffcfcc7f87653e7d9d Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 03:26:58 -0300 Subject: [PATCH 14/86] fix(infrastructure): cache reconstituted aggregates after event replay AggregateRepository.get_by_id() now saves the reconstituted aggregate to the in-memory store so subsequent reads don't replay the full event stream from the event store. --- .../infrastructure/repositories/aggregate_repository.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py index b26b4297..6b0a9fba 100644 --- a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py +++ b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py @@ -95,6 +95,10 @@ async def save(self, aggregate: TAggregateRoot) -> None: async def get_by_id(self, id: TId) -> TAggregateRoot | None: """Retrieve an aggregate by ID and replay its events. + Checks the in-memory cache first; if not cached, replays the + aggregate from the event store and caches the result so subsequent + reads avoid a full replay. + Args: id: Unique identifier of the aggregate. @@ -121,5 +125,6 @@ async def get_by_id(self, id: TId) -> TAggregateRoot | None: return None aggregate = self._aggregate_type.reconstitute(id, events) + await super().save(aggregate) return aggregate From bd3536770bf14f955c9e8da20452b854a6709186 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 04:03:00 -0300 Subject: [PATCH 15/86] fix(foundation): remove duplicate Args block and dead frozen param from docstring The message_dataclass docstring had a stale duplicate `Args:` section left over from a prior edit, including a `frozen:` parameter entry that no longer exists (the decorator always uses `frozen=False` internally). Cleaned up to the single correct `Args:` and `Returns:` section. --- .../foundation/messages/decorators.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/forging_blocks/foundation/messages/decorators.py b/src/forging_blocks/foundation/messages/decorators.py index f8b638f7..b80ee599 100644 --- a/src/forging_blocks/foundation/messages/decorators.py +++ b/src/forging_blocks/foundation/messages/decorators.py @@ -58,21 +58,19 @@ def message_dataclass(cls: type[_M]) -> type[_M]: ... @overload def message_dataclass( cls: None = None, - *, - frozen: bool = True, ) -> Callable[[type[_M]], type[_M]]: ... def message_dataclass( cls: type[_M] | None = None, - *, - frozen: bool = True, ) -> type[_M] | Callable[[type[_M]], type[_M]]: """Decorate a class as a message dataclass. - The decorator applies ``@dataclass(frozen=frozen)`` and then patches - ``get_payload_fields`` and ``from_payload_fields`` onto the class so - that payload data is automatically derived from its fields. + The decorator applies ``@dataclass(frozen=False)`` and then replaces + ``__setattr__`` with a custom implementation that raises + ``FrozenInstanceError`` after ``__init__`` completes. It also + patches ``get_payload_fields`` and ``from_payload_fields`` onto the + class so that payload data is automatically derived from its fields. When the decorated class inherits from an abstract base (e.g. :class:`Event`, :class:`Command`, :class:`Query`), the decorator @@ -82,15 +80,13 @@ def message_dataclass( Args: cls: The class to decorate (when used without arguments). - frozen: Whether the dataclass should be frozen (default ``True``). Returns: The decorated class (or a wrapper when called with keyword arguments). - """ def wrap(cls: type[_M]) -> type[_M]: - dc_cls: type[_M] = dataclass(frozen=False)(cls) + dc_cls: type[_M] = dataclass(frozen=False, eq=False)(cls) original_init = dc_cls.__init__ From 13194dfad4d81b3aecc8f482ccf941048dd58b1d Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 04:03:06 -0300 Subject: [PATCH 16/86] test(foundation): add regression tests for message equality and hashing Verifies that `Message.__eq__`/`__hash__` (based on `message_id`) are preserved after the `message_dataclass` decorator switched to `eq=False` (to prevent dataclass field comparison from overriding the identity-based semantics). --- .../foundation/messages/test_decorators.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/forging_blocks/foundation/messages/test_decorators.py b/tests/forging_blocks/foundation/messages/test_decorators.py index 3e98e05c..d0cf796c 100644 --- a/tests/forging_blocks/foundation/messages/test_decorators.py +++ b/tests/forging_blocks/foundation/messages/test_decorators.py @@ -33,3 +33,51 @@ def _selective_isinstance(obj: object, cls: type) -> bool: ): with pytest.raises(TypeError, match="does not satisfy _PatchedMessage"): message_dataclass(NotAMessage) # type: ignore[reportArgumentType] + + def test_decorated_messages_preserve_message_equality_by_id(self) -> None: + """Two decorated messages with the same message_id are equal, regardless of field data.""" + import uuid + + from forging_blocks.foundation.messages.decorators import event_dataclass + from forging_blocks.foundation.messages.event import Event + from forging_blocks.foundation.messages.message import MessageMetadata + + shared_id = uuid.uuid4() + + @event_dataclass + class Shipped(Event[dict[str, object]]): + tracking_code: str + + # Same message_id, different field data — must be equal + a = Shipped( + tracking_code="TRK-001", + metadata=MessageMetadata( + message_type="Shipped", + message_id=shared_id, + ), + ) + b = Shipped( + tracking_code="TRK-002", + metadata=MessageMetadata( + message_type="Shipped", + message_id=shared_id, + ), + ) + + assert a.tracking_code != b.tracking_code, "precondition: field data differs" + assert a == b, "messages with same message_id should be equal" + assert hash(a) == hash(b), "messages with same message_id should have equal hashes" + + def test_decorated_messages_differ_by_message_id(self) -> None: + """Two decorated messages with different message_ids are not equal, even if field data matches.""" + from forging_blocks.foundation.messages.decorators import event_dataclass + from forging_blocks.foundation.messages.event import Event + + @event_dataclass + class Shipped(Event[dict[str, object]]): + tracking_code: str + + a = Shipped(tracking_code="TRK-001") + b = Shipped(tracking_code="TRK-001") + + assert a != b, "messages with different message_ids should not be equal" From 74ac212472f8bd3ea21047ecf085daa8fc70d4f3 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 04:03:21 -0300 Subject: [PATCH 17/86] docs(foundation): clarify that MessageMetadata.created_at preserves input as-is The previous docstring implied UTC was always applied; in reality the timestamp is only defaulted to UTC when not provided by the caller. --- src/forging_blocks/foundation/messages/message/_metadata.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/forging_blocks/foundation/messages/message/_metadata.py b/src/forging_blocks/foundation/messages/message/_metadata.py index 3d31af8e..a4de7fb0 100644 --- a/src/forging_blocks/foundation/messages/message/_metadata.py +++ b/src/forging_blocks/foundation/messages/message/_metadata.py @@ -97,7 +97,8 @@ def created_at(self) -> datetime: """Get the timestamp when this message was created. Returns: - When the message was created (UTC timezone). + The timestamp (preserved as given when provided, or the + current UTC time when defaulted). """ return self._created_at From 74cb3159ca8f16ebacf6b23e47d687aaf7e381fb Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 04:27:26 -0300 Subject: [PATCH 18/86] fix(infrastructure): remove collect_events call from AggregateRepository.save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collect_events() call in save() drained uncommitted events before the UnitOfWork could publish them. Events were persisted to the event store but never dispatched to the message bus — a silent event-loss bug. Also fix aggregate_id annotation from UUID | None to TId | None for consistency with the generic parameter. --- .../infrastructure/repositories/aggregate_repository.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py index 6b0a9fba..f73afe29 100644 --- a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py +++ b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py @@ -81,7 +81,7 @@ async def save(self, aggregate: TAggregateRoot) -> None: """ events = cast(list[Event[EventPayloadType]], aggregate.uncommitted_changes) - aggregate_id: UUID | None = aggregate.id + aggregate_id: TId | None = aggregate.id if events and aggregate_id is not None: version = aggregate.version.value - len(events) result = await self._event_store.append_events( @@ -89,7 +89,6 @@ async def save(self, aggregate: TAggregateRoot) -> None: ) if not result.is_ok: raise result.error - aggregate.collect_events() await super().save(aggregate) async def get_by_id(self, id: TId) -> TAggregateRoot | None: From af4be28911960cbe259ef72c0546379fe5227338 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 04:27:32 -0300 Subject: [PATCH 19/86] fix(infrastructure): raise descriptive errors for missing metadata keys in DictMessageCodec Replace bare KeyError from raw_metadata[key] with explicit ValueError messages via a _get_required helper. Makes the decode path fail with actionable diagnostics instead of obscure KeyError tracebacks. --- .../serialization/_dict_message_codec.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py index e47d0f81..d29ae4e8 100644 --- a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py +++ b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py @@ -9,6 +9,14 @@ from ._message_codec import MessageCodec +def _get_required(data: dict[str, object], key: str) -> object: + """Return ``data[key]`` or raise ``ValueError`` with a descriptive message.""" + try: + return data[key] + except KeyError: + raise ValueError(f"Missing required key {key!r} in message metadata") from None + + class DictMessageCodec[M: Message[dict[str, object]]](MessageCodec[M, dict[str, object]]): """Codec that serializes messages to ``dict[str, object]``. @@ -32,10 +40,18 @@ def decode(self, data: dict[str, object], message_type: type[M]) -> M: metadata = MessageMetadata( message_type=str(raw_metadata.get("message_type", message_type.__name__)), - message_id=UUID(str(raw_metadata["message_id"])), - created_at=datetime.fromisoformat(str(raw_metadata["created_at"])), - causation_id=UUID(str(raw_metadata["causation_id"])), - correlation_id=UUID(str(raw_metadata["correlation_id"])), + message_id=UUID( + str(_get_required(raw_metadata, "message_id")), + ), + created_at=datetime.fromisoformat( + str(_get_required(raw_metadata, "created_at")), + ), + causation_id=UUID( + str(_get_required(raw_metadata, "causation_id")), + ), + correlation_id=UUID( + str(_get_required(raw_metadata, "correlation_id")), + ), ) return message_type.from_payload_fields(payload, metadata) From 43e58825cee0944e48e3558d482add76f20fc0a6 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 04:27:37 -0300 Subject: [PATCH 20/86] fix(foundation): guard against unknown fields in from_payload_fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate that all keys in the data dict correspond to known dataclass fields before splatting. Raises TypeError with the offending field names instead of silently ignoring or crashing on unexpected data — critical for event sourcing where every field must be accounted for. --- src/forging_blocks/foundation/messages/decorators.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/forging_blocks/foundation/messages/decorators.py b/src/forging_blocks/foundation/messages/decorators.py index b80ee599..37a3b046 100644 --- a/src/forging_blocks/foundation/messages/decorators.py +++ b/src/forging_blocks/foundation/messages/decorators.py @@ -125,6 +125,12 @@ def from_payload_fields( data: dict[str, object], metadata: MessageMetadata, ) -> _M: + fields = cast(Any, dc_cls).__dataclass_fields__ + unknown = data.keys() - fields.keys() + if unknown: + raise TypeError( + f"Unknown field(s) in payload for {cls.__name__}: {sorted(unknown)}" + ) return cls(metadata=metadata, **data) # Pyright cannot verify attribute assignment on a closed generic class. From 70b6f3cc1e6415af50535b40ed719635afb55dbd Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 04:27:42 -0300 Subject: [PATCH 21/86] fix(foundation): replace misleading or-pattern with explicit None checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'or' operator in treats any falsy UUID (zero-int, empty) as missing. Replace with explicit checks so only actual None values trigger the default — UUIDs are identity types and zero-int is a valid value. --- .../foundation/messages/message/_metadata.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/forging_blocks/foundation/messages/message/_metadata.py b/src/forging_blocks/foundation/messages/message/_metadata.py index a4de7fb0..1ff4f6d0 100644 --- a/src/forging_blocks/foundation/messages/message/_metadata.py +++ b/src/forging_blocks/foundation/messages/message/_metadata.py @@ -67,10 +67,10 @@ def __init__( """ super().__init__() self._message_type = message_type - self._message_id = message_id or uuid7() - self._created_at = created_at or datetime.now(timezone.utc) - self._correlation_id = correlation_id or uuid7() - self._causation_id = causation_id or uuid7() + self._message_id = message_id if message_id is not None else uuid7() + self._created_at = created_at if created_at is not None else datetime.now(timezone.utc) + self._correlation_id = correlation_id if correlation_id is not None else uuid7() + self._causation_id = causation_id if causation_id is not None else uuid7() @property def message_id(self) -> UUID: From d30026f4d03a6a5b33eb887589332675f396c679 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 04:27:48 -0300 Subject: [PATCH 22/86] docs(foundation): include Query subtype in Message base class docstring --- src/forging_blocks/foundation/messages/message/_message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/forging_blocks/foundation/messages/message/_message.py b/src/forging_blocks/foundation/messages/message/_message.py index a43099e0..5697689d 100644 --- a/src/forging_blocks/foundation/messages/message/_message.py +++ b/src/forging_blocks/foundation/messages/message/_message.py @@ -15,8 +15,8 @@ class Message[MessageRawType](ValueObject[MessageRawType], ABC): """Base class for all foundation messages. Messages are immutable value objects that represent intent or facts in the application. - This is the base class for Events (something that happened) and Commands - (something to do). + This is the base class for Commands (something to do), Events (something that happened), + and Queries (something to obtain). Features: - Immutable by design (inherits from ValueObject) From 9182b04ef217133eb36d18ea863eb39c92ebb474 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 05:10:56 -0300 Subject: [PATCH 23/86] fix(autohash): add missing return statement, close docstring, replace em-dashes Three pre-existing bugs in auto_hash.py: - _AutoHashDecorator.__call__ missing return class_ (decorator returned None) - _AutoHashDecorator class docstring missing closing triple-quote (SyntaxError) - Replaced Unicode em-dashes with ASCII '--' throughout --- .../foundation/autohash/auto_hash.py | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/forging_blocks/foundation/autohash/auto_hash.py b/src/forging_blocks/foundation/autohash/auto_hash.py index a5b226b4..69fb566f 100644 --- a/src/forging_blocks/foundation/autohash/auto_hash.py +++ b/src/forging_blocks/foundation/autohash/auto_hash.py @@ -1,12 +1,12 @@ -"""Auto-hash decorator for generating ``__hash__`` on dataclass instances. +"""Auto-hash decorator for generating ``__hash__`` and ``__eq__`` on class instances. -Provides the :func:`auto_hash` decorator that generates a ``__hash__`` method -based on dataclass fields and automatically composes :func:`auto_freeze` to -enforce immutability. A hashable object must be immutable — ``@auto_hash`` -ensures both by applying ``@auto_freeze`` internally, which forbids attribute -assignment after ``__init__`` completes. +Provides the :func:`auto_hash` decorator that generates ``__hash__`` and +``__eq__`` methods based on class fields and automatically composes +:func:`auto_freeze` to enforce immutability. A hashable object must be +immutable -- ``@auto_hash`` ensures both by applying ``@auto_freeze`` +internally, which forbids attribute assignment after ``__init__`` completes. Works with ``@dataclass`` (``@auto_hash`` must be the outermost decorator -— apply it *above* ``@dataclass``) and on plain classes with ``__slots__`` +-- apply it *above* ``@dataclass``) and on plain classes with ``__slots__`` or ``__annotations__``:: @auto_hash @@ -18,7 +18,7 @@ class MyType: Can be used as ``@auto_hash``, ``@auto_hash()``, or ``@auto_hash(fields=[...])`` to hash only specific attributes. -Explicit ``@auto_freeze`` is NOT required — ``@auto_hash`` applies it +Explicit ``@auto_freeze`` is NOT required -- ``@auto_hash`` applies it automatically. Applying both is harmless (idempotent). Example: @@ -56,9 +56,10 @@ class Money: class _AutoHashDecorator: """Callable class that applies auto-hash behaviour to a target class. - Generates a ``__hash__`` method based on the class's fields. The hash - is computed by converting each field value to a hashable form and - then hashing the resulting tuple. + Generates ``__hash__`` and ``__eq__`` methods based on the class's fields. + The hash is computed by converting each field value to a hashable form and + then hashing the resulting tuple. Equality compares the same fields. + """ def __init__(self, *, fields: Sequence[str] | None = None) -> None: @@ -79,7 +80,7 @@ def __call__[T](self, class_: type[T]) -> type[T]: class_: The target class to decorate. Returns: - The decorated class with ``__hash__`` generated. + The decorated class with ``__hash__`` and ``__eq__`` generated. """ field_names = self._resolve_field_names(class_) @@ -92,12 +93,22 @@ def _hash_impl(self: Any) -> int: _hash_impl.__name__ = "__hash__" _hash_impl.__qualname__ = f"{class_.__name__}.__hash__" + def _eq_impl(self: Any, other: object) -> bool: + if type(self) is not type(other): + return False + return all(getattr(self, f) == getattr(other, f) for f in _field_names) + + _eq_impl.__name__ = "__eq__" + _eq_impl.__qualname__ = f"{class_.__name__}.__eq__" + class_.__hash__ = _hash_impl + class_.__eq__ = _eq_impl + class_.__auto_hash_fields__ = _field_names _auto_freeze(class_) return class_ def _resolve_field_names(self, class_: type[object]) -> list[str]: - """Determine which field names contribute to ``__hash__``. + """Determine which field names contribute to ``__hash__`` and ``__eq__``. Priority: 1. Explicit *fields* argument passed to the decorator. @@ -182,7 +193,7 @@ def auto_hash[T]( """Generate ``__hash__`` for a class based on its fields. Automatically applies :func:`auto_freeze` to enforce immutability. - Hashable objects MUST be immutable — ``@auto_hash`` ensures both by + Hashable objects MUST be immutable -- ``@auto_hash`` ensures both by applying ``@auto_freeze`` internally, which forbids attribute assignment after ``__init__`` completes. When the class is a ``@dataclass``, *fields* defaults to all declared @@ -193,10 +204,9 @@ def auto_hash[T]( class_: The target class (when used bare). fields: Specific attribute names to include in the hash. When ``None`` (the default), all dataclass fields are used. - Returns: The decorated class (or a decorator when used parameterised), - with both ``__hash__`` and immutability applied. + with ``__hash__``, ``__eq__``, and immutability applied. Raises: TypeError: If no field names can be determined and *fields* is From 9ddd6130cbe1ad78951284f498460acd4b333488 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 05:11:11 -0300 Subject: [PATCH 24/86] refactor(value-object): replace _equality_components with @auto_hash ValueObject now delegates structural equality and hashing to @auto_hash instead of requiring subclasses to implement _equality_components. Changes: - Replace @auto_freeze with @auto_hash via __init_subclass__ - Remove __eq__, __hash__, and _equality_components abstract property - Back __str__ and __repr__ with __auto_hash_fields__ (field names -> getattr) - Drop now-unused Hashable import --- src/forging_blocks/foundation/value_object.py | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/forging_blocks/foundation/value_object.py b/src/forging_blocks/foundation/value_object.py index 93ec77e8..fed19c84 100644 --- a/src/forging_blocks/foundation/value_object.py +++ b/src/forging_blocks/foundation/value_object.py @@ -6,13 +6,11 @@ import inspect from abc import ABC, abstractmethod -from collections.abc import Hashable from typing import Any -from forging_blocks.foundation.autofreeze import auto_freeze +from forging_blocks.foundation.autohash import auto_hash -@auto_freeze class ValueObject[RawValueType](ABC): """Base class for all domain value objects. @@ -20,9 +18,11 @@ class ValueObject[RawValueType](ABC): rather than by an identity. Two value objects with the same attributes are considered equal. - Concrete subclasses are automatically frozen after ``__init__`` completes. - Intermediate abstract classes remain unfrozen so their concrete leaf - subclasses can finish setting up via ``super().__init__()``. + Concrete subclasses are automatically frozen and hashable via + :func:`auto_hash` which generates ``__hash__`` / ``__eq__`` from slot + fields and applies ``@auto_freeze`` for immutability. Intermediate + abstract classes are skipped so leaf subclasses finish their own + ``__init__`` without restriction. Example: ```python @@ -38,31 +38,23 @@ def __init__(self, value: str) -> None: @property def value(self) -> str: return self._value - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return (self._value,) ``` """ def __init_subclass__(cls, **kwargs: Any) -> None: - """Automatically apply auto_freeze to concrete subclasses.""" + """Automatically apply ``auto_hash`` to concrete subclasses. + + ``auto_hash`` generates ``__hash__`` / ``__eq__`` from class fields + and also applies ``@auto_freeze`` to enforce immutability. + """ super().__init_subclass__(**kwargs) if not inspect.isabstract(cls): - auto_freeze(cls) - - def __eq__(self, other: object) -> bool: - if type(self) is not type(other): - return False - other_vo: ValueObject[RawValueType] = other # type: ignore[assignment] - return self._equality_components == other_vo._equality_components - - def __hash__(self) -> int: - return hash(self._equality_components) + auto_hash(cls) def __str__(self) -> str: - components = self._equality_components + field_names = getattr(self, "__auto_hash_fields__", ()) + components = tuple(getattr(self, name) for name in field_names) if len(components) == 1: return f"{self.__class__.__name__}({components[0]!r})" return f"{self.__class__.__name__}{components!r}" @@ -74,8 +66,3 @@ def __repr__(self) -> str: @abstractmethod def value(self) -> RawValueType: """Return the primary raw value encapsulated by the ValueObject.""" - - @property - @abstractmethod - def _equality_components(self) -> tuple[Hashable, ...]: - """Return the components used for equality and hashing.""" From d718bab140d4a7b0ef9ed3f2c63150a458020b64 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 05:11:17 -0300 Subject: [PATCH 25/86] refactor(message): break Message from ValueObject inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message no longer inherits from ValueObject. Instead: - _message.py: Inherit from ABC only, apply @auto_freeze via __init_subclass__ - Identity-based equality on message_id (not structural) - Define abstract value property (keeps decorator patching working) - _metadata.py: Remove _equality_components — @auto_hash handles all 5 fields --- .../foundation/messages/message/_message.py | 57 ++++++++++--------- .../foundation/messages/message/_metadata.py | 11 ---- 2 files changed, 30 insertions(+), 38 deletions(-) diff --git a/src/forging_blocks/foundation/messages/message/_message.py b/src/forging_blocks/foundation/messages/message/_message.py index 5697689d..6f2dd7e3 100644 --- a/src/forging_blocks/foundation/messages/message/_message.py +++ b/src/forging_blocks/foundation/messages/message/_message.py @@ -1,32 +1,37 @@ """Base Message class for messaging patterns.""" +import inspect from abc import ABC, abstractmethod -from collections.abc import Hashable from datetime import datetime -from typing import Self +from typing import Any, Self from uuid import UUID -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.foundation.autofreeze import auto_freeze from ._metadata import MessageMetadata -class Message[MessageRawType](ValueObject[MessageRawType], ABC): +class Message[MessageRawType](ABC): """Base class for all foundation messages. - Messages are immutable value objects that represent intent or facts in the application. - This is the base class for Commands (something to do), Events (something that happened), - and Queries (something to obtain). + Messages represent intent or facts in the application. This is the + base class for Commands (something to do), Events (something that + happened), and Queries (something to obtain). - Features: - - Immutable by design (inherits from ValueObject) - - Contains MessageMetadata for infrastructure concerns - - Focus on data in subclasses - - Each message instance is unique (based on metadata.message_id) + Messages are immutable and each instance is unique -- equality is + determined solely by the ``message_id`` carried in + :class:`MessageMetadata`. - This class should not be used directly. Use Event or Command instead. + This class should not be used directly. Import :class:`Event` or + :class:`Command` instead. """ + def __init_subclass__(cls, **kwargs: Any) -> None: + """Automatically apply ``auto_freeze`` to concrete subclasses.""" + super().__init_subclass__(**kwargs) + if not inspect.isabstract(cls): + auto_freeze(cls) + def __init__(self, metadata: MessageMetadata | None = None) -> None: """Initialize the message with metadata. @@ -40,13 +45,18 @@ def __init__(self, metadata: MessageMetadata | None = None) -> None: self._metadata = metadata or MessageMetadata(message_type=effective_type) def __eq__(self, other: object) -> bool: - """Check equality based on _equality_components.""" - if not isinstance(other, Message): + """Compare messages by message ID. + + Two messages are equal if and only if they have the same + ``message_id``. All other fields (payload, timestamp, causation) + are ignored for equality. + """ + if type(self) is not type(other): return False - return self._equality_components == other._equality_components + return self.message_id == other.message_id # type: ignore[union-attr] def __hash__(self) -> int: - return hash(self._equality_components) + return hash(self.message_id) @property def metadata(self) -> MessageMetadata: @@ -112,13 +122,6 @@ def from_payload_fields( """ @property - def _equality_components(self) -> tuple[Hashable, ...]: - """Messages are equal if they have the same message ID. - - Each message instance is unique, even if they have the same data. - - Returns: - Tuple containing the message ID for equality comparison. - - """ - return (self._metadata.message_id,) + @abstractmethod + def value(self) -> MessageRawType: + """Return the raw message payload as a single value.""" diff --git a/src/forging_blocks/foundation/messages/message/_metadata.py b/src/forging_blocks/foundation/messages/message/_metadata.py index 1ff4f6d0..cc68706a 100644 --- a/src/forging_blocks/foundation/messages/message/_metadata.py +++ b/src/forging_blocks/foundation/messages/message/_metadata.py @@ -1,6 +1,5 @@ """MessageMetadata value object for messaging patterns.""" -from collections.abc import Hashable from datetime import datetime, timezone from uuid import UUID, uuid7 @@ -133,13 +132,3 @@ def value(self) -> dict[str, object]: "message_id": str(self._message_id), "message_type": self._message_type, } - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - """Message metadata equality is based on message ID and timestamp. - - Returns: - Tuple containing message_id and created_at. - - """ - return (self._message_id, self._created_at) From e5c865b270319b1792569a25eb683b4c3d3dd525 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 05:12:46 -0300 Subject: [PATCH 26/86] refactor(subclass-vos): remove _equality_components from all ValueObject subclasses All ValueObject subclasses now rely on @auto_hash for structural equality instead of overriding _equality_components. Changes across 8 files: - AggregateVersion: remove _equality_components and Hashable import - Context VOs (auth, service, transaction): inline _equality_components body into their existing .value property; no behavioral change - Script VOs (release_branch, release_level, release_version, tag_name): remove _equality_components property and unused Hashable imports --- .../release/domain/value_objects/release_branch_name.py | 6 +----- scripts/release/domain/value_objects/release_level.py | 5 ----- scripts/release/domain/value_objects/release_version.py | 6 ------ scripts/release/domain/value_objects/tag_name.py | 6 +----- .../domain/aggregate_root/aggregate_version.py | 7 ------- .../foundation/context/authorization_context.py | 4 ---- src/forging_blocks/foundation/context/service_context.py | 4 ---- .../foundation/context/transaction_context.py | 4 ---- 8 files changed, 2 insertions(+), 40 deletions(-) diff --git a/scripts/release/domain/value_objects/release_branch_name.py b/scripts/release/domain/value_objects/release_branch_name.py index db3c9d9b..d8a674f7 100644 --- a/scripts/release/domain/value_objects/release_branch_name.py +++ b/scripts/release/domain/value_objects/release_branch_name.py @@ -1,4 +1,4 @@ -from typing import Hashable, Self +from typing import Self from forging_blocks.domain import ValueObject from scripts.release.domain.errors import InvalidReleaseBranchNameError, InvalidReleaseVersionError @@ -41,7 +41,3 @@ def from_version(cls, version: ReleaseVersion) -> Self: @property def value(self) -> str: return self._value - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return (self._value,) diff --git a/scripts/release/domain/value_objects/release_level.py b/scripts/release/domain/value_objects/release_level.py index 62c2b5b2..239a446a 100644 --- a/scripts/release/domain/value_objects/release_level.py +++ b/scripts/release/domain/value_objects/release_level.py @@ -1,7 +1,6 @@ from __future__ import annotations from enum import StrEnum, auto -from typing import Hashable from forging_blocks.domain import ValueObject from scripts.release.domain.errors import InvalidReleaseLevelError @@ -29,7 +28,3 @@ def from_str(cls, value: str) -> ReleaseLevel: @property def value(self) -> ReleaseLevelEnum: return self._level - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return (self._level,) diff --git a/scripts/release/domain/value_objects/release_version.py b/scripts/release/domain/value_objects/release_version.py index 66e60c65..301b03f8 100644 --- a/scripts/release/domain/value_objects/release_version.py +++ b/scripts/release/domain/value_objects/release_version.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Hashable - from forging_blocks.domain import ValueObject from scripts.release.domain.errors import InvalidReleaseVersionError @@ -42,7 +40,3 @@ def minor(self) -> int: @property def patch(self) -> int: return self._patch - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return (self._major, self._minor, self._patch) diff --git a/scripts/release/domain/value_objects/tag_name.py b/scripts/release/domain/value_objects/tag_name.py index e811cd5f..14741f2e 100644 --- a/scripts/release/domain/value_objects/tag_name.py +++ b/scripts/release/domain/value_objects/tag_name.py @@ -1,4 +1,4 @@ -from typing import Hashable, Self +from typing import Self from forging_blocks.domain import ValueObject from scripts.release.domain.errors import InvalidReleaseVersionError, InvalidTagNameError @@ -41,7 +41,3 @@ def for_version(cls, version: ReleaseVersion) -> Self: @property def value(self) -> str: return self._value - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return (self._value,) diff --git a/src/forging_blocks/domain/aggregate_root/aggregate_version.py b/src/forging_blocks/domain/aggregate_root/aggregate_version.py index 0877d1ce..0ab6e0fb 100644 --- a/src/forging_blocks/domain/aggregate_root/aggregate_version.py +++ b/src/forging_blocks/domain/aggregate_root/aggregate_version.py @@ -1,7 +1,5 @@ """AggregateVersion value object for optimistic concurrency control.""" -from collections.abc import Hashable - from forging_blocks.foundation.value_object import ValueObject @@ -27,8 +25,3 @@ def value(self) -> int: def increment(self) -> AggregateVersion: """Return a new AggregateVersion incremented by one.""" return AggregateVersion(self._value + 1) - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - """Components used for equality comparison.""" - return (self._value,) diff --git a/src/forging_blocks/foundation/context/authorization_context.py b/src/forging_blocks/foundation/context/authorization_context.py index 8887d52b..46f5554c 100644 --- a/src/forging_blocks/foundation/context/authorization_context.py +++ b/src/forging_blocks/foundation/context/authorization_context.py @@ -80,10 +80,6 @@ def metadata(self) -> tuple[tuple[str, Hashable], ...]: @property def value(self) -> tuple[Hashable, ...]: """Composite value: all fields as a tuple.""" - return self._equality_components - - @property - def _equality_components(self) -> tuple[Hashable, ...]: return ( self._user_id, self._roles, diff --git a/src/forging_blocks/foundation/context/service_context.py b/src/forging_blocks/foundation/context/service_context.py index fb04f7c3..6234edee 100644 --- a/src/forging_blocks/foundation/context/service_context.py +++ b/src/forging_blocks/foundation/context/service_context.py @@ -63,10 +63,6 @@ def metadata(self) -> tuple[tuple[str, Hashable], ...]: @property def value(self) -> tuple[Hashable, ...]: """Composite value: all fields as a tuple.""" - return self._equality_components - - @property - def _equality_components(self) -> tuple[Hashable, ...]: return ( self._correlation_id, self._user_id, diff --git a/src/forging_blocks/foundation/context/transaction_context.py b/src/forging_blocks/foundation/context/transaction_context.py index cf24dea0..cf80f33f 100644 --- a/src/forging_blocks/foundation/context/transaction_context.py +++ b/src/forging_blocks/foundation/context/transaction_context.py @@ -67,10 +67,6 @@ def metadata(self) -> tuple[tuple[str, Hashable], ...]: @property def value(self) -> tuple[Hashable, ...]: """Composite value: all fields as a tuple.""" - return self._equality_components - - @property - def _equality_components(self) -> tuple[Hashable, ...]: return ( self._transaction_id, self._started_at, From bd6041a51ffc9794aefe5e3283e3346fb44b9fa5 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 05:13:20 -0300 Subject: [PATCH 27/86] test(equality): update tests after ValueObject/Message refactor Remove _equality_components references from all test files: - test_value_objects.py: remove property from Email, AnotherEmailType, ParentVO, and MultiComponentVO helper classes - test_metadata.py: update equality/hash tests for full 5-field structural equality (was partial 2-field) - test_tag_name.py: remove test that directly accessed _equality_components; equality already covered by existing tests --- .../foundation/messages/test_metadata.py | 18 ++++++++++++++---- .../foundation/test_value_objects.py | 16 ---------------- .../domain/value_objects/test_tag_name.py | 5 ----- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/tests/forging_blocks/foundation/messages/test_metadata.py b/tests/forging_blocks/foundation/messages/test_metadata.py index f5b89aba..8e1d33ce 100644 --- a/tests/forging_blocks/foundation/messages/test_metadata.py +++ b/tests/forging_blocks/foundation/messages/test_metadata.py @@ -94,17 +94,21 @@ def test_created_at_when_provided_without_timezone_then_preserves_value(self) -> assert metadata.created_at == naive assert metadata.created_at.tzinfo is None - def test_eq_when_same_id_and_timestamp_then_true(self) -> None: - """Two instances with identical message_id and created_at are equal.""" + def test_eq_when_all_fields_equal_then_true(self) -> None: + """Two instances with identical values for all fields are equal.""" metadata1 = MessageMetadata( message_type=self.message_type, message_id=self.message_id, created_at=self.created_at, + causation_id=self.causation_id, + correlation_id=self.correlation_id, ) metadata2 = MessageMetadata( message_type=self.message_type, message_id=self.message_id, created_at=self.created_at, + causation_id=self.causation_id, + correlation_id=self.correlation_id, ) assert metadata1 == metadata2 @@ -150,20 +154,26 @@ def test_eq_when_different_type_then_false(self) -> None: assert metadata != "not metadata" assert metadata != 42 - def test_hash_when_same_values_then_same_hash(self) -> None: - """Equal instances produce identical hashes.""" + def test_hash_when_all_fields_equal_then_same_hash(self) -> None: + """Equal instances (all fields match) produce identical hashes.""" message_id = uuid7() created_at = datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc) + causation_id = uuid7() + correlation_id = uuid7() metadata1 = MessageMetadata( message_type=self.message_type, message_id=message_id, created_at=created_at, + causation_id=causation_id, + correlation_id=correlation_id, ) metadata2 = MessageMetadata( message_type=self.message_type, message_id=message_id, created_at=created_at, + causation_id=causation_id, + correlation_id=correlation_id, ) assert hash(metadata1) == hash(metadata2) diff --git a/tests/forging_blocks/foundation/test_value_objects.py b/tests/forging_blocks/foundation/test_value_objects.py index aac618a5..18a5b31a 100644 --- a/tests/forging_blocks/foundation/test_value_objects.py +++ b/tests/forging_blocks/foundation/test_value_objects.py @@ -18,10 +18,6 @@ def __init__(self, value: str): def value(self) -> str: return self._value - @property - def _equality_components(self) -> tuple[str]: - return (self._value,) - class AnotherEmailType(ValueObject[str]): __slots__ = ("_value",) @@ -36,10 +32,6 @@ def __init__(self, value: str): def value(self) -> str: return self._value - @property - def _equality_components(self) -> tuple[str]: - return (self._value,) - class ParentVO(ValueObject[str]): __slots__ = ("_value",) @@ -52,10 +44,6 @@ def __init__(self, value: str): def value(self) -> str: return self._value - @property - def _equality_components(self) -> tuple[str]: - return (self._value,) - class ChildVO(ParentVO): __slots__ = () @@ -78,10 +66,6 @@ def __init__(self, first: str, second: str) -> None: def value(self) -> str: return f"{self._first}:{self._second}" - @property - def _equality_components(self) -> tuple[str, str]: - return (self._first, self._second) - @pytest.mark.unit class TestValueObject: diff --git a/tests/scripts/release/domain/value_objects/test_tag_name.py b/tests/scripts/release/domain/value_objects/test_tag_name.py index 18035d5b..06c66c53 100644 --- a/tests/scripts/release/domain/value_objects/test_tag_name.py +++ b/tests/scripts/release/domain/value_objects/test_tag_name.py @@ -85,8 +85,3 @@ def test_for_version_when_called_then_returns_tag(self) -> None: tag = TagName.for_version(version) assert tag.value == "v2.1.0" - - def test_equality_components_when_called_then_returns_tuple(self) -> None: - tag = TagName("v1.0.0") - - assert tag._equality_components == ("v1.0.0",) From 15d53f93c5af92aa2800188300cd70e3b1a3fcc8 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 05:24:28 -0300 Subject: [PATCH 28/86] refactor(autohash): decouple auto_hash from auto_freeze, apply independently in ValueObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@auto_hash` no longer implicitly applies `@auto_freeze`. Callers that relied on the implicit freeze — `ValueObject.__init_subclass__`, `ErrorMessageModel`, and `ErrorViewModel` — now apply both decorators explicitly. --- .../foundation/autohash/auto_hash.py | 41 +++++++++++-------- src/forging_blocks/foundation/value_object.py | 16 ++++---- .../errors/error_message_model.py | 2 + .../presentation/errors/error_view_model.py | 2 + .../foundation/autohash/test_auto_hash.py | 13 +++--- 5 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/forging_blocks/foundation/autohash/auto_hash.py b/src/forging_blocks/foundation/autohash/auto_hash.py index 69fb566f..4cbdbb50 100644 --- a/src/forging_blocks/foundation/autohash/auto_hash.py +++ b/src/forging_blocks/foundation/autohash/auto_hash.py @@ -1,13 +1,10 @@ """Auto-hash decorator for generating ``__hash__`` and ``__eq__`` on class instances. Provides the :func:`auto_hash` decorator that generates ``__hash__`` and -``__eq__`` methods based on class fields and automatically composes -:func:`auto_freeze` to enforce immutability. A hashable object must be -immutable -- ``@auto_hash`` ensures both by applying ``@auto_freeze`` -internally, which forbids attribute assignment after ``__init__`` completes. -Works with ``@dataclass`` (``@auto_hash`` must be the outermost decorator --- apply it *above* ``@dataclass``) and on plain classes with ``__slots__`` -or ``__annotations__``:: +``__eq__`` methods based on class fields. Works with ``@dataclass`` +(``@auto_hash`` must be the outermost decorator -- apply it *above* +``@dataclass``) and on plain classes with ``__slots__`` or +``__annotations__``:: @auto_hash @dataclass @@ -18,17 +15,25 @@ class MyType: Can be used as ``@auto_hash``, ``@auto_hash()``, or ``@auto_hash(fields=[...])`` to hash only specific attributes. -Explicit ``@auto_freeze`` is NOT required -- ``@auto_hash`` applies it -automatically. Applying both is harmless (idempotent). +For immutability, combine with :func:`auto_freeze`: + + @auto_hash + @auto_freeze + @dataclass + class Money: + amount: int + currency: str Example: ```python from dataclasses import dataclass from forging_blocks.foundation.autohash import auto_hash + from forging_blocks.foundation.autofreeze import auto_freeze @auto_hash + @auto_freeze @dataclass class Money: amount: int @@ -47,7 +52,6 @@ class Money: from collections.abc import Callable, Sequence from typing import Any, overload -from forging_blocks.foundation.autofreeze import auto_freeze as _auto_freeze from forging_blocks.foundation.autohash.helpers.hashable_converter import ( HashableConverter, ) @@ -80,7 +84,9 @@ def __call__[T](self, class_: type[T]) -> type[T]: class_: The target class to decorate. Returns: - The decorated class with ``__hash__`` and ``__eq__`` generated. + The decorated class with ``__hash__`` and ``__eq__`` generated + (no longer applies ``@auto_freeze`` implicitly -- apply both + decorators separately when immutability is needed). """ field_names = self._resolve_field_names(class_) @@ -104,7 +110,6 @@ def _eq_impl(self: Any, other: object) -> bool: class_.__hash__ = _hash_impl class_.__eq__ = _eq_impl class_.__auto_hash_fields__ = _field_names - _auto_freeze(class_) return class_ def _resolve_field_names(self, class_: type[object]) -> list[str]: @@ -190,12 +195,12 @@ def auto_hash[T]( *, fields: Sequence[str] | None = None, ) -> type[T] | Callable[[type[T]], type[T]]: - """Generate ``__hash__`` for a class based on its fields. + """Generate ``__hash__`` and ``__eq__`` for a class based on its fields. - Automatically applies :func:`auto_freeze` to enforce immutability. - Hashable objects MUST be immutable -- ``@auto_hash`` ensures both by - applying ``@auto_freeze`` internally, which forbids attribute assignment - after ``__init__`` completes. + Does NOT apply :func:`auto_freeze` -- combine both decorators when + immutability is required. Hashable objects SHOULD be immutable; + ``@auto_hash`` generates the structural comparison but leaves + immutability enforcement to the caller. When the class is a ``@dataclass``, *fields* defaults to all declared dataclass fields (via ``dataclasses.fields``). Otherwise it falls back to ``__slots__`` (across the MRO) or ``__annotations__`` keys. @@ -206,7 +211,7 @@ def auto_hash[T]( ``None`` (the default), all dataclass fields are used. Returns: The decorated class (or a decorator when used parameterised), - with ``__hash__``, ``__eq__``, and immutability applied. + with ``__hash__`` and ``__eq__`` generated. Raises: TypeError: If no field names can be determined and *fields* is diff --git a/src/forging_blocks/foundation/value_object.py b/src/forging_blocks/foundation/value_object.py index fed19c84..27c58fb0 100644 --- a/src/forging_blocks/foundation/value_object.py +++ b/src/forging_blocks/foundation/value_object.py @@ -8,6 +8,7 @@ from abc import ABC, abstractmethod from typing import Any +from forging_blocks.foundation.autofreeze import auto_freeze from forging_blocks.foundation.autohash import auto_hash @@ -19,10 +20,10 @@ class ValueObject[RawValueType](ABC): are considered equal. Concrete subclasses are automatically frozen and hashable via - :func:`auto_hash` which generates ``__hash__`` / ``__eq__`` from slot - fields and applies ``@auto_freeze`` for immutability. Intermediate - abstract classes are skipped so leaf subclasses finish their own - ``__init__`` without restriction. + :func:`auto_freeze` and :func:`auto_hash`, which generate + ``__hash__`` / ``__eq__`` from slot fields and enforce + immutability. Intermediate abstract classes are skipped so leaf + subclasses finish their own ``__init__`` without restriction. Example: ```python @@ -43,13 +44,14 @@ def value(self) -> str: """ def __init_subclass__(cls, **kwargs: Any) -> None: - """Automatically apply ``auto_hash`` to concrete subclasses. + """Apply ``auto_freeze`` and ``auto_hash`` to concrete subclasses. - ``auto_hash`` generates ``__hash__`` / ``__eq__`` from class fields - and also applies ``@auto_freeze`` to enforce immutability. + ``auto_freeze`` enforces immutability; ``auto_hash`` generates + ``__hash__`` / ``__eq__`` from class fields. """ super().__init_subclass__(**kwargs) if not inspect.isabstract(cls): + auto_freeze(cls) auto_hash(cls) def __str__(self) -> str: diff --git a/src/forging_blocks/presentation/errors/error_message_model.py b/src/forging_blocks/presentation/errors/error_message_model.py index 515b5a7b..8bd33e60 100644 --- a/src/forging_blocks/presentation/errors/error_message_model.py +++ b/src/forging_blocks/presentation/errors/error_message_model.py @@ -2,10 +2,12 @@ from dataclasses import dataclass +from forging_blocks.foundation.autofreeze import auto_freeze from forging_blocks.foundation.autohash import auto_hash @auto_hash +@auto_freeze @dataclass class ErrorMessageModel: """Carries the information needed to render one error to a user. diff --git a/src/forging_blocks/presentation/errors/error_view_model.py b/src/forging_blocks/presentation/errors/error_view_model.py index 0ad1407b..95132cb4 100644 --- a/src/forging_blocks/presentation/errors/error_view_model.py +++ b/src/forging_blocks/presentation/errors/error_view_model.py @@ -2,11 +2,13 @@ from dataclasses import dataclass, field +from forging_blocks.foundation.autofreeze import auto_freeze from forging_blocks.foundation.autohash import auto_hash from forging_blocks.presentation.errors.error_message_model import ErrorMessageModel @auto_hash +@auto_freeze @dataclass class ErrorViewModel: """Holds one or more ``ErrorMessageModel`` entries produced by an diff --git a/tests/forging_blocks/foundation/autohash/test_auto_hash.py b/tests/forging_blocks/foundation/autohash/test_auto_hash.py index 2e94122d..c3b16d5d 100644 --- a/tests/forging_blocks/foundation/autohash/test_auto_hash.py +++ b/tests/forging_blocks/foundation/autohash/test_auto_hash.py @@ -159,8 +159,8 @@ class Item: assert hash(i1) == hash(i2) assert i1 in {i1, i2} # hashable → can be in set - def test_when_auto_hash_then_instance_is_frozen(self) -> None: - """auto_hash composes auto_freeze — instances are immutable.""" + def test_when_auto_hash_alone_then_instance_not_frozen(self) -> None: + """@auto_hash no longer applies @auto_freeze — mutation is allowed.""" @auto_hash @dataclass @@ -171,8 +171,9 @@ class Point: p = Point(1, 2) _ = hash(p) - with pytest.raises(CantModifyImmutableAttributeError): - p.x = 999 + # No error expected — @auto_hash alone does NOT freeze. + p.x = 999 + assert p.x == 999 def test_when_plain_class_with_slots_then_hash_works(self) -> None: @auto_hash @@ -315,7 +316,7 @@ def __init__(self, x: int, y: int) -> None: assert hash(p1) != hash(p3) def test_when_auto_hash_and_auto_freeze_stacked_then_idempotent(self) -> None: - """@auto_hash auto-applies @auto_freeze — stacking both is harmless.""" + """@auto_hash and @auto_freeze are independent — stacking is explicit.""" @auto_freeze @auto_hash @@ -330,6 +331,8 @@ class Point: p.x = 99 def test_when_auto_freeze_before_auto_hash_then_idempotent(self) -> None: + """@auto_hash stacked above @auto_freeze — order-independent when both explicit.""" + @auto_hash @auto_freeze @dataclass From a18e12e75d537e50da856f03d994f4facace05e9 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 05:26:29 -0300 Subject: [PATCH 29/86] docs: remove _equality_components references after ValueObject refactor _equality_components is no longer part of the ValueObject contract -- `@auto_hash` and `@auto_freeze` handle structural equality and immutability automatically via `__init_subclass__`. --- .github/copilot-instructions.md | 1 - docs/guide/examples.md | 14 +++++--------- docs/guide/getting-started.md | 8 ++------ 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4d0707e6..25b711ed 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -459,7 +459,6 @@ class MyPort[T](OutboundPort): | Blocking I/O in async context | ❌ Flag | Add `async def` / `await` | | Manual `_payload` on `@event_dataclass` class | ❌ Flag | Decorator patches it automatically | | Missing `__slots__` on ValueObject subclass | ❌ Flag | Add `__slots__ = ("_field",)` | -| Missing `_equality_components` on ValueObject | ❌ Flag | Implement required property | | `try/except` for control flow | ❌ Flag | Use `Result[T, E]` | | Overriding `@runtime_final` method | ❌ Flag | `TypeError` at class creation | | Overriding `apply`, `replay`, `collect_events` in AggregateRoot | ❌ Flag | These are runtime-final | diff --git a/docs/guide/examples.md b/docs/guide/examples.md index 9aa4fa10..993e5a00 100644 --- a/docs/guide/examples.md +++ b/docs/guide/examples.md @@ -127,7 +127,6 @@ The design is: ## 4. Modeling a value with ValueObject ```python -from collections.abc import Hashable from forging_blocks.foundation.value_object import ValueObject @@ -144,16 +143,13 @@ class Email(ValueObject[str]): def value(self) -> str: return self._value - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return (self._value,) ``` -`ValueObject` uses automatic freezing — subclasses need **no** `@auto_freeze` -decorator or `_freeze()` call. Two `Email` instances with the same value are -considered equal and can be used interchangeably as dictionary keys or set -members. Attempting to mutate one after construction raises a -`CantModifyImmutableAttributeError`. +`ValueObject` uses :func:`auto_freeze` and :func:`auto_hash` under the hood -- +concrete subclasses are automatically frozen and hashable via +``__init_subclass__``. Two ``Email`` instances with the same value are +equal and can be used as dictionary keys or set members. Attempting to +mutate one after construction raises a ``CantModifyImmutableAttributeError``. --- diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index e997b206..01763985 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -49,7 +49,6 @@ When a value is more than a primitive, you can wrap it in a `ValueObject` to make its rules visible and reusable. ```python -from collections.abc import Hashable from forging_blocks.foundation.value_object import ValueObject @@ -65,14 +64,11 @@ class Email(ValueObject[str]): @property def value(self) -> str: return self._value - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return (self._value,) ``` `ValueObject` gives you value-based equality, hashability, and immutability -out of the box, so that you can focus on the rules of *your* value. +automatically via :func:`auto_hash` and :func:`auto_freeze`, so that you +can focus on the rules of *your* value. --- From 88861305da7b553d0bb1ec4505f3ebb622b8f419 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 05:30:17 -0300 Subject: [PATCH 30/86] docs: remove remaining _equality_components references from copilot instructions and test names - copilot-instructions.md: drop dead Hashable import, remove _equality_components property from Email example, update text to mention auto_freeze + auto_hash via __init_subclass__ - test_release_level.py: rename test methods to drop stale _equality_components from names --- .github/copilot-instructions.md | 10 ++++------ .../release/domain/value_objects/test_release_level.py | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 25b711ed..04d7c43b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -167,7 +167,7 @@ Do **not** implement `_payload` manually on decorated classes — the decorator ```python from forging_blocks.foundation.value_object import ValueObject -from collections.abc import Hashable + class Email(ValueObject[str]): __slots__ = ("_value",) # always include __slots__ @@ -181,13 +181,11 @@ class Email(ValueObject[str]): @property def value(self) -> str: return self._value - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return (self._value,) # controls equality and hashing ``` -`ValueObject` uses `@auto_freeze` internally. After `__init__` completes, any mutation raises `CantModifyImmutableAttributeError`. Concrete subclasses are frozen automatically — no decorator needed. +Concrete ``ValueObject`` subclasses are automatically frozen and hashable via +:func:`auto_freeze` and :func:`auto_hash` (applied through ``__init_subclass__``). +After ``__init__`` completes, any mutation raises ``CantModifyImmutableAttributeError``. ### Specifications: Composable Predicates diff --git a/tests/scripts/release/domain/value_objects/test_release_level.py b/tests/scripts/release/domain/value_objects/test_release_level.py index baf9e03d..bbaf99ff 100644 --- a/tests/scripts/release/domain/value_objects/test_release_level.py +++ b/tests/scripts/release/domain/value_objects/test_release_level.py @@ -35,13 +35,13 @@ def test_from_str_when_invalid_value_then_error(self) -> None: with pytest.raises(InvalidReleaseLevelError): ReleaseLevel.from_str("hotfix") - def test_equality_components_when_same_level_then_equal(self) -> None: + def test_when_same_level_then_equal(self) -> None: level_1 = ReleaseLevel.from_str("PATCH") level_2 = ReleaseLevel.from_str("PATCH") assert level_1 == level_2 - def test_equality_components_when_different_level_then_not_equal(self) -> None: + def test_when_different_level_then_not_equal(self) -> None: level_1 = ReleaseLevel.from_str("PATCH") level_2 = ReleaseLevel.from_str("MINOR") From 8d1b218b5766f4e9f6584bc6c2122684b0ac73be Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 13:36:44 -0300 Subject: [PATCH 31/86] fix(messages): apply auto_hash/auto_eq unconditionally in Message.__init_subclass__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isabstract() check in __init_subclass__ was blocking auto_hash/auto_eq for decorated message subclasses (Event, Command, Query) because abstract methods are patched by message_dataclass AFTER __init_subclass__ runs. Now auto_hash and auto_eq are applied unconditionally — they're harmless for abstract classes. auto_freeze remains conditional since decorated messages use message_dataclass's own freeze mechanism. --- .../foundation/messages/message/_message.py | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/forging_blocks/foundation/messages/message/_message.py b/src/forging_blocks/foundation/messages/message/_message.py index 6f2dd7e3..10be8300 100644 --- a/src/forging_blocks/foundation/messages/message/_message.py +++ b/src/forging_blocks/foundation/messages/message/_message.py @@ -6,7 +6,9 @@ from typing import Any, Self from uuid import UUID +from forging_blocks.foundation.autoeq import auto_eq from forging_blocks.foundation.autofreeze import auto_freeze +from forging_blocks.foundation.autohash import auto_hash from ._metadata import MessageMetadata @@ -18,17 +20,29 @@ class Message[MessageRawType](ABC): base class for Commands (something to do), Events (something that happened), and Queries (something to obtain). - Messages are immutable and each instance is unique -- equality is - determined solely by the ``message_id`` carried in - :class:`MessageMetadata`. + Messages are immutable and each instance is unique — equality and + hash are determined solely by the ``message_id`` carried in + :class:`MessageMetadata`, enforced via :func:`auto_hash` and + :func:`auto_eq` with ``fields=["message_id"]``. This class should not be used directly. Import :class:`Event` or :class:`Command` instead. """ def __init_subclass__(cls, **kwargs: Any) -> None: - """Automatically apply ``auto_freeze`` to concrete subclasses.""" + """Automatically apply ``auto_hash``, ``auto_eq``, and ``auto_freeze`` + to concrete subclasses. + + ``auto_hash`` and ``auto_eq`` are applied unconditionally (before the + abstract-method check) so they take effect even when a decorator like + ``@message_dataclass`` patches ``__abstractmethods__`` later. + ``auto_hash`` and ``auto_eq`` use ``fields=["message_id"]`` so that + message identity (equality and hashing) is driven solely by the + unique message identifier, not by payload fields. + """ super().__init_subclass__(**kwargs) + auto_hash(cls, fields=["message_id"]) + auto_eq(cls, fields=["message_id"]) if not inspect.isabstract(cls): auto_freeze(cls) @@ -44,20 +58,6 @@ def __init__(self, metadata: MessageMetadata | None = None) -> None: effective_type = type(self).__name__ self._metadata = metadata or MessageMetadata(message_type=effective_type) - def __eq__(self, other: object) -> bool: - """Compare messages by message ID. - - Two messages are equal if and only if they have the same - ``message_id``. All other fields (payload, timestamp, causation) - are ignored for equality. - """ - if type(self) is not type(other): - return False - return self.message_id == other.message_id # type: ignore[union-attr] - - def __hash__(self) -> int: - return hash(self.message_id) - @property def metadata(self) -> MessageMetadata: """Get the message metadata. From b5436539263883f40d538990f9e1356ab935bab8 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 13:36:53 -0300 Subject: [PATCH 32/86] refactor(autohash): split __eq__ out of auto_hash into standalone auto_eq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto_hash now generates only __hash__. auto_eq is a new standalone decorator for __eq__. Both are independent — neither composes the other nor applies auto_freeze. auto_hash docstrings and README corrected to remove stale claims about generating __eq__. --- .../foundation/autoeq/README.md | 41 +++ .../foundation/autoeq/__init__.py | 5 + .../foundation/autoeq/auto_eq.py | 128 ++++++++ .../foundation/autoeq/helpers/__init__.py | 12 + .../autoeq/helpers/field_resolver.py | 45 +++ .../foundation/autofreeze/README.md | 33 ++ .../foundation/autohash/README.md | 47 +++ .../foundation/autohash/auto_hash.py | 70 ++--- .../foundation/autoeq/__init__.py | 0 .../foundation/autoeq/test_auto_eq.py | 297 ++++++++++++++++++ .../foundation/autohash/test_auto_hash.py | 4 +- 11 files changed, 640 insertions(+), 42 deletions(-) create mode 100644 src/forging_blocks/foundation/autoeq/README.md create mode 100644 src/forging_blocks/foundation/autoeq/__init__.py create mode 100644 src/forging_blocks/foundation/autoeq/auto_eq.py create mode 100644 src/forging_blocks/foundation/autoeq/helpers/__init__.py create mode 100644 src/forging_blocks/foundation/autoeq/helpers/field_resolver.py create mode 100644 src/forging_blocks/foundation/autofreeze/README.md create mode 100644 src/forging_blocks/foundation/autohash/README.md create mode 100644 tests/forging_blocks/foundation/autoeq/__init__.py create mode 100644 tests/forging_blocks/foundation/autoeq/test_auto_eq.py diff --git a/src/forging_blocks/foundation/autoeq/README.md b/src/forging_blocks/foundation/autoeq/README.md new file mode 100644 index 00000000..3cd6f061 --- /dev/null +++ b/src/forging_blocks/foundation/autoeq/README.md @@ -0,0 +1,41 @@ +"""The `auto_eq` decorator generates `__eq__` for class instances. + +Generates equality based on class fields but does **not** generate +`__hash__`. Combine with [auto_hash][] when hashability is needed. + +## Usage + +=== "Bare decorator" + ```python + from dataclasses import dataclass + from forging_blocks.foundation.autoeq import auto_eq + + @auto_eq + @dataclass + class Point: + x: int + y: int + + assert Point(1, 2) == Point(1, 2) + ``` + +=== "With parameters" + ```python + from dataclasses import dataclass + from forging_blocks.foundation.autoeq import auto_eq + + @auto_eq(fields=["x"]) + @dataclass + class Point: + x: int + y: int + + assert Point(1, 2) == Point(1, 999) + ``` + +## Generated members + +|Member|Description| +|---|---| +|`__eq__`|Structural equality on selected fields; type-strict (`type(self) is type(other)`)| +|`__auto_eq_fields__`|Tuple of field names used in equality| diff --git a/src/forging_blocks/foundation/autoeq/__init__.py b/src/forging_blocks/foundation/autoeq/__init__.py new file mode 100644 index 00000000..fdac1547 --- /dev/null +++ b/src/forging_blocks/foundation/autoeq/__init__.py @@ -0,0 +1,5 @@ +from .auto_eq import auto_eq + +__all__ = [ + "auto_eq", +] diff --git a/src/forging_blocks/foundation/autoeq/auto_eq.py b/src/forging_blocks/foundation/autoeq/auto_eq.py new file mode 100644 index 00000000..c9c1f6c3 --- /dev/null +++ b/src/forging_blocks/foundation/autoeq/auto_eq.py @@ -0,0 +1,128 @@ +"""Auto-eq decorator for generating ``__eq__`` on class instances. + +Provides the :func:`auto_eq` decorator that generates ``__eq__`` based on +class fields. Works with ``@dataclass`` (``@auto_eq`` must be the outermost +decorator) and on plain classes with ``__slots__`` or ``__annotations__``. + +Can be used as ``@auto_eq``, ``@auto_eq()``, or ``@auto_eq(fields=[...])`` +to compare only specific attributes. + +Does NOT generate ``__hash__`` — combine with :func:`auto_hash` when +hashability is needed: + + @auto_hash + @auto_eq + @auto_freeze + @dataclass + class Money: + amount: int + currency: str + +Example: + ```python + from dataclasses import dataclass + from forging_blocks.foundation.autoeq import auto_eq + + + @auto_eq + @dataclass + class Point: + x: int + y: int + + + p1 = Point(1, 2) + p2 = Point(1, 2) + p3 = Point(3, 4) + assert p1 == p2 + assert p1 != p3 + ``` +""" + +from collections.abc import Callable, Sequence +from typing import Any, overload + +from forging_blocks.foundation.autoeq.helpers.field_resolver import ( + FieldResolver, +) + + +class _AutoEqDecorator: + """Callable class that applies auto-eq behaviour to a target class. + + Generates ``__eq__`` based on the class's fields. Does NOT generate + ``__hash__`` — use :func:`auto_hash` separately when hashability + is required. + """ + + def __init__(self, *, fields: Sequence[str] | None = None) -> None: + self._fields = fields + + def __call__[T](self, class_: type[T]) -> type[T]: + field_names = FieldResolver.resolve(class_, self._fields) + _field_names = tuple(field_names) + + def _eq_impl(self: Any, other: object) -> bool: + if type(self) is not type(other): + return False + return all(getattr(self, f) == getattr(other, f) for f in _field_names) + + _eq_impl.__name__ = "__eq__" + _eq_impl.__qualname__ = f"{class_.__name__}.__eq__" + + class_.__eq__ = _eq_impl + class_.__auto_eq_fields__ = _field_names + return class_ + + +@overload +def auto_eq[T](class_: type[T]) -> type[T]: ... + + +@overload +def auto_eq[T]( + class_: type[T], + *, + fields: Sequence[str] | None = None, +) -> type[T]: ... + + +@overload +def auto_eq[T]( + class_: None = None, + *, + fields: Sequence[str] | None = None, +) -> Callable[[type[T]], type[T]]: ... + + +def auto_eq[T]( + class_: type[T] | None = None, + *, + fields: Sequence[str] | None = None, +) -> type[T] | Callable[[type[T]], type[T]]: + """Generate ``__eq__`` for a class based on its fields. + + Does NOT generate ``__hash__`` — combine with :func:`auto_hash` + when hashability is required. + When the class is a ``@dataclass``, *fields* defaults to all declared + dataclass fields (via ``dataclasses.fields``). Otherwise it falls back + to ``__slots__`` (across the MRO) or ``__annotations__`` keys. + + Args: + class_: The target class (when used bare). + fields: Specific attribute names to include in equality. When + ``None`` (the default), all dataclass fields are used. + + Returns: + The decorated class (or a decorator when used parameterised), + with ``__eq__`` generated. + + Raises: + TypeError: If no field names can be determined and *fields* is + ``None``. + """ + decorator = _AutoEqDecorator(fields=fields) + + if class_ is not None: + return decorator(class_) + return decorator diff --git a/src/forging_blocks/foundation/autoeq/helpers/__init__.py b/src/forging_blocks/foundation/autoeq/helpers/__init__.py new file mode 100644 index 00000000..a1b566ea --- /dev/null +++ b/src/forging_blocks/foundation/autoeq/helpers/__init__.py @@ -0,0 +1,12 @@ +"""Internal helpers for the auto_eq decorator. + +These are implementation details and not part of the public API. +""" + +from forging_blocks.foundation.autoeq.helpers.field_resolver import ( + FieldResolver, +) + +__all__ = [ + "FieldResolver", +] diff --git a/src/forging_blocks/foundation/autoeq/helpers/field_resolver.py b/src/forging_blocks/foundation/autoeq/helpers/field_resolver.py new file mode 100644 index 00000000..85f8210c --- /dev/null +++ b/src/forging_blocks/foundation/autoeq/helpers/field_resolver.py @@ -0,0 +1,45 @@ +"""Field resolution logic shared by the auto_eq decorator.""" + +import dataclasses +from collections.abc import Sequence + + +class FieldResolver: + """Resolves which field names contribute to ``__eq__`` for a class.""" + + @staticmethod + def resolve( + class_: type[object], + fields: Sequence[str] | None = None, + ) -> list[str]: + if fields is not None: + return list(fields) + + if dataclasses.is_dataclass(class_): + return [f.name for f in dataclasses.fields(class_)] + + slots = FieldResolver._collect_slots(class_) + if slots: + return sorted(slots) + + annotations: dict[str, object] | None = getattr(class_, "__annotations__", None) + if annotations: + return [k for k in annotations if not k.startswith("__")] + + msg = ( + f"Cannot determine eq fields for non-dataclass {class_.__name__}. " + f"Pass fields= explicitly, e.g. @auto_eq(fields=['x', 'y'])." + ) + raise TypeError(msg) + + @staticmethod + def _collect_slots(class_: type[object]) -> set[str]: + all_slots: set[str] = set() + for cls in class_.__mro__: + slots = getattr(cls, "__slots__", ()) + if isinstance(slots, str): + slots = (slots,) + for slot in slots: + if not slot.startswith("__"): + all_slots.add(slot) + return all_slots diff --git a/src/forging_blocks/foundation/autofreeze/README.md b/src/forging_blocks/foundation/autofreeze/README.md new file mode 100644 index 00000000..6a0d5710 --- /dev/null +++ b/src/forging_blocks/foundation/autofreeze/README.md @@ -0,0 +1,33 @@ +"""The `auto_freeze` decorator enforces immutability after `__init__` completes. + +## Usage + +=== "Bare decorator" + ```python + from forging_blocks.foundation.autofreeze import auto_freeze + + @auto_freeze + class Settings: + def __init__(self, host: str) -> None: + self.host = host + + s = Settings("localhost") + s.host = "other" # Raises CantModifyImmutableAttributeError + ``` + +=== "Selective freeze" + ```python + from forging_blocks.foundation.autofreeze import auto_freeze + + @auto_freeze(attrs=["_id"]) + class User: + def __init__(self, user_id: str, name: str) -> None: + self._id = user_id # frozen + self._name = name # mutable + ``` + +## Generated members + +|Member|Description| +|---|---| +|`__setattr__` override|Blocks mutation of frozen attributes after `__init__` returns| diff --git a/src/forging_blocks/foundation/autohash/README.md b/src/forging_blocks/foundation/autohash/README.md new file mode 100644 index 00000000..720de106 --- /dev/null +++ b/src/forging_blocks/foundation/autohash/README.md @@ -0,0 +1,47 @@ +"""The `auto_hash` decorator generates `__hash__` for class instances. + +Does **not** generate ``__eq__`` or apply ``auto_freeze``. ``__eq__`` in the +examples below is provided by ``@dataclass``. Combine with +:func:`~forging_blocks.foundation.autoeq.auto_eq` for explicit equality, +and :func:`~forging_blocks.foundation.autofreeze.auto_freeze` for immutability. + +## Usage + +=== "Bare decorator" + ```python + from dataclasses import dataclass + from forging_blocks.foundation.autohash import auto_hash + + @auto_hash + @dataclass + class Point: + x: int + y: int + + p1 = Point(1, 2) + p2 = Point(1, 2) + assert hash(p1) == hash(p2) + ``` + +=== "With parameters" + ```python + from dataclasses import dataclass + from forging_blocks.foundation.autohash import auto_hash + + @auto_hash(fields=["x"]) + @dataclass + class Point: + x: int + y: int + + p1 = Point(1, 2) + p2 = Point(1, 999) + assert hash(p1) == hash(p2) + ``` + +## Generated members + +|Member|Description| +|---|---| +|`__hash__`|Hash computed from the selected fields (mutable values converted to hashable equivalents)| +|`__auto_hash_fields__`|Tuple of field names used in hashing| diff --git a/src/forging_blocks/foundation/autohash/auto_hash.py b/src/forging_blocks/foundation/autohash/auto_hash.py index 4cbdbb50..718b3999 100644 --- a/src/forging_blocks/foundation/autohash/auto_hash.py +++ b/src/forging_blocks/foundation/autohash/auto_hash.py @@ -1,10 +1,9 @@ -"""Auto-hash decorator for generating ``__hash__`` and ``__eq__`` on class instances. +"""Auto-hash decorator for generating ``__hash__`` on class instances. -Provides the :func:`auto_hash` decorator that generates ``__hash__`` and -``__eq__`` methods based on class fields. Works with ``@dataclass`` -(``@auto_hash`` must be the outermost decorator -- apply it *above* -``@dataclass``) and on plain classes with ``__slots__`` or -``__annotations__``:: +Provides the :func:`auto_hash` decorator that generates ``__hash__`` +based on class fields. Works with ``@dataclass`` (``@auto_hash`` must be +the outermost decorator -- apply it *above* ``@dataclass``) and on plain +classes with ``__slots__`` or ``__annotations__``:: @auto_hash @dataclass @@ -15,9 +14,11 @@ class MyType: Can be used as ``@auto_hash``, ``@auto_hash()``, or ``@auto_hash(fields=[...])`` to hash only specific attributes. -For immutability, combine with :func:`auto_freeze`: +Does NOT generate ``__eq__`` -- combine with :func:`auto_eq` when structural +equality is needed alongside hashing: @auto_hash + @auto_eq @auto_freeze @dataclass class Money: @@ -28,11 +29,13 @@ class Money: ```python from dataclasses import dataclass - from forging_blocks.foundation.autohash import auto_hash + from forging_blocks.foundation.autoeq import auto_eq from forging_blocks.foundation.autofreeze import auto_freeze + from forging_blocks.foundation.autohash import auto_hash @auto_hash + @auto_eq @auto_freeze @dataclass class Money: @@ -42,8 +45,8 @@ class Money: m1 = Money(100, "USD") m2 = Money(100, "USD") - assert m1 == m2 assert hash(m1) == hash(m2) + assert m1 == m2 # from @auto_eq ``` """ @@ -60,9 +63,9 @@ class Money: class _AutoHashDecorator: """Callable class that applies auto-hash behaviour to a target class. - Generates ``__hash__`` and ``__eq__`` methods based on the class's fields. + Generates ``__hash__`` only, based on the class's fields. The hash is computed by converting each field value to a hashable form and - then hashing the resulting tuple. Equality compares the same fields. + then hashing the resulting tuple. """ @@ -84,9 +87,9 @@ def __call__[T](self, class_: type[T]) -> type[T]: class_: The target class to decorate. Returns: - The decorated class with ``__hash__`` and ``__eq__`` generated - (no longer applies ``@auto_freeze`` implicitly -- apply both - decorators separately when immutability is needed). + The decorated class with ``__hash__`` generated from its fields. + Equality (``__eq__``) is NOT generated — use :func:`auto_eq` + separately for structural equality comparisons. """ field_names = self._resolve_field_names(class_) @@ -99,21 +102,12 @@ def _hash_impl(self: Any) -> int: _hash_impl.__name__ = "__hash__" _hash_impl.__qualname__ = f"{class_.__name__}.__hash__" - def _eq_impl(self: Any, other: object) -> bool: - if type(self) is not type(other): - return False - return all(getattr(self, f) == getattr(other, f) for f in _field_names) - - _eq_impl.__name__ = "__eq__" - _eq_impl.__qualname__ = f"{class_.__name__}.__eq__" - class_.__hash__ = _hash_impl - class_.__eq__ = _eq_impl class_.__auto_hash_fields__ = _field_names return class_ def _resolve_field_names(self, class_: type[object]) -> list[str]: - """Determine which field names contribute to ``__hash__`` and ``__eq__``. + """Determine which field names contribute to ``__hash__``. Priority: 1. Explicit *fields* argument passed to the decorator. @@ -195,27 +189,21 @@ def auto_hash[T]( *, fields: Sequence[str] | None = None, ) -> type[T] | Callable[[type[T]], type[T]]: - """Generate ``__hash__`` and ``__eq__`` for a class based on its fields. + """Generate ``__hash__`` for a class based on its fields. - Does NOT apply :func:`auto_freeze` -- combine both decorators when - immutability is required. Hashable objects SHOULD be immutable; - ``@auto_hash`` generates the structural comparison but leaves - immutability enforcement to the caller. - When the class is a ``@dataclass``, *fields* defaults to all declared - dataclass fields (via ``dataclasses.fields``). Otherwise it falls back - to ``__slots__`` (across the MRO) or ``__annotations__`` keys. + Generates ``__hash__`` only — does NOT generate ``__eq__``. + Use :func:`auto_eq` for structural equality comparisons. Args: - class_: The target class (when used bare). - fields: Specific attribute names to include in the hash. When - ``None`` (the default), all dataclass fields are used. - Returns: - The decorated class (or a decorator when used parameterised), - with ``__hash__`` and ``__eq__`` generated. + class_: The class to decorate. + fields: Optional sequence of field names to include in the hash. + When ``None``, all dataclass fields (or ``__slots__``/ + ``__annotations__`` keys) are used. - Raises: - TypeError: If no field names can be determined and *fields* is - ``None``. + Returns: + The decorated class with ``__hash__`` generated. Equality + (``__eq__``) is not changed — apply ``@auto_eq`` separately + when needed. """ decorator = _AutoHashDecorator(fields=fields) diff --git a/tests/forging_blocks/foundation/autoeq/__init__.py b/tests/forging_blocks/foundation/autoeq/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/forging_blocks/foundation/autoeq/test_auto_eq.py b/tests/forging_blocks/foundation/autoeq/test_auto_eq.py new file mode 100644 index 00000000..b896e487 --- /dev/null +++ b/tests/forging_blocks/foundation/autoeq/test_auto_eq.py @@ -0,0 +1,297 @@ +# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false, reportAttributeAccessIssue=false, reportUnusedVariable=false +"""Tests for the auto_eq decorator.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from forging_blocks.foundation.autoeq.auto_eq import auto_eq + + +@pytest.mark.unit +class TestAutoEqDecorator: + """Tests for the auto_eq decorator public API.""" + + def test_when_used_without_parentheses_then_decorates_class(self) -> None: + @auto_eq + @dataclass + class MyClass: + x: int + + decorated = auto_eq(MyClass) + assert decorated is MyClass + + def test_when_used_with_empty_parentheses_then_returns_decorator(self) -> None: + @dataclass + class MyClass: + x: int + + decorator = auto_eq() + decorated = decorator(MyClass) + assert decorated is MyClass + + def test_when_used_with_fields_then_returns_decorator(self) -> None: + @dataclass + class MyClass: + x: int + y: int + + decorator = auto_eq(fields=["x"]) + decorated = decorator(MyClass) + assert decorated is MyClass + + def test_when_class_already_decorated_then_replaces_eq(self) -> None: + @auto_eq + @dataclass + class MyClass: + x: int + + original_eq = MyClass.__eq__ + + redecorated = auto_eq(MyClass) + assert redecorated is MyClass + assert redecorated.__eq__ is not original_eq + + def test_when_equal_values_then_objects_are_equal(self) -> None: + @auto_eq + @dataclass + class Point: + x: int + y: int + + p1 = Point(1, 2) + p2 = Point(1, 2) + + assert p1 == p2 + + def test_when_different_values_then_objects_are_not_equal(self) -> None: + @auto_eq + @dataclass + class Point: + x: int + y: int + + p1 = Point(1, 2) + p2 = Point(3, 4) + + assert p1 != p2 + + def test_when_fields_subset_then_only_selected_fields_compared(self) -> None: + @auto_eq(fields=["x"]) + @dataclass + class Point: + x: int + y: int + + p1 = Point(1, 2) + p2 = Point(1, 999) + + assert p1 == p2 + + def test_when_fields_subset_different_then_not_equal(self) -> None: + @auto_eq(fields=["x"]) + @dataclass + class Point: + x: int + y: int + + p1 = Point(1, 2) + p2 = Point(2, 999) + + assert p1 != p2 + + def test_when_none_field_value_then_eq_does_not_raise(self) -> None: + @auto_eq + @dataclass + class OptionalThing: + name: str | None + + t1 = OptionalThing(None) + t2 = OptionalThing(None) + assert t1 == t2 + + t3 = OptionalThing("hello") + assert t1 != t3 + + def test_when_different_type_then_not_equal(self) -> None: + @auto_eq + @dataclass + class A: + x: int + + @auto_eq + @dataclass + class B: + x: int + + assert A(1) != B(1) + + def test_when_subclass_same_fields_then_not_equal(self) -> None: + @auto_eq + @dataclass + class Parent: + x: int + + @auto_eq + @dataclass + class Child(Parent): + pass + + assert Parent(1) != Child(1) + + def test_when_different_type_unrelated_then_not_equal(self) -> None: + @auto_eq + @dataclass + class Point: + x: int + + p = Point(1) + assert p != "not a point" + assert p != 1 + assert p != None # noqa: E711 + + def test_when_single_field_then_eq_compares_fields_individually(self) -> None: + @auto_eq + @dataclass + class Single: + value: str + + s1 = Single("hello") + s2 = Single("hello") + s3 = Single("world") + + assert s1 == s2 + assert s1 != s3 + + def test_when_dataclass_with_default_then_default_included_in_eq(self) -> None: + @auto_eq + @dataclass + class WithDefault: + x: int + y: int = 42 + + w1 = WithDefault(1) + w2 = WithDefault(1) + + assert w1 == w2 + + def test_when_auto_hash_also_applied_then_eq_still_works(self) -> None: + from forging_blocks.foundation.autohash.auto_hash import auto_hash + + @auto_hash + @auto_eq + @dataclass + class Money: + amount: int + currency: str + + m1 = Money(100, "USD") + m2 = Money(100, "USD") + m3 = Money(200, "USD") + + assert m1 == m2 + assert m1 != m3 + + def test_when_auto_eq_alone_then_no_hash_generated(self) -> None: + @auto_eq + @dataclass + class Point: + x: int + + assert Point.__auto_eq_fields__ == ("x",) + assert Point.__dict__["__eq__"].__name__ == "__eq__" + + def test_marker_fields_set_on_class(self) -> None: + @auto_eq + @dataclass + class Point: + x: int + y: int + + assert Point.__auto_eq_fields__ == ("x", "y") + + def test_marker_fields_when_subset_selected(self) -> None: + @auto_eq(fields=["x"]) + @dataclass + class Point: + x: int + y: int + + assert Point.__auto_eq_fields__ == ("x",) + + def test_when_slots_class_then_uses_slots(self) -> None: + @auto_eq + class Slotted: + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + + assert Slotted(1, 2) == Slotted(1, 2) + assert Slotted(1, 2) != Slotted(3, 4) + + def test_when_slots_inherited_then_collects_all(self) -> None: + @auto_eq + class Base: + __slots__ = ("x",) + + def __init__(self, x: int) -> None: + self.x = x + + class Child(Base): + __slots__ = ("y",) + + def __init__(self, x: int, y: int) -> None: + super().__init__(x) + self.y = y + + assert Child(1, 2) == Child(1, 2) + assert Child(1, 2) != Child(3, 4) + + def test_when_annotations_only_then_uses_annotations(self) -> None: + @auto_eq + class Annotated: + x: int + y: int + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + + assert Annotated(1, 2) == Annotated(1, 2) + assert Annotated(1, 2) != Annotated(3, 4) + + def test_when_no_fields_detectable_then_raises_type_error(self) -> None: + with pytest.raises(TypeError, match="Cannot determine eq fields"): + + @auto_eq + class Empty: + pass + + def test_when_explicit_fields_on_empty_class_then_succeeds(self) -> None: + @auto_eq(fields=["x", "y"]) + class Explicit: + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + + assert Explicit(1, 2) == Explicit(1, 2) + assert Explicit(1, 2) != Explicit(3, 4) + + def test_when_eq_comparison_uses_getattr_not_dict(self) -> None: + """Explicit fields path uses getattr, supporting properties and descriptors.""" + + @auto_eq(fields=["_value"]) + @dataclass + class Point: + _value: int + + @property + def value(self) -> int: + return self._value + + assert Point(1) == Point(1) + assert Point(1) != Point(2) diff --git a/tests/forging_blocks/foundation/autohash/test_auto_hash.py b/tests/forging_blocks/foundation/autohash/test_auto_hash.py index c3b16d5d..34cfcc64 100644 --- a/tests/forging_blocks/foundation/autohash/test_auto_hash.py +++ b/tests/forging_blocks/foundation/autohash/test_auto_hash.py @@ -7,6 +7,7 @@ import pytest +from forging_blocks.foundation.autoeq.auto_eq import auto_eq from forging_blocks.foundation.autofreeze.auto_freeze import auto_freeze from forging_blocks.foundation.autohash.auto_hash import auto_hash from forging_blocks.foundation.errors import CantModifyImmutableAttributeError @@ -59,8 +60,8 @@ class MyClass: assert redecorated is MyClass assert redecorated.__hash__ is not original_hash - def test_when_dataclass_all_fields_then_equal_objects_have_equal_hash(self) -> None: @auto_hash + @auto_eq @dataclass class Point: x: int @@ -131,6 +132,7 @@ class WithDefault: def test_when_dataclass_frozen_by_auto_freeze_then_hash_still_works(self) -> None: @auto_freeze @auto_hash + @auto_eq @dataclass class Money: amount: int From d46c147d3768ed5fdc8fedb70f501cb29ec6a5ca Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 13:36:59 -0300 Subject: [PATCH 33/86] refactor(foundation): integrate auto_eq, replace type: ignore with cast() value_object.py now applies auto_hash and auto_eq independently. All remaining # type: ignore comments replaced with cast() calls for strict pyright compliance. --- src/forging_blocks/foundation/__init__.py | 2 ++ .../foundation/messages/decorators.py | 7 ------ .../foundation/meta/final_meta.py | 8 +++---- src/forging_blocks/foundation/result/err.py | 4 ++-- src/forging_blocks/foundation/result/ok.py | 4 ++-- src/forging_blocks/foundation/value_object.py | 24 +++++++++++++------ 6 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/forging_blocks/foundation/__init__.py b/src/forging_blocks/foundation/__init__.py index 32240817..7d097dad 100644 --- a/src/forging_blocks/foundation/__init__.py +++ b/src/forging_blocks/foundation/__init__.py @@ -1,5 +1,6 @@ """ForgingBlocks foundation modules.""" +from .autoeq import auto_eq from .autohash import auto_hash from .context import AuthorizationContext, ServiceContext, TransactionContext from .errors import ( @@ -43,6 +44,7 @@ from .value_object import ValueObject __all__ = [ + "auto_eq", "auto_hash", "ArchitectureError", "AndSpecification", diff --git a/src/forging_blocks/foundation/messages/decorators.py b/src/forging_blocks/foundation/messages/decorators.py index 37a3b046..fc2c2c8e 100644 --- a/src/forging_blocks/foundation/messages/decorators.py +++ b/src/forging_blocks/foundation/messages/decorators.py @@ -133,18 +133,11 @@ def from_payload_fields( ) return cls(metadata=metadata, **data) - # Pyright cannot verify attribute assignment on a closed generic class. - # Assign through a cast(Any, dc_cls) boundary so pyright permits the - # monkey-patching while ruff's B009 (prefer direct assignment) is satisfied. - # The result is validated against _PatchedMessage below. patched = cast(Any, dc_cls) patched.__init__ = new_init patched.get_payload_fields = get_payload_fields patched.from_payload_fields = from_payload_fields - # Patch abstract members so decorated subclasses of Event/Command/Query - # can be instantiated without manually implementing _payload / value - # or from_payload_fields. abstract_methods: frozenset[str] = getattr(dc_cls, "__abstractmethods__", frozenset()) if "_payload" in abstract_methods: patched._payload = property(lambda self: self.get_payload_fields()) diff --git a/src/forging_blocks/foundation/meta/final_meta.py b/src/forging_blocks/foundation/meta/final_meta.py index de2c8706..595dedc2 100644 --- a/src/forging_blocks/foundation/meta/final_meta.py +++ b/src/forging_blocks/foundation/meta/final_meta.py @@ -6,7 +6,7 @@ """ from collections.abc import Callable -from typing import Any +from typing import Any, cast def _unwrap_descriptor(attr_value: object) -> object: @@ -58,7 +58,7 @@ def __new__( ) -> type: """Prevent overriding of runtime-final methods in subclasses.""" validate_no_runtime_final_override(name, bases, namespace) - return type.__new__(mcls, name, bases, namespace, **kwargs) # type: ignore[no-any-return] + return cast(type, type.__new__(mcls, name, bases, namespace, **kwargs)) type AnyCallable = Callable[..., Any] @@ -69,6 +69,6 @@ def runtime_final[F: AnyCallable](func: F) -> F: Adds both static (`__final__`) and runtime (`__is_runtime_final__`) flags. """ - func.__final__ = True # type: ignore[attr-defined] - func.__is_runtime_final__ = True # type: ignore[attr-defined] + object.__setattr__(func, "__final__", True) + object.__setattr__(func, "__is_runtime_final__", True) return func diff --git a/src/forging_blocks/foundation/result/err.py b/src/forging_blocks/foundation/result/err.py index 6bc2a9c4..e2a4a381 100644 --- a/src/forging_blocks/foundation/result/err.py +++ b/src/forging_blocks/foundation/result/err.py @@ -7,7 +7,7 @@ """ from collections.abc import Callable -from typing import TypeVar +from typing import TypeVar, cast from forging_blocks.foundation.errors import ResultAccessError @@ -37,7 +37,7 @@ def __eq__(self, other: object) -> bool: """Two Errs are equal when their wrapped errors are equal.""" if not isinstance(other, Err): return False - other_err: ErrType = other # type: ignore[assignment] + other_err = cast(Err[object, object], other) return self._error == other_err._error def __hash__(self) -> int: diff --git a/src/forging_blocks/foundation/result/ok.py b/src/forging_blocks/foundation/result/ok.py index 1b78b357..10ad8674 100644 --- a/src/forging_blocks/foundation/result/ok.py +++ b/src/forging_blocks/foundation/result/ok.py @@ -6,7 +6,7 @@ """ from collections.abc import Callable -from typing import TypeVar +from typing import TypeVar, cast from forging_blocks.foundation.errors import ResultAccessError @@ -36,7 +36,7 @@ def __eq__(self, other: object) -> bool: """Two Oks are equal when their wrapped values are equal.""" if not isinstance(other, Ok): return False - other_ok: OkType = other # type: ignore[assignment] + other_ok = cast(Ok[object, object], other) return self._value == other_ok._value def __hash__(self) -> int: diff --git a/src/forging_blocks/foundation/value_object.py b/src/forging_blocks/foundation/value_object.py index 27c58fb0..4361692d 100644 --- a/src/forging_blocks/foundation/value_object.py +++ b/src/forging_blocks/foundation/value_object.py @@ -8,6 +8,7 @@ from abc import ABC, abstractmethod from typing import Any +from forging_blocks.foundation.autoeq import auto_eq from forging_blocks.foundation.autofreeze import auto_freeze from forging_blocks.foundation.autohash import auto_hash @@ -19,11 +20,17 @@ class ValueObject[RawValueType](ABC): rather than by an identity. Two value objects with the same attributes are considered equal. - Concrete subclasses are automatically frozen and hashable via - :func:`auto_freeze` and :func:`auto_hash`, which generate - ``__hash__`` / ``__eq__`` from slot fields and enforce - immutability. Intermediate abstract classes are skipped so leaf - subclasses finish their own ``__init__`` without restriction. + Concrete subclasses are automatically frozen, hashable, and structurally + comparable via :func:`auto_freeze`, :func:`auto_hash`, and + :func:`auto_eq`. The three decorators are independent — each applies + exactly one concern: + + - ``@auto_freeze`` enforces immutability. + - ``@auto_hash`` generates ``__hash__`` from class fields. + - ``@auto_eq`` generates ``__eq__`` from class fields. + + Intermediate abstract classes are skipped so leaf subclasses finish + their own ``__init__`` without restriction. Example: ```python @@ -44,15 +51,18 @@ def value(self) -> str: """ def __init_subclass__(cls, **kwargs: Any) -> None: - """Apply ``auto_freeze`` and ``auto_hash`` to concrete subclasses. + """Apply ``auto_freeze``, ``auto_hash``, and ``auto_eq`` to concrete subclasses. ``auto_freeze`` enforces immutability; ``auto_hash`` generates - ``__hash__`` / ``__eq__`` from class fields. + ``__hash__`` from class fields; ``auto_eq`` generates ``__eq__`` + from class fields. All three are independent decorators — none + composes the others. """ super().__init_subclass__(**kwargs) if not inspect.isabstract(cls): auto_freeze(cls) auto_hash(cls) + auto_eq(cls) def __str__(self) -> str: field_names = getattr(self, "__auto_hash_fields__", ()) From 2f9435b964f92bbffef7d2c0dcf970bf708ab2d2 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 13:37:55 -0300 Subject: [PATCH 34/86] test: remove type: ignore, use cast() for strict pyright compliance --- .../foundation/errors/test_core.py | 2 +- .../foundation/errors/test_field_errors.py | 4 +- .../foundation/messages/test_command.py | 4 +- .../foundation/messages/test_decorators.py | 10 +++- .../foundation/messages/test_event.py | 4 +- .../foundation/messages/test_metadata.py | 2 +- .../foundation/meta/test_final_abc_meta.py | 7 ++- .../foundation/meta/test_final_meta.py | 55 ++++++++++++------- .../foundation/test_identified.py | 2 +- .../foundation/test_value_objects.py | 4 +- 10 files changed, 59 insertions(+), 35 deletions(-) diff --git a/tests/forging_blocks/foundation/errors/test_core.py b/tests/forging_blocks/foundation/errors/test_core.py index ed5fe0cc..4998fc4c 100644 --- a/tests/forging_blocks/foundation/errors/test_core.py +++ b/tests/forging_blocks/foundation/errors/test_core.py @@ -20,7 +20,7 @@ def test_context_when_reassigning_then_raises_attribute_error(self) -> None: metadata = ErrorMetadata(context={"key": "value"}) with pytest.raises(AttributeError): - metadata.context = {"new_key": "new_value"} # type: ignore[misc] + metadata.context = {"new_key": "new_value"} def test_context_when_mutating_dictionary_then_succeeds(self) -> None: metadata = ErrorMetadata(context={"key": "value"}) diff --git a/tests/forging_blocks/foundation/errors/test_field_errors.py b/tests/forging_blocks/foundation/errors/test_field_errors.py index b1749e28..74ba1d99 100644 --- a/tests/forging_blocks/foundation/errors/test_field_errors.py +++ b/tests/forging_blocks/foundation/errors/test_field_errors.py @@ -1,4 +1,6 @@ # pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false +from typing import cast + import pytest from forging_blocks.foundation import Error, ErrorMessage, FieldErrors, FieldReference @@ -16,7 +18,7 @@ def test__init__when_errors_but_no_field_then_raise_value_error(self) -> None: error_message = ErrorMessage("An error occurred") with pytest.raises(ValueError): - FieldErrors(field=None, errors=[error_message]) # type: ignore + FieldErrors(field=cast(FieldReference, None), errors=[error_message]) def test_errors_when_errors_defined_then_returns_errors(self) -> None: error_message = ErrorMessage("An error occurred") diff --git a/tests/forging_blocks/foundation/messages/test_command.py b/tests/forging_blocks/foundation/messages/test_command.py index 2d999e50..cb8b14e2 100644 --- a/tests/forging_blocks/foundation/messages/test_command.py +++ b/tests/forging_blocks/foundation/messages/test_command.py @@ -4,7 +4,7 @@ """ # pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false -from typing import Any, Self +from typing import Any, Self, cast import pytest @@ -92,4 +92,4 @@ def test_domain_semantics_when_command_created_then_uses_imperative_naming(self) def test_constructuor_when_payload_not_implemented_then_raises_type_error(self): with pytest.raises(TypeError): - PayloadAndValueNotImplCommand() # type: ignore + cast(type[object], PayloadAndValueNotImplCommand)() diff --git a/tests/forging_blocks/foundation/messages/test_decorators.py b/tests/forging_blocks/foundation/messages/test_decorators.py index d0cf796c..cbec8298 100644 --- a/tests/forging_blocks/foundation/messages/test_decorators.py +++ b/tests/forging_blocks/foundation/messages/test_decorators.py @@ -1,7 +1,13 @@ +# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false + """Unit tests for message dataclass decorators.""" +from typing import cast + import pytest +from forging_blocks.foundation.messages.message import Message + @pytest.mark.unit class TestMessageDataclassDecorator: @@ -13,7 +19,7 @@ def test_message_dataclass_when_patched_message_check_fails_then_type_error( from unittest.mock import patch from forging_blocks.foundation.messages.decorators import ( - _PatchedMessage, # pyright: ignore[reportPrivateUsage] + _PatchedMessage, message_dataclass, ) @@ -32,7 +38,7 @@ def _selective_isinstance(obj: object, cls: type) -> bool: side_effect=_selective_isinstance, ): with pytest.raises(TypeError, match="does not satisfy _PatchedMessage"): - message_dataclass(NotAMessage) # type: ignore[reportArgumentType] + message_dataclass(cast(type[Message[object]], NotAMessage)) def test_decorated_messages_preserve_message_equality_by_id(self) -> None: """Two decorated messages with the same message_id are equal, regardless of field data.""" diff --git a/tests/forging_blocks/foundation/messages/test_event.py b/tests/forging_blocks/foundation/messages/test_event.py index 32a1ca2a..558ecc3e 100644 --- a/tests/forging_blocks/foundation/messages/test_event.py +++ b/tests/forging_blocks/foundation/messages/test_event.py @@ -1,5 +1,5 @@ # pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false -from typing import Any, Self +from typing import Any, Self, cast import pytest @@ -78,4 +78,4 @@ def test_occured_at_when_called_then_returns_created_at(self): def test_constructor_when_not_implemented_then_raises_type_error(self): with pytest.raises(TypeError): - _ = PayloadNotImplementEvent() # type: ignore + _ = cast(type[object], PayloadNotImplementEvent)() diff --git a/tests/forging_blocks/foundation/messages/test_metadata.py b/tests/forging_blocks/foundation/messages/test_metadata.py index 8e1d33ce..2914c6e6 100644 --- a/tests/forging_blocks/foundation/messages/test_metadata.py +++ b/tests/forging_blocks/foundation/messages/test_metadata.py @@ -214,4 +214,4 @@ def test_modification_when_setting_attribute_then_raises(self) -> None: metadata = MessageMetadata(message_type=self.message_type) with pytest.raises(CantModifyImmutableAttributeError): - metadata._message_id = uuid7() # type: ignore[reportAttributeAccessIssue] + metadata._message_id = uuid7() diff --git a/tests/forging_blocks/foundation/meta/test_final_abc_meta.py b/tests/forging_blocks/foundation/meta/test_final_abc_meta.py index ae584615..25c8cb05 100644 --- a/tests/forging_blocks/foundation/meta/test_final_abc_meta.py +++ b/tests/forging_blocks/foundation/meta/test_final_abc_meta.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import abstractmethod +from typing import cast import pytest @@ -114,7 +115,7 @@ class Incomplete(AbstractBase): class TestFinalABCMetaAbstractEnforcement: def test_instantiating_base_with_abstract_method_raises_type_error(self) -> None: with pytest.raises(TypeError): - AbstractBase() # type: ignore[abstract] + cast(type[object], AbstractBase)() def test_concrete_child_instantiates(self) -> None: instance = ConcreteChild() @@ -127,7 +128,7 @@ def test_child_providing_abstract_method_instantiates(self) -> None: def test_inheriting_abstract_without_implementation_raises_type_error(self) -> None: with pytest.raises(TypeError): - Incomplete() # type: ignore[abstract] + cast(type[object], Incomplete)() @pytest.mark.unit @@ -213,7 +214,7 @@ def test_instantiating_class_with_only_sealed_succeeds(self) -> None: def test_instantiating_class_with_only_abstract_fails_without_implementation(self) -> None: with pytest.raises(TypeError): - IncompleteOnly() # type: ignore[abstract] + cast(type[object], IncompleteOnly)() def test_plain_base_with_no_abstract_or_sealed_instantiates(self) -> None: instance = PlainBase() diff --git a/tests/forging_blocks/foundation/meta/test_final_meta.py b/tests/forging_blocks/foundation/meta/test_final_meta.py index a28873d1..9a46e4b0 100644 --- a/tests/forging_blocks/foundation/meta/test_final_meta.py +++ b/tests/forging_blocks/foundation/meta/test_final_meta.py @@ -3,6 +3,8 @@ from __future__ import annotations +from typing import cast + import pytest from forging_blocks.foundation import FinalMeta, runtime_final @@ -154,17 +156,20 @@ def test___new___when_final_method_not_in_namespace_then_creates_class( @pytest.mark.unit class TestValidateNoRuntimeFinalOverride: def test_when_no_bases_then_returns_none(self) -> None: - assert validate_no_runtime_final_override("MyClass", (), {"x": 1}) is None # type: ignore[func-returns-value] + assert cast(object, validate_no_runtime_final_override("MyClass", (), {"x": 1})) is None def test_when_base_has_final_method_not_overridden_then_returns_none( self, ) -> None: assert ( - validate_no_runtime_final_override( - "Child", (HelperBaseWithFinal,), {"other_method": lambda: None} + cast( + object, + validate_no_runtime_final_override( + "Child", (HelperBaseWithFinal,), {"other_method": lambda: None} + ), ) is None - ) # type: ignore[func-returns-value] + ) def test_when_base_has_final_method_overridden_then_raises_type_error( self, @@ -178,11 +183,16 @@ def test_when_base_has_final_method_overridden_then_raises_type_error( def test_when_base_has_no_final_methods_then_returns_none(self) -> None: assert ( - validate_no_runtime_final_override( - "Child", (HelperBasePlain,), {"normal_method": lambda self: "overridden"} + cast( + object, + validate_no_runtime_final_override( + "Child", + (HelperBasePlain,), + {"normal_method": lambda self: "overridden"}, + ), ) is None - ) # type: ignore[func-returns-value] + ) def test_error_message_contains_subclass_name(self) -> None: with pytest.raises(TypeError, match="in subclass 'BadChild'"): @@ -216,13 +226,16 @@ def test_when_namespace_has_new_method_not_in_bases_then_returns_none( self, ) -> None: assert ( - validate_no_runtime_final_override( - "Child", - (HelperBaseWithFinal,), - {"brand_new_method": lambda self: 42}, + cast( + object, + validate_no_runtime_final_override( + "Child", + (HelperBaseWithFinal,), + {"brand_new_method": lambda self: 42}, + ), ) is None - ) # type: ignore[func-returns-value] + ) @pytest.mark.unit @@ -271,11 +284,12 @@ def test_runtime_final_when_applied_to_method_then_preserves_functionality( def test_runtime_final_when_applied_to_classmethod_then_sets_attributes( self, ) -> None: - @classmethod # type: ignore[misc] - def sample_classmethod(cls) -> str: - return "test" + class _: + @classmethod + def sample_classmethod(cls) -> str: + return "test" - decorated = runtime_final(sample_classmethod) + decorated = runtime_final(_.__dict__["sample_classmethod"]) assert hasattr(decorated, "__is_runtime_final__") assert decorated.__is_runtime_final__ is True @@ -283,11 +297,12 @@ def sample_classmethod(cls) -> str: def test_runtime_final_when_applied_to_staticmethod_then_sets_attributes( self, ) -> None: - @staticmethod # type: ignore[misc] - def sample_staticmethod() -> str: - return "test" + class _: + @staticmethod + def sample_staticmethod() -> str: + return "test" - decorated = runtime_final(sample_staticmethod) + decorated = runtime_final(_.__dict__["sample_staticmethod"]) assert hasattr(decorated, "__is_runtime_final__") assert decorated.__is_runtime_final__ is True diff --git a/tests/forging_blocks/foundation/test_identified.py b/tests/forging_blocks/foundation/test_identified.py index 634a0d9e..22c6a51f 100644 --- a/tests/forging_blocks/foundation/test_identified.py +++ b/tests/forging_blocks/foundation/test_identified.py @@ -29,7 +29,7 @@ class WithIdAsPlainAttribute: """Class with id as a plain attribute, not a property.""" def __init__(self, entity_id: str) -> None: - self.id = entity_id # type: ignore[assignment] + object.__setattr__(self, "id", entity_id) @pytest.mark.unit diff --git a/tests/forging_blocks/foundation/test_value_objects.py b/tests/forging_blocks/foundation/test_value_objects.py index 18a5b31a..8a10e8ba 100644 --- a/tests/forging_blocks/foundation/test_value_objects.py +++ b/tests/forging_blocks/foundation/test_value_objects.py @@ -78,7 +78,7 @@ def test___setattr___when_object_is_frozen_then_raises_cant_modify_immutable_err ) -> None: email = Email("a@example.com") with pytest.raises(CantModifyImmutableAttributeError): - email._value = "b@example.com" # type: ignore + email._value = "b@example.com" def test___eq___when_values_are_equal_then_returns_true(self) -> None: e1 = Email("a@example.com") @@ -178,4 +178,4 @@ def test___setattr___when_multi_component_is_frozen_then_raises_cant_modify_immu vo = MultiComponentVO("hello", "world") with pytest.raises(CantModifyImmutableAttributeError): - vo._first = "changed" # type: ignore + vo._first = "changed" From 3d12c212a85d4f0038505bcbe4bc0923e0dbf4e7 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 13:50:54 -0300 Subject: [PATCH 35/86] docs(autohash): standardize docstrings to match auto_freeze style - Add 'Useful for:' section and restructured examples in module docstring - Add 'Can be used as' line, class_ None detail, and Raises: to function docstring --- .../foundation/autohash/auto_hash.py | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/src/forging_blocks/foundation/autohash/auto_hash.py b/src/forging_blocks/foundation/autohash/auto_hash.py index 718b3999..e8254720 100644 --- a/src/forging_blocks/foundation/autohash/auto_hash.py +++ b/src/forging_blocks/foundation/autohash/auto_hash.py @@ -1,42 +1,46 @@ """Auto-hash decorator for generating ``__hash__`` on class instances. Provides the :func:`auto_hash` decorator that generates ``__hash__`` -based on class fields. Works with ``@dataclass`` (``@auto_hash`` must be -the outermost decorator -- apply it *above* ``@dataclass``) and on plain -classes with ``__slots__`` or ``__annotations__``:: - - @auto_hash - @dataclass - class MyType: - user_id: str - name: str | None = None +based on class fields. Works with ``@dataclass`` (``@auto_hash`` must be +the outermost decorator — apply it *above* ``@dataclass``) and on plain +classes with ``__slots__`` or ``__annotations__``. Can be used as ``@auto_hash``, ``@auto_hash()``, or ``@auto_hash(fields=[...])`` to hash only specific attributes. -Does NOT generate ``__eq__`` -- combine with :func:`auto_eq` when structural -equality is needed alongside hashing: +Does NOT generate ``__eq__`` — combine with :func:`auto_eq` when structural +equality is needed alongside hashing. + +Useful for: Value objects, domain events, and any type that requires +consistent hashing for sets or dictionary keys. + +Example: + ```python + from dataclasses import dataclass + from forging_blocks.foundation.autohash import auto_hash + @auto_hash - @auto_eq - @auto_freeze @dataclass - class Money: - amount: int - currency: str + class UserId: + value: str -Example: + + u1 = UserId("abc") + u2 = UserId("abc") + assert hash(u1) == hash(u2) + ``` + + With selective fields and :func:`auto_eq`: ```python from dataclasses import dataclass from forging_blocks.foundation.autoeq import auto_eq - from forging_blocks.foundation.autofreeze import auto_freeze from forging_blocks.foundation.autohash import auto_hash @auto_hash @auto_eq - @auto_freeze @dataclass class Money: amount: int @@ -191,19 +195,26 @@ def auto_hash[T]( ) -> type[T] | Callable[[type[T]], type[T]]: """Generate ``__hash__`` for a class based on its fields. - Generates ``__hash__`` only — does NOT generate ``__eq__``. - Use :func:`auto_eq` for structural equality comparisons. + Can be used as ``@auto_hash``, ``@auto_hash()``, or + ``@auto_hash(fields=[...])``. Generates ``__hash__`` only — does NOT + generate ``__eq__``. Use :func:`auto_eq` for structural equality + comparisons. Args: - class_: The class to decorate. + class_: The target class (when used directly as ``@auto_hash``). + ``None`` when used with parentheses (``@auto_hash()`` or + ``@auto_hash(fields=...)``). fields: Optional sequence of field names to include in the hash. When ``None``, all dataclass fields (or ``__slots__``/ ``__annotations__`` keys) are used. Returns: - The decorated class with ``__hash__`` generated. Equality - (``__eq__``) is not changed — apply ``@auto_eq`` separately - when needed. + The decorated class if *class_* is provided; otherwise a callable + that can be used as a decorator. + + Raises: + TypeError: If no field names can be determined automatically and + *fields* is ``None``. """ decorator = _AutoHashDecorator(fields=fields) From 7ecfcc6083070838052aa7bd788829711f798b44 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 13:50:58 -0300 Subject: [PATCH 36/86] docs(autoeq): standardize docstrings to match auto_freeze style - Add 'Useful for:' section and restructured examples in module docstring - Add 'Can be used as' line and class_ None detail to function docstring - Add missing __init__ and __call__ docstrings to _AutoEqDecorator --- .../foundation/autoeq/auto_eq.py | 88 ++++++++++++++----- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/src/forging_blocks/foundation/autoeq/auto_eq.py b/src/forging_blocks/foundation/autoeq/auto_eq.py index c9c1f6c3..f4a08c1e 100644 --- a/src/forging_blocks/foundation/autoeq/auto_eq.py +++ b/src/forging_blocks/foundation/autoeq/auto_eq.py @@ -2,25 +2,22 @@ Provides the :func:`auto_eq` decorator that generates ``__eq__`` based on class fields. Works with ``@dataclass`` (``@auto_eq`` must be the outermost -decorator) and on plain classes with ``__slots__`` or ``__annotations__``. +decorator — apply it *above* ``@dataclass``) and on plain classes with +``__slots__`` or ``__annotations__``. -Can be used as ``@auto_eq``, ``@auto_eq()``, or ``@auto_eq(fields=[...])`` -to compare only specific attributes. +Can be used as ``@auto_eq``, ``@auto_eq()``, or +``@auto_eq(fields=[...])`` to compare only specific attributes. -Does NOT generate ``__hash__`` — combine with :func:`auto_hash` when -hashability is needed: +Does NOT generate ``__hash__`` — use :func:`auto_hash` when +hashability is required. - @auto_hash - @auto_eq - @auto_freeze - @dataclass - class Money: - amount: int - currency: str +Useful for: Value objects, domain entities, and any type that requires +structural equality comparisons. Example: ```python from dataclasses import dataclass + from forging_blocks.foundation.autoeq import auto_eq @@ -37,6 +34,29 @@ class Point: assert p1 == p2 assert p1 != p3 ``` + + With selective fields and :func:`auto_hash`: + ```python + from dataclasses import dataclass + + from forging_blocks.foundation.autoeq import auto_eq + from forging_blocks.foundation.autohash import auto_hash + + + @auto_hash + @auto_eq + @dataclass + class Money: + amount: int + currency: str + + + m1 = Money(100, "USD") + m2 = Money(100, "USD") + assert m1 == m2 # from @auto_eq + assert hash(m1) == hash(m2) # from @auto_hash + ``` + """ from collections.abc import Callable, Sequence @@ -56,9 +76,28 @@ class _AutoEqDecorator: """ def __init__(self, *, fields: Sequence[str] | None = None) -> None: + """Initialise the decorator with optional field selector. + + Args: + fields: Specific field names to compare in equality. + When ``None``, all dataclass fields (or ``__slots__``/ + ``__annotations__`` keys) are used. + + """ self._fields = fields def __call__[T](self, class_: type[T]) -> type[T]: + """Apply auto-eq behaviour to *class_*. + + Args: + class_: The target class to decorate. + + Returns: + The decorated class with ``__eq__`` generated from its fields. + Hashing (``__hash__``) is NOT generated — use :func:`auto_hash` + separately when hashability is required. + + """ field_names = FieldResolver.resolve(class_, self._fields) _field_names = tuple(field_names) @@ -102,24 +141,31 @@ def auto_eq[T]( ) -> type[T] | Callable[[type[T]], type[T]]: """Generate ``__eq__`` for a class based on its fields. - Does NOT generate ``__hash__`` — combine with :func:`auto_hash` - when hashability is required. + Can be used as ``@auto_eq``, ``@auto_eq()``, or + ``@auto_eq(fields=[...])``. Generates ``__eq__`` only — does NOT + generate ``__hash__``. Use :func:`auto_hash` when hashability + is required. + When the class is a ``@dataclass``, *fields* defaults to all declared dataclass fields (via ``dataclasses.fields``). Otherwise it falls back to ``__slots__`` (across the MRO) or ``__annotations__`` keys. Args: - class_: The target class (when used bare). - fields: Specific attribute names to include in equality. When - ``None`` (the default), all dataclass fields are used. + class_: The target class (when used directly as ``@auto_eq``). + ``None`` when used with parentheses (``@auto_eq()`` or + ``@auto_eq(fields=...)``). + fields: Optional sequence of field names to include in equality. + When ``None``, all dataclass fields (or ``__slots__``/ + ``__annotations__`` keys) are used. Returns: - The decorated class (or a decorator when used parameterised), - with ``__eq__`` generated. + The decorated class if *class_* is provided; otherwise a callable + that can be used as a decorator. Raises: - TypeError: If no field names can be determined and *fields* is - ``None``. + TypeError: If no field names can be determined automatically and + *fields* is ``None``. + """ decorator = _AutoEqDecorator(fields=fields) From bce1a5ec8df30557ee24b60123c2e40a41c7bfb1 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:31 -0300 Subject: [PATCH 37/86] docs(guide): replace RST :func: roles with backtick code spans --- docs/guide/examples.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/examples.md b/docs/guide/examples.md index 993e5a00..28137c4b 100644 --- a/docs/guide/examples.md +++ b/docs/guide/examples.md @@ -145,7 +145,7 @@ class Email(ValueObject[str]): ``` -`ValueObject` uses :func:`auto_freeze` and :func:`auto_hash` under the hood -- +`ValueObject` uses `auto_freeze` and `auto_hash` under the hood -- concrete subclasses are automatically frozen and hashable via ``__init_subclass__``. Two ``Email`` instances with the same value are equal and can be used as dictionary keys or set members. Attempting to From e67cec16f884aafbafa8efcbde2e2547b7759784 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:31 -0300 Subject: [PATCH 38/86] docs(guide): replace RST :func: roles with backtick code spans --- docs/guide/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 01763985..a6848215 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -67,7 +67,7 @@ class Email(ValueObject[str]): ``` `ValueObject` gives you value-based equality, hashability, and immutability -automatically via :func:`auto_hash` and :func:`auto_freeze`, so that you +automatically via `auto_hash` and `auto_freeze`, so that you can focus on the rules of *your* value. --- From d4f99b2ae0a1724770f5cf83e139b8fcc17b8b6c Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:32 -0300 Subject: [PATCH 39/86] docs(reference): replace RST :class: role with backtick code span --- docs/reference/foundation/messages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/foundation/messages.md b/docs/reference/foundation/messages.md index fffd4297..80b859eb 100644 --- a/docs/reference/foundation/messages.md +++ b/docs/reference/foundation/messages.md @@ -2,7 +2,7 @@ Messages are immutable, architecture-neutral data carriers — **Command**, **Event**, and **Query**. -All messages are frozen dataclasses. Their type communicates intent: a Command asks for action, an Event records a fact, a Query requests data. Serialization is handled by codecs in the infrastructure layer (see :class:`~forging_blocks.infrastructure.serialization.DictMessageCodec`). +All messages are frozen dataclasses. Their type communicates intent: a Command asks for action, an Event records a fact, a Query requests data. Serialization is handled by codecs in the infrastructure layer (see `DictMessageCodec`). ## Types From cc2194163b17a316fa44f7ad3825d20c7b9e9a83 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:32 -0300 Subject: [PATCH 40/86] docs(aggregate-root): replace RST :meth: role with backtick code span --- src/forging_blocks/domain/aggregate_root/aggregate_root.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/domain/aggregate_root/aggregate_root.py b/src/forging_blocks/domain/aggregate_root/aggregate_root.py index 679bc253..1f95b0c3 100644 --- a/src/forging_blocks/domain/aggregate_root/aggregate_root.py +++ b/src/forging_blocks/domain/aggregate_root/aggregate_root.py @@ -51,7 +51,7 @@ def reconstitute( """Reconstitute an aggregate from a sequence of stored events. Creates a new aggregate instance identified by *aggregate_id* and - replays each event via :meth:`replay` to restore its state. The + replays each event via `replay` to restore its state. The caller is responsible for providing events in chronological order. Args: From 77b58b33dc03ddb852f14489fdc19ae5b8a77b5a Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:32 -0300 Subject: [PATCH 41/86] docs(permissions): replace RST :class: role with backtick code span --- .../domain/permissions/composite_permission_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/domain/permissions/composite_permission_checker.py b/src/forging_blocks/domain/permissions/composite_permission_checker.py index ce02c4b4..d01bfbeb 100644 --- a/src/forging_blocks/domain/permissions/composite_permission_checker.py +++ b/src/forging_blocks/domain/permissions/composite_permission_checker.py @@ -6,7 +6,7 @@ class CompositePermissionChecker: - """Combines multiple :class:`PermissionChecker` instances with OR logic.""" + """Combines multiple `PermissionChecker` instances with OR logic.""" __match_args__ = ("_checkers",) From 0f098fdf5b8d87b5779e425f40b12e432f039367 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:33 -0300 Subject: [PATCH 42/86] docs(permissions): replace RST :class: role with backtick code span --- .../domain/permissions/resource_permission_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/domain/permissions/resource_permission_checker.py b/src/forging_blocks/domain/permissions/resource_permission_checker.py index a94127e5..0dac2355 100644 --- a/src/forging_blocks/domain/permissions/resource_permission_checker.py +++ b/src/forging_blocks/domain/permissions/resource_permission_checker.py @@ -9,7 +9,7 @@ class ResourcePermissionChecker: Args: resource_permissions: A dictionary mapping resource-type names - (``str``) to the list of :class:`Permission` values allowed + (``str``) to the list of `Permission` values allowed for that resource type. """ From cdbd20653a9467fa2bba1da7deedcd4dc008396b Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:33 -0300 Subject: [PATCH 43/86] docs(permissions): replace RST :class: role with backtick code span --- .../domain/permissions/role_based_permission_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/domain/permissions/role_based_permission_checker.py b/src/forging_blocks/domain/permissions/role_based_permission_checker.py index a1178ec1..14e19f69 100644 --- a/src/forging_blocks/domain/permissions/role_based_permission_checker.py +++ b/src/forging_blocks/domain/permissions/role_based_permission_checker.py @@ -9,7 +9,7 @@ class RoleBasedPermissionChecker: Args: role_permissions: A dictionary mapping role names (``str``) to the list - of :class:`Permission` values that role is allowed. + of `Permission` values that role is allowed. """ From 08d9572c35f353b13e9443776ede092aa818c0f3 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:33 -0300 Subject: [PATCH 44/86] docs(autoeq): replace RST :func: roles with backtick code spans --- src/forging_blocks/foundation/autoeq/auto_eq.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/forging_blocks/foundation/autoeq/auto_eq.py b/src/forging_blocks/foundation/autoeq/auto_eq.py index f4a08c1e..f3046776 100644 --- a/src/forging_blocks/foundation/autoeq/auto_eq.py +++ b/src/forging_blocks/foundation/autoeq/auto_eq.py @@ -1,6 +1,6 @@ """Auto-eq decorator for generating ``__eq__`` on class instances. -Provides the :func:`auto_eq` decorator that generates ``__eq__`` based on +Provides the `auto_eq` decorator that generates ``__eq__`` based on class fields. Works with ``@dataclass`` (``@auto_eq`` must be the outermost decorator — apply it *above* ``@dataclass``) and on plain classes with ``__slots__`` or ``__annotations__``. @@ -8,7 +8,7 @@ class fields. Works with ``@dataclass`` (``@auto_eq`` must be the outermost Can be used as ``@auto_eq``, ``@auto_eq()``, or ``@auto_eq(fields=[...])`` to compare only specific attributes. -Does NOT generate ``__hash__`` — use :func:`auto_hash` when +Does NOT generate ``__hash__`` — use `auto_hash` when hashability is required. Useful for: Value objects, domain entities, and any type that requires @@ -35,7 +35,7 @@ class Point: assert p1 != p3 ``` - With selective fields and :func:`auto_hash`: + With selective fields and `auto_hash`: ```python from dataclasses import dataclass @@ -71,7 +71,7 @@ class _AutoEqDecorator: """Callable class that applies auto-eq behaviour to a target class. Generates ``__eq__`` based on the class's fields. Does NOT generate - ``__hash__`` — use :func:`auto_hash` separately when hashability + ``__hash__`` — use `auto_hash` separately when hashability is required. """ @@ -94,7 +94,7 @@ def __call__[T](self, class_: type[T]) -> type[T]: Returns: The decorated class with ``__eq__`` generated from its fields. - Hashing (``__hash__``) is NOT generated — use :func:`auto_hash` + Hashing (``__hash__``) is NOT generated — use `auto_hash` separately when hashability is required. """ @@ -143,7 +143,7 @@ def auto_eq[T]( Can be used as ``@auto_eq``, ``@auto_eq()``, or ``@auto_eq(fields=[...])``. Generates ``__eq__`` only — does NOT - generate ``__hash__``. Use :func:`auto_hash` when hashability + generate ``__hash__``. Use `auto_hash` when hashability is required. When the class is a ``@dataclass``, *fields* defaults to all declared From 09e6a02bcb1ebeb5b4ac71190be3569ee7f5dbb8 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:33 -0300 Subject: [PATCH 45/86] docs(autofreeze): replace RST :func: role with backtick code span --- src/forging_blocks/foundation/autofreeze/auto_freeze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/foundation/autofreeze/auto_freeze.py b/src/forging_blocks/foundation/autofreeze/auto_freeze.py index 6ac2644a..375da527 100644 --- a/src/forging_blocks/foundation/autofreeze/auto_freeze.py +++ b/src/forging_blocks/foundation/autofreeze/auto_freeze.py @@ -1,6 +1,6 @@ """Auto-freeze decorator for enforcing immutability on class instances. -Provides the :func:`auto_freeze` decorator that automatically freezes instances +Provides the `auto_freeze` decorator that automatically freezes instances after ``__init__`` completes. The decorator injects a frozen state marker and a ``__setattr__`` override to prevent attribute modifications. From b6267e2184aace21f092fc5e47b01ff3760bc2b7 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:34 -0300 Subject: [PATCH 46/86] docs(autofreeze): replace RST :class:/:attr: roles with backtick code spans --- .../foundation/autofreeze/helpers/frozen_state.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/forging_blocks/foundation/autofreeze/helpers/frozen_state.py b/src/forging_blocks/foundation/autofreeze/helpers/frozen_state.py index 0c38967d..5bd0ef97 100644 --- a/src/forging_blocks/foundation/autofreeze/helpers/frozen_state.py +++ b/src/forging_blocks/foundation/autofreeze/helpers/frozen_state.py @@ -40,7 +40,7 @@ class FrozenStateManager: concurrently-live instances, which is acceptable for typical workloads. - All internal dicts are keyed by ``id(instance)`` (an :class:`int`) + All internal dicts are keyed by ``id(instance)`` (an `int`) so that lookups never trigger ``__hash__`` on the instance — the instance may still be inside ``__init__`` and its fields may not be populated yet. @@ -69,7 +69,7 @@ class FrozenStateManager: def _fallback_key(cls, instance: object) -> _RefKey: """Return the stable ``id(instance)`` key used in all tracking dicts. - As a side effect, registers a :class:`weakref.ref` for instances + As a side effect, registers a `weakref.ref` for instances that support weak references so that entries are cleaned up on garbage collection. """ @@ -90,8 +90,8 @@ def _fallback_key(cls, instance: object) -> _RefKey: def _cleanup_fallback(cls, ref: weakref.ReferenceType[object]) -> None: """Remove all per-instance entries from every tracking dict. - Installed as the callback on every :class:`weakref.ref` stored in - :attr:`_refs_by_id`. Called automatically by the runtime when the + Installed as the callback on every `weakref.ref` stored in + `_refs_by_id`. Called automatically by the runtime when the referent is garbage-collected. """ # Find the id that maps to this ref. From 9f89d444ebcd71af87a1f76bf1177496584cc0dd Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:34 -0300 Subject: [PATCH 47/86] docs(autohash): replace RST :func: roles with backtick code spans --- src/forging_blocks/foundation/autohash/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/forging_blocks/foundation/autohash/README.md b/src/forging_blocks/foundation/autohash/README.md index 720de106..90027990 100644 --- a/src/forging_blocks/foundation/autohash/README.md +++ b/src/forging_blocks/foundation/autohash/README.md @@ -2,8 +2,8 @@ Does **not** generate ``__eq__`` or apply ``auto_freeze``. ``__eq__`` in the examples below is provided by ``@dataclass``. Combine with -:func:`~forging_blocks.foundation.autoeq.auto_eq` for explicit equality, -and :func:`~forging_blocks.foundation.autofreeze.auto_freeze` for immutability. +`auto_eq` for explicit equality, +and `auto_freeze` for immutability. ## Usage From 55aace552b0a1707766f1e99f771cc2da156b281 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:34 -0300 Subject: [PATCH 48/86] docs(autohash): replace RST :func: roles with backtick code spans --- src/forging_blocks/foundation/autohash/auto_hash.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/forging_blocks/foundation/autohash/auto_hash.py b/src/forging_blocks/foundation/autohash/auto_hash.py index e8254720..0d5f3a18 100644 --- a/src/forging_blocks/foundation/autohash/auto_hash.py +++ b/src/forging_blocks/foundation/autohash/auto_hash.py @@ -1,6 +1,6 @@ """Auto-hash decorator for generating ``__hash__`` on class instances. -Provides the :func:`auto_hash` decorator that generates ``__hash__`` +Provides the `auto_hash` decorator that generates ``__hash__`` based on class fields. Works with ``@dataclass`` (``@auto_hash`` must be the outermost decorator — apply it *above* ``@dataclass``) and on plain classes with ``__slots__`` or ``__annotations__``. @@ -8,7 +8,7 @@ Can be used as ``@auto_hash``, ``@auto_hash()``, or ``@auto_hash(fields=[...])`` to hash only specific attributes. -Does NOT generate ``__eq__`` — combine with :func:`auto_eq` when structural +Does NOT generate ``__eq__`` — combine with `auto_eq` when structural equality is needed alongside hashing. Useful for: Value objects, domain events, and any type that requires @@ -31,7 +31,7 @@ class UserId: assert hash(u1) == hash(u2) ``` - With selective fields and :func:`auto_eq`: + With selective fields and `auto_eq`: ```python from dataclasses import dataclass @@ -92,7 +92,7 @@ def __call__[T](self, class_: type[T]) -> type[T]: Returns: The decorated class with ``__hash__`` generated from its fields. - Equality (``__eq__``) is NOT generated — use :func:`auto_eq` + Equality (``__eq__``) is NOT generated — use `auto_eq` separately for structural equality comparisons. """ @@ -197,7 +197,7 @@ def auto_hash[T]( Can be used as ``@auto_hash``, ``@auto_hash()``, or ``@auto_hash(fields=[...])``. Generates ``__hash__`` only — does NOT - generate ``__eq__``. Use :func:`auto_eq` for structural equality + generate ``__eq__``. Use `auto_eq` for structural equality comparisons. Args: From 624206f3218b8be54cc2f0739c4724233b921db6 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:35 -0300 Subject: [PATCH 49/86] docs(autohash): replace RST :class: roles with backtick code spans --- .../foundation/autohash/helpers/hashable_converter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/forging_blocks/foundation/autohash/helpers/hashable_converter.py b/src/forging_blocks/foundation/autohash/helpers/hashable_converter.py index 057405ed..3f8c1757 100644 --- a/src/forging_blocks/foundation/autohash/helpers/hashable_converter.py +++ b/src/forging_blocks/foundation/autohash/helpers/hashable_converter.py @@ -19,7 +19,7 @@ class HashableConverter: - ``dict`` → ``frozenset`` of ``(key, hashable_value)`` pairs (recursively) - Already-hashable values (``str``, ``int``, ``None``, ``tuple``, ``frozenset``, etc.) are returned unchanged. - - Everything else raises :class:`NonHashableValueError`. + - Everything else raises `NonHashableValueError`. """ @classmethod @@ -65,9 +65,9 @@ def _convert_list[T](cls, items: list[T]) -> tuple[Hashable, ...]: @classmethod def _convert_dict[K, V](cls, mapping: dict[K, V]) -> frozenset[tuple[K, Hashable]]: - """Convert *mapping* to a :class:`frozenset` of ``(key, hashable_value)`` pairs. + """Convert *mapping* to a `frozenset` of ``(key, hashable_value)`` pairs. - Uses :class:`frozenset` rather than ``tuple(sorted(...))`` because + Uses `frozenset` rather than ``tuple(sorted(...))`` because dict keys must be hashable but are not required to be orderable. """ return frozenset((k, cls.convert(v)) for k, v in mapping.items()) From b74f90d182d908d6fe9dfc2a2c230d4e2b263e85 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:35 -0300 Subject: [PATCH 50/86] docs(errors): replace RST :class: role with backtick code span --- .../foundation/errors/non_hashable_value_error.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/foundation/errors/non_hashable_value_error.py b/src/forging_blocks/foundation/errors/non_hashable_value_error.py index f235398c..ae86820f 100644 --- a/src/forging_blocks/foundation/errors/non_hashable_value_error.py +++ b/src/forging_blocks/foundation/errors/non_hashable_value_error.py @@ -1,6 +1,6 @@ """Error raised when a field value cannot be converted to a hashable equivalent. -Used by :class:`HashableConverter` when a value is neither natively hashable +Used by `HashableConverter` when a value is neither natively hashable nor one of the supported convertible types (``list``, ``dict``). """ From b0b7dfcfa63c4cfd65f6d2e045b57d786bf7e186 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:35 -0300 Subject: [PATCH 51/86] docs(messages): replace RST :class: roles with backtick code spans --- src/forging_blocks/foundation/messages/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/foundation/messages/decorators.py b/src/forging_blocks/foundation/messages/decorators.py index fc2c2c8e..41c45b5c 100644 --- a/src/forging_blocks/foundation/messages/decorators.py +++ b/src/forging_blocks/foundation/messages/decorators.py @@ -73,7 +73,7 @@ def message_dataclass( class so that payload data is automatically derived from its fields. When the decorated class inherits from an abstract base (e.g. - :class:`Event`, :class:`Command`, :class:`Query`), the decorator + `Event`, `Command`, `Query`), the decorator automatically patches ``_payload``, ``value``, and ``from_payload_fields`` — and removes them from ``__abstractmethods__`` — so the concrete subclass is instantiable without manual stubs. From 9c2afa260ac56ce4adbbce5d8a075e91756b0798 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:36 -0300 Subject: [PATCH 52/86] docs(messages): replace RST :func:/:class: roles with backtick code spans --- .../foundation/messages/message/_message.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/forging_blocks/foundation/messages/message/_message.py b/src/forging_blocks/foundation/messages/message/_message.py index 10be8300..4ede3708 100644 --- a/src/forging_blocks/foundation/messages/message/_message.py +++ b/src/forging_blocks/foundation/messages/message/_message.py @@ -22,11 +22,11 @@ class Message[MessageRawType](ABC): Messages are immutable and each instance is unique — equality and hash are determined solely by the ``message_id`` carried in - :class:`MessageMetadata`, enforced via :func:`auto_hash` and - :func:`auto_eq` with ``fields=["message_id"]``. + `MessageMetadata`, enforced via `auto_hash` and + `auto_eq` with ``fields=["message_id"]``. - This class should not be used directly. Import :class:`Event` or - :class:`Command` instead. + This class should not be used directly. Import `Event` or + `Command` instead. """ def __init_subclass__(cls, **kwargs: Any) -> None: From cd67590f4904c68bae34fca2250df7785fa92d3d Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:36 -0300 Subject: [PATCH 53/86] docs(meta): replace RST :func: role with backtick code span --- src/forging_blocks/foundation/meta/final_abc_meta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/foundation/meta/final_abc_meta.py b/src/forging_blocks/foundation/meta/final_abc_meta.py index 709fd5c5..5a03ab9a 100644 --- a/src/forging_blocks/foundation/meta/final_abc_meta.py +++ b/src/forging_blocks/foundation/meta/final_abc_meta.py @@ -14,7 +14,7 @@ class FinalABCMeta(FinalMeta, ABCMeta): """Metaclass combining `FinalMeta` and `ABCMeta`. Enables both abstract base class functionality via `ABCMeta` - and runtime enforcement of methods decorated with :func:`runtime_final`. + and runtime enforcement of methods decorated with `runtime_final`. Usage:: From 7a01257677c95db6e27867b896dc8926dee04c28 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:36 -0300 Subject: [PATCH 54/86] docs(value-object): replace RST :func: roles with backtick code spans --- src/forging_blocks/foundation/value_object.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/forging_blocks/foundation/value_object.py b/src/forging_blocks/foundation/value_object.py index 4361692d..97d1c118 100644 --- a/src/forging_blocks/foundation/value_object.py +++ b/src/forging_blocks/foundation/value_object.py @@ -21,8 +21,8 @@ class ValueObject[RawValueType](ABC): are considered equal. Concrete subclasses are automatically frozen, hashable, and structurally - comparable via :func:`auto_freeze`, :func:`auto_hash`, and - :func:`auto_eq`. The three decorators are independent — each applies + comparable via `auto_freeze`, `auto_hash`, and + `auto_eq`. The three decorators are independent — each applies exactly one concern: - ``@auto_freeze`` enforces immutability. From f2fe12bdf8ee4bf85cee973d9dfce191d43c8b4d Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:00:36 -0300 Subject: [PATCH 55/86] docs(aggregate-repository): replace RST :meth: role with backtick code span --- .../infrastructure/repositories/aggregate_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py index f73afe29..479f4a6d 100644 --- a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py +++ b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py @@ -30,7 +30,7 @@ class AggregateRepository[ bound uses ``Any`` as the second type argument — not because the event type is untyped, but because PEP 695 (and the underlying type system) forbids one TypeVar from appearing inside another TypeVar's - bound. The ``cast`` in :meth:`save` is the explicit, localized bridge + bound. The ``cast`` in `save` is the explicit, localized bridge across this gap. The invariant is enforced by construction. TId: The aggregate identity type, bounded by ``UUID``. """ From cf75e510ce1b95dbfc34866a2609a49d657bd8c8 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:01:12 -0300 Subject: [PATCH 56/86] docs(copilot-instructions): replace RST :func: roles with backtick code spans --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 04d7c43b..b612d44e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -184,7 +184,7 @@ class Email(ValueObject[str]): ``` Concrete ``ValueObject`` subclasses are automatically frozen and hashable via -:func:`auto_freeze` and :func:`auto_hash` (applied through ``__init_subclass__``). +`auto_freeze` and `auto_hash` (applied through ``__init_subclass__``). After ``__init__`` completes, any mutation raises ``CantModifyImmutableAttributeError``. ### Specifications: Composable Predicates From 24c1d7127f47007af059f281958518ab9a78988d Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:14:40 -0300 Subject: [PATCH 57/86] docs(autofreeze): remove domain-specific Entity references from docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto_freeze decorator is a general-purpose utility — it should not reference domain concepts like Entities. Replaced with generic terminology. --- .../foundation/autoeq/auto_eq.py | 24 +------------------ .../foundation/autofreeze/auto_freeze.py | 4 ++-- .../foundation/autohash/auto_hash.py | 24 +------------------ 3 files changed, 4 insertions(+), 48 deletions(-) diff --git a/src/forging_blocks/foundation/autoeq/auto_eq.py b/src/forging_blocks/foundation/autoeq/auto_eq.py index f3046776..01f888ed 100644 --- a/src/forging_blocks/foundation/autoeq/auto_eq.py +++ b/src/forging_blocks/foundation/autoeq/auto_eq.py @@ -11,7 +11,7 @@ class fields. Works with ``@dataclass`` (``@auto_eq`` must be the outermost Does NOT generate ``__hash__`` — use `auto_hash` when hashability is required. -Useful for: Value objects, domain entities, and any type that requires +Useful for: Value objects, plain data classes, and any type that requires structural equality comparisons. Example: @@ -35,28 +35,6 @@ class Point: assert p1 != p3 ``` - With selective fields and `auto_hash`: - ```python - from dataclasses import dataclass - - from forging_blocks.foundation.autoeq import auto_eq - from forging_blocks.foundation.autohash import auto_hash - - - @auto_hash - @auto_eq - @dataclass - class Money: - amount: int - currency: str - - - m1 = Money(100, "USD") - m2 = Money(100, "USD") - assert m1 == m2 # from @auto_eq - assert hash(m1) == hash(m2) # from @auto_hash - ``` - """ from collections.abc import Callable, Sequence diff --git a/src/forging_blocks/foundation/autofreeze/auto_freeze.py b/src/forging_blocks/foundation/autofreeze/auto_freeze.py index 375da527..f654eca8 100644 --- a/src/forging_blocks/foundation/autofreeze/auto_freeze.py +++ b/src/forging_blocks/foundation/autofreeze/auto_freeze.py @@ -7,7 +7,7 @@ Can be used as ``@auto_freeze``, ``@auto_freeze()``, or ``@auto_freeze(attrs=[...])`` for selective freezing. -Useful for: Entities, value objects, and any domain type that +Useful for: Value objects, immutable data types, and any class that should be immutable after construction. Example: @@ -33,7 +33,7 @@ def currency(self) -> str: return self._currency ``` - With selective freezing (e.g., for Entities): + With selective freezing: ```python @auto_freeze(attrs=["_id"]) class User: diff --git a/src/forging_blocks/foundation/autohash/auto_hash.py b/src/forging_blocks/foundation/autohash/auto_hash.py index 0d5f3a18..a6df6bb0 100644 --- a/src/forging_blocks/foundation/autohash/auto_hash.py +++ b/src/forging_blocks/foundation/autohash/auto_hash.py @@ -11,7 +11,7 @@ Does NOT generate ``__eq__`` — combine with `auto_eq` when structural equality is needed alongside hashing. -Useful for: Value objects, domain events, and any type that requires +Useful for: Value objects, hashable data types, and any type that requires consistent hashing for sets or dictionary keys. Example: @@ -31,28 +31,6 @@ class UserId: assert hash(u1) == hash(u2) ``` - With selective fields and `auto_eq`: - ```python - from dataclasses import dataclass - - from forging_blocks.foundation.autoeq import auto_eq - from forging_blocks.foundation.autohash import auto_hash - - - @auto_hash - @auto_eq - @dataclass - class Money: - amount: int - currency: str - - - m1 = Money(100, "USD") - m2 = Money(100, "USD") - assert hash(m1) == hash(m2) - assert m1 == m2 # from @auto_eq - ``` - """ import dataclasses From c099e37fd59f3b8637c397fe4657a8d792a88f57 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:15:37 -0300 Subject: [PATCH 58/86] docs(reference): add auto-generated auto-eq and auto-hash reference pages Generated by scripts/generate_autodoc_pages.py via pre-commit hook when auto_eq.py and auto_hash.py source docstrings were updated. --- docs/reference/foundation/auto-eq.md | 79 ++++++++++++++++++++++++++ docs/reference/foundation/auto-hash.md | 77 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 docs/reference/foundation/auto-eq.md create mode 100644 docs/reference/foundation/auto-hash.md diff --git a/docs/reference/foundation/auto-eq.md b/docs/reference/foundation/auto-eq.md new file mode 100644 index 00000000..30c138d5 --- /dev/null +++ b/docs/reference/foundation/auto-eq.md @@ -0,0 +1,79 @@ +# Auto-Equality (`auto_eq`) + +The `@auto_eq` decorator generates `__eq__` for class instances based on their fields. It does **not** generate `__hash__` — combine with [`@auto_hash`](auto-hash.md) when hashability is required. + +## Usage + +- `@auto_eq` — Equality on all fields +- `@auto_eq()` — Equivalent, explicit parens form +- `@auto_eq(fields=["x", "y"])` — Equality on specific fields only + +After decoration, instances compare equal when all selected fields match. Comparison is type-strict: `type(self) is type(other)` must be true, so subclasses of the same type are not equal to their parent. + +## When to use + +`@auto_eq` is useful when you need structural equality without hashability — for example, mutable objects where hashing would be incorrect. For immutable value types that need both equality and hashing, use [`@auto_hash`](auto-hash.md) or the [`ValueObject`](value-objects.md) base class instead. + +## Generated members + +The decorated class receives: + +- `__eq__` — structural equality on the resolved fields +- `__auto_eq_fields__` — tuple of field names used in equality, available for introspection + +## Examples + +```python +from dataclasses import dataclass +from forging_blocks.foundation import auto_eq + +@auto_eq +@dataclass +class Point: + x: int + y: int + +p1 = Point(1, 2) +p2 = Point(1, 2) +p3 = Point(3, 4) + +assert p1 == p2 +assert p1 != p3 +``` + +With explicit field selection: + +```python +from dataclasses import dataclass +from forging_blocks.foundation import auto_eq + +@auto_eq(fields=["x"]) +@dataclass +class Point: + x: int + y: int + +# y is ignored — only x matters +assert Point(1, 2) == Point(1, 999) +assert Point(1, 2) != Point(2, 999) +``` + +Combined with `@auto_hash` for hashability: + +```python +from dataclasses import dataclass +from forging_blocks.foundation import auto_eq, auto_hash + +@auto_hash +@auto_eq +@dataclass +class Money: + amount: int + currency: str + +m1 = Money(100, "USD") +m2 = Money(100, "USD") +s = {m1, m2} +assert len(s) == 1 # hashing works +assert m1 == m2 # equality works +``` diff --git a/docs/reference/foundation/auto-hash.md b/docs/reference/foundation/auto-hash.md new file mode 100644 index 00000000..30a15871 --- /dev/null +++ b/docs/reference/foundation/auto-hash.md @@ -0,0 +1,77 @@ +# Auto-Hash (`auto_hash`) + +The `@auto_hash` decorator generates `__hash__` for class instances based on their fields. Mutable values (lists, dicts) are automatically converted to hashable equivalents (tuples, frozensets) during hashing. It does **not** generate ``__eq__`` — ``__eq__`` in the examples below is provided by ``@dataclass``. Combine with [`@auto_eq`](auto-eq.md) for explicit equality. + +## Usage + +- `@auto_hash` — Hash on all fields +- `@auto_hash()` — Equivalent, explicit parens form +- `@auto_hash(fields=["x", "y"])` — Hash on specific fields only + +After decoration, equal objects produce equal hashes — suitable for use in sets and dict keys. The decorator raises `NonHashableValueError` at hash time if a field value cannot be converted to a hashable type. + +## When to use + +`@auto_hash` is ideal for value types that need hashing. If you also need equality, use [`@auto_eq`](auto-eq.md) or rely on ``@dataclass`` (which generates ``__eq__`` by default). If you only need immutability without structural comparison, use [`@auto_freeze`](auto-freeze.md). + +## Generated members + +The decorated class receives: + +- `__hash__` — hash computed from the resolved field values, with mutable-to-hashable conversion +- `__auto_hash_fields__` — tuple of field names used in hashing, available for introspection + +## Examples + +```python +from dataclasses import dataclass +from forging_blocks.foundation import auto_hash + +@auto_hash +@dataclass +class Point: + x: int + y: int + +p1 = Point(1, 2) +p2 = Point(1, 2) +p3 = Point(3, 4) + +assert hash(p1) == hash(p2) +assert hash(p1) != hash(p3) +assert p1 == p2 +``` + +With mutable field conversion: + +```python +from dataclasses import dataclass +from forging_blocks.foundation import auto_hash + +@auto_hash +@dataclass +class Tagged: + name: str + tags: list[str] + +a = Tagged("item", ["a", "b"]) +b = Tagged("item", ["a", "b"]) +assert hash(a) == hash(b) # list converted to tuple automatically +``` + +Selective fields: + +```python +from dataclasses import dataclass +from forging_blocks.foundation import auto_hash + +@auto_hash(fields=["id"]) +@dataclass +class Entity: + id: str + name: str + +e1 = Entity("1", "Alice") +e2 = Entity("1", "Bob") +assert hash(e1) == hash(e2) # only id matters +``` From 528b7c50d4e641cfbeef7137b6883378e4a2f218 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:24:20 -0300 Subject: [PATCH 59/86] docs(autofreeze): remove unused CantModifyImmutableAttributeError import from docstring example --- src/forging_blocks/foundation/autofreeze/auto_freeze.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/forging_blocks/foundation/autofreeze/auto_freeze.py b/src/forging_blocks/foundation/autofreeze/auto_freeze.py index f654eca8..442df4bd 100644 --- a/src/forging_blocks/foundation/autofreeze/auto_freeze.py +++ b/src/forging_blocks/foundation/autofreeze/auto_freeze.py @@ -13,7 +13,6 @@ Example: ```python from forging_blocks.foundation.autofreeze import auto_freeze - from forging_blocks.foundation.errors import CantModifyImmutableAttributeError @auto_freeze From a543af202eb7a6468801d36aec705f39db507739 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:29:37 -0300 Subject: [PATCH 60/86] feat(scripts): add docstring import validation to autodoc generator Parses every python code block in module/class/function docstrings, collects imported names, and verifies each appears as a Load-context reference. Fails the build with specific file/line messages when unused imports are found. --- scripts/generate_autodoc_pages.py | 87 ++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/scripts/generate_autodoc_pages.py b/scripts/generate_autodoc_pages.py index 84f7e121..e885524f 100755 --- a/scripts/generate_autodoc_pages.py +++ b/scripts/generate_autodoc_pages.py @@ -7,6 +7,7 @@ from __future__ import annotations +import ast import re import shutil import sys @@ -133,6 +134,77 @@ def update_nav(mkdocs: str, section: str) -> str: return mkdocs.rstrip() + "\n" + section + "\n" +_PYTHON_BLOCK_RE = re.compile(r"```python\n(.*?)```", re.DOTALL) + + +def _find_python_blocks(docstring: str) -> list[str]: + """Extract ```python code blocks from a docstring.""" + return _PYTHON_BLOCK_RE.findall(docstring) + + +def validate_docstring_imports(src_path: Path) -> list[str]: + """Verify that imports in docstring code examples are actually used. + + Parses every ```python block in every module/class/function docstring, + collects imported names, then checks whether each imported name appears + as a ``Load``-context reference (including as the root of an attribute + chain like ``mod.Thing``). + + Returns a list of warning messages, one per unused import. + """ + warnings: list[str] = [] + source = src_path.read_text(encoding="utf-8") + + try: + tree = ast.parse(source) + except SyntaxError: + return warnings + + for node in ast.walk(tree): + if not isinstance(node, ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef): + continue + docstring = ast.get_docstring(node) + if not docstring: + continue + for block in _find_python_blocks(docstring): + try: + block_tree = ast.parse(block) + except SyntaxError: + continue + + imported: dict[str, int] = {} # name -> line in block + for n in ast.walk(block_tree): + match n: + case ast.Import(names=names): + for alias in names: + imported[alias.asname or alias.name] = n.lineno + case ast.ImportFrom(names=names): + for alias in names: + if alias.name == "*": + continue + imported[alias.asname or alias.name] = n.lineno + + used: set[str] = set() + for n in ast.walk(block_tree): + if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load): + used.add(n.id) + elif isinstance(n, ast.Attribute) and isinstance(n.ctx, ast.Load): + root = n + while isinstance(root, ast.Attribute): + root = root.value + if isinstance(root, ast.Name): + used.add(root.id) + + for name, line_no in imported.items(): + if name not in used: + warnings.append( + f"{src_path}: unused import '{name}' " + f"in docstring code example (block line {line_no})" + ) + + return warnings + + def ensure_autodoc_index(out_dir: Path) -> None: index_file = out_dir / "index.md" @@ -159,10 +231,23 @@ def main() -> None: print(f"[ERROR] Source directory not found: {SRC_DIR}") sys.exit(1) + source_files = find_source_files(SRC_DIR) + + all_warnings: list[str] = [] + for src_path in source_files: + all_warnings.extend(validate_docstring_imports(src_path)) + + if all_warnings: + print("\n[DOCSTRING IMPORT WARNINGS]") + for w in all_warnings: + print(f" {w}") + print() + sys.exit(1) + # Clean stale autodoc pages from renamed/deleted modules shutil.rmtree(OUT_DIR, ignore_errors=True) - files = [generate_markdown(p) for p in find_source_files(SRC_DIR)] + files = [generate_markdown(p) for p in source_files] ensure_autodoc_index(OUT_DIR) mkdocs_text = MKDOCS_YML.read_text(encoding="utf-8") From 9d81550bc03dfda7d9e483e52a46369de3994ffc Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:40:16 -0300 Subject: [PATCH 61/86] feat(presentation): remove unused LoggerPort import from middleware docstrings Remove LoggerPort imports from LoggingMiddleware and TimingMiddleware docstring code examples. Only the middleware class itself is needed for the example; LoggerPort was imported but never used. --- src/forging_blocks/presentation/builtin/logging_middleware.py | 1 - src/forging_blocks/presentation/builtin/timing_middleware.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/forging_blocks/presentation/builtin/logging_middleware.py b/src/forging_blocks/presentation/builtin/logging_middleware.py index 3e98151b..2e843bc5 100644 --- a/src/forging_blocks/presentation/builtin/logging_middleware.py +++ b/src/forging_blocks/presentation/builtin/logging_middleware.py @@ -24,7 +24,6 @@ class LoggingMiddleware[RequestType, ResponseType](Middleware[RequestType, Respo Example: ```python - from forging_blocks.application.ports.outbound.logger_port import LoggerPort from forging_blocks.presentation.builtin import LoggingMiddleware mw = LoggingMiddleware[MyRequest, MyResponse](logger=my_logger) diff --git a/src/forging_blocks/presentation/builtin/timing_middleware.py b/src/forging_blocks/presentation/builtin/timing_middleware.py index 3ed57afb..53ad1260 100644 --- a/src/forging_blocks/presentation/builtin/timing_middleware.py +++ b/src/forging_blocks/presentation/builtin/timing_middleware.py @@ -26,7 +26,6 @@ class TimingMiddleware[RequestType, ResponseType](Middleware[RequestType, Respon Example: ```python - from forging_blocks.application.ports.outbound.logger_port import LoggerPort from forging_blocks.presentation.builtin import TimingMiddleware mw = TimingMiddleware[MyRequest, MyResponse](logger=my_logger) From 835b92a2f9edc3866515e62098463da671f03cbf Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 14:40:27 -0300 Subject: [PATCH 62/86] fix(scripts): dedent code blocks when validating docstring imports Docstring code examples retain indentation from their parent docstring, which ast.parse rejects as unexpected indent at module level. Apply textwrap.dedent before parsing each block. Add test_validate_docstring_imports.py with 8 test cases covering: unused imports, all-used, aliases, attribute chains, no docstring, no code block, syntax errors, and multiple docstrings. Clean up test_generate_autodoc_pages.py: remove relocated TestValidateDocstringImports class and unused pytest.mark.unit import. --- scripts/generate_autodoc_pages.py | 3 +- tests/scripts/test_generate_autodoc_pages.py | 1 - .../test_validate_docstring_imports.py | 127 ++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 tests/scripts/test_validate_docstring_imports.py diff --git a/scripts/generate_autodoc_pages.py b/scripts/generate_autodoc_pages.py index e885524f..8e3be97f 100755 --- a/scripts/generate_autodoc_pages.py +++ b/scripts/generate_autodoc_pages.py @@ -11,6 +11,7 @@ import re import shutil import sys +import textwrap from pathlib import Path SRC_DIR = Path("src/forging_blocks") @@ -168,7 +169,7 @@ def validate_docstring_imports(src_path: Path) -> list[str]: continue for block in _find_python_blocks(docstring): try: - block_tree = ast.parse(block) + block_tree = ast.parse(textwrap.dedent(block)) except SyntaxError: continue diff --git a/tests/scripts/test_generate_autodoc_pages.py b/tests/scripts/test_generate_autodoc_pages.py index d68ba5be..11858393 100644 --- a/tests/scripts/test_generate_autodoc_pages.py +++ b/tests/scripts/test_generate_autodoc_pages.py @@ -263,7 +263,6 @@ def test_when_index_exists_then_does_not_modify(self, tmp_path: Path) -> None: assert index_file.read_text() == "existing content" -@pytest.mark.unit class TestMain: def test_when_src_dir_missing_then_exits( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/scripts/test_validate_docstring_imports.py b/tests/scripts/test_validate_docstring_imports.py new file mode 100644 index 00000000..2b1a636e --- /dev/null +++ b/tests/scripts/test_validate_docstring_imports.py @@ -0,0 +1,127 @@ +"""Tests for validate_docstring_imports in the autodoc generator.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from scripts.generate_autodoc_pages import validate_docstring_imports + + +@pytest.mark.unit +class TestValidateDocstringImports: + def test_when_unused_import_then_returns_warning(self, tmp_path: Path) -> None: + src = tmp_path / "mod.py" + src.write_text( + '"""Module docstring.\n' + "\n" + "Example:\n" + " ```python\n" + " from foo import bar\n" + " print(1)\n" + " ```\n" + '"""\n' + ) + result = validate_docstring_imports(src) + assert len(result) == 1 + assert "unused import 'bar'" in result[0] + + def test_when_all_imports_used_then_returns_empty(self, tmp_path: Path) -> None: + src = tmp_path / "mod.py" + src.write_text( + '"""Module docstring.\n' + "\n" + "Example:\n" + " ```python\n" + " from foo import bar\n" + " print(bar)\n" + " ```\n" + '"""\n' + ) + result = validate_docstring_imports(src) + assert result == [] + + def test_when_import_as_alias_used_then_returns_empty(self, tmp_path: Path) -> None: + src = tmp_path / "mod.py" + src.write_text( + '"""Module docstring.\n' + "\n" + "Example:\n" + " ```python\n" + " from foo import bar as baz\n" + " print(baz)\n" + " ```\n" + '"""\n' + ) + result = validate_docstring_imports(src) + assert result == [] + + def test_when_module_import_used_as_attr_then_returns_empty(self, tmp_path: Path) -> None: + src = tmp_path / "mod.py" + src.write_text( + '"""Module docstring.\n' + "\n" + "Example:\n" + " ```python\n" + " import foo\n" + " foo.bar()\n" + " ```\n" + '"""\n' + ) + result = validate_docstring_imports(src) + assert result == [] + + def test_when_no_docstring_then_returns_empty(self, tmp_path: Path) -> None: + src = tmp_path / "mod.py" + src.write_text("x = 1\n") + result = validate_docstring_imports(src) + assert result == [] + + def test_when_no_python_block_then_returns_empty(self, tmp_path: Path) -> None: + src = tmp_path / "mod.py" + src.write_text('"""Has docstring but no code block."""\n') + result = validate_docstring_imports(src) + assert result == [] + + def test_when_syntax_error_in_block_then_skipped(self, tmp_path: Path) -> None: + src = tmp_path / "mod.py" + src.write_text( + '"""Module docstring.\n' + "\n" + "Example:\n" + " ```python\n" + " this is not valid python\n" + " ```\n" + '"""\n' + ) + result = validate_docstring_imports(src) + assert result == [] + + def test_when_multiple_docstrings_then_checks_all(self, tmp_path: Path) -> None: + src = tmp_path / "mod.py" + src.write_text( + '"""Module docstring.\n' + "\n" + "Example:\n" + " ```python\n" + " from foo import bar\n" + " print(1)\n" + " ```\n" + '"""\n' + "\n" + "\n" + "class Foo:\n" + ' """Class docstring.\n' + "\n" + " Example:\n" + " ```python\n" + " from baz import qux\n" + " print(1)\n" + " ```\n" + ' """\n' + " pass\n" + ) + result = validate_docstring_imports(src) + assert len(result) == 2 + assert "unused import 'bar'" in result[0] + assert "unused import 'qux'" in result[1] From e98ee26dd65e50a6dc0b9031ab791201dc0e9f5d Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 16:02:46 -0300 Subject: [PATCH 63/86] refactor(domain): move ValueObject, Messages, and Specifications from foundation to domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move three domain-modeling primitives from the Foundation block to the Domain block where they semantically belong: - ValueObject base class (foundation/value_object.py → domain/value_object.py) - Messages sub-package (foundation/messages/ → domain/messages/) - Specification pattern (foundation/specification/ → domain/specification/) These are core domain modeling concerns — value identity, message-driven behavior, and composable business rules — not general-purpose foundation utilities. No backward compatibility shims; all consumers update imports. --- .../{foundation => domain}/messages.md | 0 docs/reference/foundation/specifications.md | 24 ------ docs/reference/foundation/value-objects.md | 22 ----- .../messages/__init__.py | 0 .../messages/command.py | 0 .../messages/decorators.py | 0 .../{foundation => domain}/messages/event.py | 0 .../messages/message/__init__.py | 0 .../messages/message/_message.py | 0 .../messages/message/_metadata.py | 0 .../{foundation => domain}/messages/query.py | 0 .../specification/__init__.py | 0 .../specification/base.py | 0 .../specification/composable.py | 0 .../specification/expression.py | 0 .../logical_operators/__init__.py | 0 .../logical_operators/and_specification.py | 0 .../logical_operators/not_specification.py | 0 .../logical_operators/or_specification.py | 0 src/forging_blocks/foundation/value_object.py | 80 ------------------- .../messages/__init__.py | 0 .../messages/test_command.py | 0 .../messages/test_decorators.py | 0 .../messages/test_event.py | 0 .../messages/test_message.py | 0 .../messages/test_metadata.py | 0 .../messages/test_serializable.py | 0 .../specification/__init__.py | 0 .../logical_operators/__init__.py | 0 .../test_and_specification.py | 0 .../test_not_specification.py | 0 .../test_or_specification.py | 0 .../specification/test_base_specification.py | 0 .../test_composable_specification.py | 0 .../test_expression_specification.py | 0 .../test_value_objects.py | 0 36 files changed, 126 deletions(-) rename docs/reference/{foundation => domain}/messages.md (100%) delete mode 100644 docs/reference/foundation/specifications.md delete mode 100644 docs/reference/foundation/value-objects.md rename src/forging_blocks/{foundation => domain}/messages/__init__.py (100%) rename src/forging_blocks/{foundation => domain}/messages/command.py (100%) rename src/forging_blocks/{foundation => domain}/messages/decorators.py (100%) rename src/forging_blocks/{foundation => domain}/messages/event.py (100%) rename src/forging_blocks/{foundation => domain}/messages/message/__init__.py (100%) rename src/forging_blocks/{foundation => domain}/messages/message/_message.py (100%) rename src/forging_blocks/{foundation => domain}/messages/message/_metadata.py (100%) rename src/forging_blocks/{foundation => domain}/messages/query.py (100%) rename src/forging_blocks/{foundation => domain}/specification/__init__.py (100%) rename src/forging_blocks/{foundation => domain}/specification/base.py (100%) rename src/forging_blocks/{foundation => domain}/specification/composable.py (100%) rename src/forging_blocks/{foundation => domain}/specification/expression.py (100%) rename src/forging_blocks/{foundation => domain}/specification/logical_operators/__init__.py (100%) rename src/forging_blocks/{foundation => domain}/specification/logical_operators/and_specification.py (100%) rename src/forging_blocks/{foundation => domain}/specification/logical_operators/not_specification.py (100%) rename src/forging_blocks/{foundation => domain}/specification/logical_operators/or_specification.py (100%) delete mode 100644 src/forging_blocks/foundation/value_object.py rename tests/forging_blocks/{foundation => domain}/messages/__init__.py (100%) rename tests/forging_blocks/{foundation => domain}/messages/test_command.py (100%) rename tests/forging_blocks/{foundation => domain}/messages/test_decorators.py (100%) rename tests/forging_blocks/{foundation => domain}/messages/test_event.py (100%) rename tests/forging_blocks/{foundation => domain}/messages/test_message.py (100%) rename tests/forging_blocks/{foundation => domain}/messages/test_metadata.py (100%) rename tests/forging_blocks/{foundation => domain}/messages/test_serializable.py (100%) rename tests/forging_blocks/{foundation => domain}/specification/__init__.py (100%) rename tests/forging_blocks/{foundation => domain}/specification/logical_operators/__init__.py (100%) rename tests/forging_blocks/{foundation => domain}/specification/logical_operators/test_and_specification.py (100%) rename tests/forging_blocks/{foundation => domain}/specification/logical_operators/test_not_specification.py (100%) rename tests/forging_blocks/{foundation => domain}/specification/logical_operators/test_or_specification.py (100%) rename tests/forging_blocks/{foundation => domain}/specification/test_base_specification.py (100%) rename tests/forging_blocks/{foundation => domain}/specification/test_composable_specification.py (100%) rename tests/forging_blocks/{foundation => domain}/specification/test_expression_specification.py (100%) rename tests/forging_blocks/{foundation => domain}/test_value_objects.py (100%) diff --git a/docs/reference/foundation/messages.md b/docs/reference/domain/messages.md similarity index 100% rename from docs/reference/foundation/messages.md rename to docs/reference/domain/messages.md diff --git a/docs/reference/foundation/specifications.md b/docs/reference/foundation/specifications.md deleted file mode 100644 index fc23fbf7..00000000 --- a/docs/reference/foundation/specifications.md +++ /dev/null @@ -1,24 +0,0 @@ -# Specifications - -The **Specification** pattern is a composable predicate over candidate objects. - -## Core contract - -- **Specification** — Abstract base with `is_satisfied_by(candidate) → bool` -- **ComposableSpecification** — Adds `&`, `|`, `~` for fluent logical composition -- **ExpressionSpecification** — Wraps a user-provided callable predicate - -## Logical operators - -- **AndSpecification** — Satisfied when both specs are satisfied (`a & b`) -- **OrSpecification** — Satisfied when at least one spec is satisfied (`a | b`) -- **NotSpecification** — Satisfied when the wrapped spec is not satisfied (`~a`) - -Composed results remain composable. `(a & b) | c` chains naturally. - -## When to use - -Specifications are ideal for querying, validation, and filtering. Define a predicate once, then compose it with others rather than writing nested if-statements. - -!!! note "Related" - See [Domain Specifications](../domain/specifications.md) for domain-level usage patterns. diff --git a/docs/reference/foundation/value-objects.md b/docs/reference/foundation/value-objects.md deleted file mode 100644 index 1e698e47..00000000 --- a/docs/reference/foundation/value-objects.md +++ /dev/null @@ -1,22 +0,0 @@ -# Value Objects - -A **ValueObject** is an immutable object with value-based equality and hashing. - -## Characteristics - -- Immutable after construction -- Equality determined by all field values, not identity -- Hashable — suitable for use as dict keys or set members -- Optionally serializable via `__dict__` without extra ceremony - -## When to use - -Use `ValueObject` when you need immutability, value-based `__eq__`/`__hash__`, and optional `__dict__` serialization in one base class. Useful for any type where identity is irrelevant and field values define equality. - -!!! note "ValueObject vs auto-freeze" - `ValueObject` gives you immutability, value equality, and hashing. - - If you only need immutability and already have your own base class, [auto-freeze](auto-freeze.md) is a lighter alternative. Both are optional. - -!!! note "Related" - The Domain block re-exports `ValueObject`. See [Domain Value Objects](../domain/value-objects.md) for domain-specific guidance. diff --git a/src/forging_blocks/foundation/messages/__init__.py b/src/forging_blocks/domain/messages/__init__.py similarity index 100% rename from src/forging_blocks/foundation/messages/__init__.py rename to src/forging_blocks/domain/messages/__init__.py diff --git a/src/forging_blocks/foundation/messages/command.py b/src/forging_blocks/domain/messages/command.py similarity index 100% rename from src/forging_blocks/foundation/messages/command.py rename to src/forging_blocks/domain/messages/command.py diff --git a/src/forging_blocks/foundation/messages/decorators.py b/src/forging_blocks/domain/messages/decorators.py similarity index 100% rename from src/forging_blocks/foundation/messages/decorators.py rename to src/forging_blocks/domain/messages/decorators.py diff --git a/src/forging_blocks/foundation/messages/event.py b/src/forging_blocks/domain/messages/event.py similarity index 100% rename from src/forging_blocks/foundation/messages/event.py rename to src/forging_blocks/domain/messages/event.py diff --git a/src/forging_blocks/foundation/messages/message/__init__.py b/src/forging_blocks/domain/messages/message/__init__.py similarity index 100% rename from src/forging_blocks/foundation/messages/message/__init__.py rename to src/forging_blocks/domain/messages/message/__init__.py diff --git a/src/forging_blocks/foundation/messages/message/_message.py b/src/forging_blocks/domain/messages/message/_message.py similarity index 100% rename from src/forging_blocks/foundation/messages/message/_message.py rename to src/forging_blocks/domain/messages/message/_message.py diff --git a/src/forging_blocks/foundation/messages/message/_metadata.py b/src/forging_blocks/domain/messages/message/_metadata.py similarity index 100% rename from src/forging_blocks/foundation/messages/message/_metadata.py rename to src/forging_blocks/domain/messages/message/_metadata.py diff --git a/src/forging_blocks/foundation/messages/query.py b/src/forging_blocks/domain/messages/query.py similarity index 100% rename from src/forging_blocks/foundation/messages/query.py rename to src/forging_blocks/domain/messages/query.py diff --git a/src/forging_blocks/foundation/specification/__init__.py b/src/forging_blocks/domain/specification/__init__.py similarity index 100% rename from src/forging_blocks/foundation/specification/__init__.py rename to src/forging_blocks/domain/specification/__init__.py diff --git a/src/forging_blocks/foundation/specification/base.py b/src/forging_blocks/domain/specification/base.py similarity index 100% rename from src/forging_blocks/foundation/specification/base.py rename to src/forging_blocks/domain/specification/base.py diff --git a/src/forging_blocks/foundation/specification/composable.py b/src/forging_blocks/domain/specification/composable.py similarity index 100% rename from src/forging_blocks/foundation/specification/composable.py rename to src/forging_blocks/domain/specification/composable.py diff --git a/src/forging_blocks/foundation/specification/expression.py b/src/forging_blocks/domain/specification/expression.py similarity index 100% rename from src/forging_blocks/foundation/specification/expression.py rename to src/forging_blocks/domain/specification/expression.py diff --git a/src/forging_blocks/foundation/specification/logical_operators/__init__.py b/src/forging_blocks/domain/specification/logical_operators/__init__.py similarity index 100% rename from src/forging_blocks/foundation/specification/logical_operators/__init__.py rename to src/forging_blocks/domain/specification/logical_operators/__init__.py diff --git a/src/forging_blocks/foundation/specification/logical_operators/and_specification.py b/src/forging_blocks/domain/specification/logical_operators/and_specification.py similarity index 100% rename from src/forging_blocks/foundation/specification/logical_operators/and_specification.py rename to src/forging_blocks/domain/specification/logical_operators/and_specification.py diff --git a/src/forging_blocks/foundation/specification/logical_operators/not_specification.py b/src/forging_blocks/domain/specification/logical_operators/not_specification.py similarity index 100% rename from src/forging_blocks/foundation/specification/logical_operators/not_specification.py rename to src/forging_blocks/domain/specification/logical_operators/not_specification.py diff --git a/src/forging_blocks/foundation/specification/logical_operators/or_specification.py b/src/forging_blocks/domain/specification/logical_operators/or_specification.py similarity index 100% rename from src/forging_blocks/foundation/specification/logical_operators/or_specification.py rename to src/forging_blocks/domain/specification/logical_operators/or_specification.py diff --git a/src/forging_blocks/foundation/value_object.py b/src/forging_blocks/foundation/value_object.py deleted file mode 100644 index 97d1c118..00000000 --- a/src/forging_blocks/foundation/value_object.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Domain value objects module. - -This module provides the base ValueObject class for implementing domain value objects -following the principles of Domain-Driven Design (DDD). -""" - -import inspect -from abc import ABC, abstractmethod -from typing import Any - -from forging_blocks.foundation.autoeq import auto_eq -from forging_blocks.foundation.autofreeze import auto_freeze -from forging_blocks.foundation.autohash import auto_hash - - -class ValueObject[RawValueType](ABC): - """Base class for all domain value objects. - - Value objects are immutable objects defined entirely by their attributes - rather than by an identity. Two value objects with the same attributes - are considered equal. - - Concrete subclasses are automatically frozen, hashable, and structurally - comparable via `auto_freeze`, `auto_hash`, and - `auto_eq`. The three decorators are independent — each applies - exactly one concern: - - - ``@auto_freeze`` enforces immutability. - - ``@auto_hash`` generates ``__hash__`` from class fields. - - ``@auto_eq`` generates ``__eq__`` from class fields. - - Intermediate abstract classes are skipped so leaf subclasses finish - their own ``__init__`` without restriction. - - Example: - ```python - class Email(ValueObject[str]): - __slots__ = ("_value",) - - def __init__(self, value: str) -> None: - super().__init__() - if "@" not in value: - raise ValueError("Invalid email format") - self._value = value - - @property - def value(self) -> str: - return self._value - ``` - - """ - - def __init_subclass__(cls, **kwargs: Any) -> None: - """Apply ``auto_freeze``, ``auto_hash``, and ``auto_eq`` to concrete subclasses. - - ``auto_freeze`` enforces immutability; ``auto_hash`` generates - ``__hash__`` from class fields; ``auto_eq`` generates ``__eq__`` - from class fields. All three are independent decorators — none - composes the others. - """ - super().__init_subclass__(**kwargs) - if not inspect.isabstract(cls): - auto_freeze(cls) - auto_hash(cls) - auto_eq(cls) - - def __str__(self) -> str: - field_names = getattr(self, "__auto_hash_fields__", ()) - components = tuple(getattr(self, name) for name in field_names) - if len(components) == 1: - return f"{self.__class__.__name__}({components[0]!r})" - return f"{self.__class__.__name__}{components!r}" - - def __repr__(self) -> str: - return self.__str__() - - @property - @abstractmethod - def value(self) -> RawValueType: - """Return the primary raw value encapsulated by the ValueObject.""" diff --git a/tests/forging_blocks/foundation/messages/__init__.py b/tests/forging_blocks/domain/messages/__init__.py similarity index 100% rename from tests/forging_blocks/foundation/messages/__init__.py rename to tests/forging_blocks/domain/messages/__init__.py diff --git a/tests/forging_blocks/foundation/messages/test_command.py b/tests/forging_blocks/domain/messages/test_command.py similarity index 100% rename from tests/forging_blocks/foundation/messages/test_command.py rename to tests/forging_blocks/domain/messages/test_command.py diff --git a/tests/forging_blocks/foundation/messages/test_decorators.py b/tests/forging_blocks/domain/messages/test_decorators.py similarity index 100% rename from tests/forging_blocks/foundation/messages/test_decorators.py rename to tests/forging_blocks/domain/messages/test_decorators.py diff --git a/tests/forging_blocks/foundation/messages/test_event.py b/tests/forging_blocks/domain/messages/test_event.py similarity index 100% rename from tests/forging_blocks/foundation/messages/test_event.py rename to tests/forging_blocks/domain/messages/test_event.py diff --git a/tests/forging_blocks/foundation/messages/test_message.py b/tests/forging_blocks/domain/messages/test_message.py similarity index 100% rename from tests/forging_blocks/foundation/messages/test_message.py rename to tests/forging_blocks/domain/messages/test_message.py diff --git a/tests/forging_blocks/foundation/messages/test_metadata.py b/tests/forging_blocks/domain/messages/test_metadata.py similarity index 100% rename from tests/forging_blocks/foundation/messages/test_metadata.py rename to tests/forging_blocks/domain/messages/test_metadata.py diff --git a/tests/forging_blocks/foundation/messages/test_serializable.py b/tests/forging_blocks/domain/messages/test_serializable.py similarity index 100% rename from tests/forging_blocks/foundation/messages/test_serializable.py rename to tests/forging_blocks/domain/messages/test_serializable.py diff --git a/tests/forging_blocks/foundation/specification/__init__.py b/tests/forging_blocks/domain/specification/__init__.py similarity index 100% rename from tests/forging_blocks/foundation/specification/__init__.py rename to tests/forging_blocks/domain/specification/__init__.py diff --git a/tests/forging_blocks/foundation/specification/logical_operators/__init__.py b/tests/forging_blocks/domain/specification/logical_operators/__init__.py similarity index 100% rename from tests/forging_blocks/foundation/specification/logical_operators/__init__.py rename to tests/forging_blocks/domain/specification/logical_operators/__init__.py diff --git a/tests/forging_blocks/foundation/specification/logical_operators/test_and_specification.py b/tests/forging_blocks/domain/specification/logical_operators/test_and_specification.py similarity index 100% rename from tests/forging_blocks/foundation/specification/logical_operators/test_and_specification.py rename to tests/forging_blocks/domain/specification/logical_operators/test_and_specification.py diff --git a/tests/forging_blocks/foundation/specification/logical_operators/test_not_specification.py b/tests/forging_blocks/domain/specification/logical_operators/test_not_specification.py similarity index 100% rename from tests/forging_blocks/foundation/specification/logical_operators/test_not_specification.py rename to tests/forging_blocks/domain/specification/logical_operators/test_not_specification.py diff --git a/tests/forging_blocks/foundation/specification/logical_operators/test_or_specification.py b/tests/forging_blocks/domain/specification/logical_operators/test_or_specification.py similarity index 100% rename from tests/forging_blocks/foundation/specification/logical_operators/test_or_specification.py rename to tests/forging_blocks/domain/specification/logical_operators/test_or_specification.py diff --git a/tests/forging_blocks/foundation/specification/test_base_specification.py b/tests/forging_blocks/domain/specification/test_base_specification.py similarity index 100% rename from tests/forging_blocks/foundation/specification/test_base_specification.py rename to tests/forging_blocks/domain/specification/test_base_specification.py diff --git a/tests/forging_blocks/foundation/specification/test_composable_specification.py b/tests/forging_blocks/domain/specification/test_composable_specification.py similarity index 100% rename from tests/forging_blocks/foundation/specification/test_composable_specification.py rename to tests/forging_blocks/domain/specification/test_composable_specification.py diff --git a/tests/forging_blocks/foundation/specification/test_expression_specification.py b/tests/forging_blocks/domain/specification/test_expression_specification.py similarity index 100% rename from tests/forging_blocks/foundation/specification/test_expression_specification.py rename to tests/forging_blocks/domain/specification/test_expression_specification.py diff --git a/tests/forging_blocks/foundation/test_value_objects.py b/tests/forging_blocks/domain/test_value_objects.py similarity index 100% rename from tests/forging_blocks/foundation/test_value_objects.py rename to tests/forging_blocks/domain/test_value_objects.py From 0bdce28a5937afbf9cb7a7ea33a097eca238fea1 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 16:03:00 -0300 Subject: [PATCH 64/86] refactor(domain): update source imports from foundation to domain modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all source-file imports to reference the new domain module paths: - ValueObject: foundation.value_object → domain.value_object - Messages: foundation.messages → domain.messages - Specifications: foundation.specification → domain.specification Includes TYPE_CHECKING guards and from __future__ import annotations in domain/permissions/ to resolve circular imports with foundation.context. --- .../ports/inbound/message_handler_port.py | 6 +- .../ports/outbound/command_sender_port.py | 2 +- .../ports/outbound/event_bus_port.py | 4 +- .../ports/outbound/event_publisher_port.py | 2 +- .../ports/outbound/event_store_port.py | 2 +- .../ports/outbound/query_fetcher_port.py | 2 +- .../outbound/specification_repository_port.py | 2 +- src/forging_blocks/domain/README.md | 20 ++--- src/forging_blocks/domain/__init__.py | 20 +++-- .../domain/aggregate_root/aggregate_root.py | 2 +- .../aggregate_root/aggregate_version.py | 2 +- .../domain/messages/__init__.py | 2 +- src/forging_blocks/domain/messages/command.py | 2 +- .../domain/messages/decorators.py | 6 +- src/forging_blocks/domain/messages/event.py | 2 +- .../domain/messages/message/_metadata.py | 2 +- src/forging_blocks/domain/messages/query.py | 2 +- .../composite_permission_checker.py | 8 +- .../domain/permissions/permission_checker.py | 8 +- .../resource_permission_checker.py | 8 +- .../role_based_permission_checker.py | 8 +- .../logical_operators/and_specification.py | 4 +- .../logical_operators/not_specification.py | 4 +- .../logical_operators/or_specification.py | 4 +- src/forging_blocks/domain/value_object.py | 81 +++++++++++++++++-- src/forging_blocks/foundation/__init__.py | 21 ----- .../context/authorization_context.py | 2 +- .../foundation/context/service_context.py | 2 +- .../foundation/context/transaction_context.py | 2 +- .../errors/not_callable_predicate_error.py | 2 +- .../event_buses/event_bus_base.py | 4 +- .../event_buses/in_memory_event_bus.py | 4 +- .../event_buses/in_memory_event_bus_base.py | 4 +- .../event_stores/event_store_base.py | 2 +- .../event_stores/in_memory_event_store.py | 2 +- .../in_memory_event_store_base.py | 2 +- .../message_bus/in_memory_message_bus.py | 2 +- .../message_bus/message_bus_command_sender.py | 2 +- .../message_bus_event_publisher.py | 2 +- .../message_bus/message_bus_query_fetcher.py | 2 +- .../repositories/aggregate_repository.py | 2 +- .../repositories/in_memory_read_repository.py | 2 +- .../serialization/_dict_message_codec.py | 2 +- 43 files changed, 170 insertions(+), 96 deletions(-) diff --git a/src/forging_blocks/application/ports/inbound/message_handler_port.py b/src/forging_blocks/application/ports/inbound/message_handler_port.py index b670beb9..e7a5970c 100644 --- a/src/forging_blocks/application/ports/inbound/message_handler_port.py +++ b/src/forging_blocks/application/ports/inbound/message_handler_port.py @@ -18,9 +18,9 @@ from abc import abstractmethod -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.query import Query +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.query import Query from forging_blocks.foundation.ports import InboundPort diff --git a/src/forging_blocks/application/ports/outbound/command_sender_port.py b/src/forging_blocks/application/ports/outbound/command_sender_port.py index bbaa4994..29a0cb82 100644 --- a/src/forging_blocks/application/ports/outbound/command_sender_port.py +++ b/src/forging_blocks/application/ports/outbound/command_sender_port.py @@ -14,7 +14,7 @@ from abc import abstractmethod -from forging_blocks.foundation.messages.command import Command +from forging_blocks.domain.messages.command import Command from forging_blocks.foundation.ports import OutboundPort diff --git a/src/forging_blocks/application/ports/outbound/event_bus_port.py b/src/forging_blocks/application/ports/outbound/event_bus_port.py index 6084c445..29d06c0e 100644 --- a/src/forging_blocks/application/ports/outbound/event_bus_port.py +++ b/src/forging_blocks/application/ports/outbound/event_bus_port.py @@ -8,8 +8,8 @@ from abc import abstractmethod from forging_blocks.application.errors.event_bus_error import EventBusError -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation.ports import OutboundPort from forging_blocks.foundation.result import Result diff --git a/src/forging_blocks/application/ports/outbound/event_publisher_port.py b/src/forging_blocks/application/ports/outbound/event_publisher_port.py index e2d06901..058f14f6 100644 --- a/src/forging_blocks/application/ports/outbound/event_publisher_port.py +++ b/src/forging_blocks/application/ports/outbound/event_publisher_port.py @@ -14,7 +14,7 @@ from abc import abstractmethod -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation.ports import OutboundPort diff --git a/src/forging_blocks/application/ports/outbound/event_store_port.py b/src/forging_blocks/application/ports/outbound/event_store_port.py index 3a50a23f..3a42c4ca 100644 --- a/src/forging_blocks/application/ports/outbound/event_store_port.py +++ b/src/forging_blocks/application/ports/outbound/event_store_port.py @@ -10,7 +10,7 @@ from uuid import UUID from forging_blocks.application.errors.event_store_error import EventStoreError -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation.ports import OutboundPort from forging_blocks.foundation.result import Result diff --git a/src/forging_blocks/application/ports/outbound/query_fetcher_port.py b/src/forging_blocks/application/ports/outbound/query_fetcher_port.py index a7de0ce1..ae2b78c6 100644 --- a/src/forging_blocks/application/ports/outbound/query_fetcher_port.py +++ b/src/forging_blocks/application/ports/outbound/query_fetcher_port.py @@ -14,7 +14,7 @@ from abc import abstractmethod -from forging_blocks.foundation.messages.query import Query +from forging_blocks.domain.messages.query import Query from forging_blocks.foundation.ports import OutboundPort diff --git a/src/forging_blocks/application/ports/outbound/specification_repository_port.py b/src/forging_blocks/application/ports/outbound/specification_repository_port.py index fe3f0618..520a4df5 100644 --- a/src/forging_blocks/application/ports/outbound/specification_repository_port.py +++ b/src/forging_blocks/application/ports/outbound/specification_repository_port.py @@ -8,7 +8,7 @@ from collections.abc import Sequence from forging_blocks.application.ports.outbound.repository_port import ReadOnlyRepositoryPort -from forging_blocks.foundation.specification import Specification +from forging_blocks.domain.specification import Specification class SpecificationRepositoryPort[TEntity, TId](ReadOnlyRepositoryPort[TEntity, TId]): diff --git a/src/forging_blocks/domain/README.md b/src/forging_blocks/domain/README.md index c87db74a..21e340d6 100644 --- a/src/forging_blocks/domain/README.md +++ b/src/forging_blocks/domain/README.md @@ -8,15 +8,15 @@ This block models your problem space using core DDD patterns: **Entities, Value ## Directory Structure -``` domain/ -├── aggregate_root.py # Base class for aggregate roots -├── aggregate_version.py # Version tracking for optimistic concurrency -├── entity.py # Base class for entities with identity -├── value_object.py # Base class for value objects (immutables) -├── errors/ # Domain-specific errors -└── README.md # This documentation -``` +├── aggregate_root/ # Aggregate roots with event sourcing +├── entity.py # Base class for entities with identity +├── value_object.py # Base class for value objects (immutables) +├── messages/ # Commands, Events, Queries +├── specification/ # Composable specification pattern +├── permissions/ # Permission checking +├── errors/ # Domain-specific errors +└── README.md # This documentation --- @@ -38,7 +38,7 @@ domain/ - **Events:** Things that have happened (immutable, recordable). - **Commands:** Requests for actions (intent, not result). - **Queries:** Requests for data (query, not result). -- All are defined in the **Foundation** block as reusable message abstractions. +- All are defined in the **Domain** block as reusable message abstractions. --- @@ -59,7 +59,7 @@ domain/ Create subclasses of `Event` and use them to communicate important business changes. 4. **Use Domain Commands and Queries** - Import from `forging_blocks.foundation.messages` for intent and data retrieval. + Import from `forging_blocks.domain.messages` for intent and data retrieval. --- diff --git a/src/forging_blocks/domain/__init__.py b/src/forging_blocks/domain/__init__.py index 6e795d08..66f48df7 100644 --- a/src/forging_blocks/domain/__init__.py +++ b/src/forging_blocks/domain/__init__.py @@ -1,13 +1,5 @@ """ForgingBlocks for domain-specific modules.""" -from forging_blocks.foundation.specification import ( - AndSpecification, - ExpressionSpecification, - NotSpecification, - OrSpecification, - Specification, -) - from .aggregate_root import AggregateRoot, AggregateVersion from .entity import Entity from .errors import ( @@ -16,10 +8,18 @@ EntityIdModificationError, EntityIdNoneError, ) +from .messages import Command, Event, Message, Query from .permissions.composite_permission_checker import CompositePermissionChecker from .permissions.permission_checker import PermissionChecker from .permissions.resource_permission_checker import ResourcePermissionChecker from .permissions.role_based_permission_checker import RoleBasedPermissionChecker +from .specification import ( + AndSpecification, + ExpressionSpecification, + NotSpecification, + OrSpecification, + Specification, +) from .validators.composite_validation_rule import CompositeValidationRule from .validators.email_validator import EmailValidator from .validators.length_validator import LengthValidator @@ -31,6 +31,7 @@ "AggregateRoot", "AggregateVersion", "AndSpecification", + "Command", "CompositePermissionChecker", "CompositeValidationRule", "DraftEntityIsNotHashableError", @@ -39,11 +40,14 @@ "EntityIdDeletionError", "EntityIdModificationError", "EntityIdNoneError", + "Event", "ExpressionSpecification", "LengthValidator", + "Message", "NotSpecification", "OrSpecification", "PermissionChecker", + "Query", "RangeValidator", "RequiredValidator", "ResourcePermissionChecker", diff --git a/src/forging_blocks/domain/aggregate_root/aggregate_root.py b/src/forging_blocks/domain/aggregate_root/aggregate_root.py index 1f95b0c3..9f743baa 100644 --- a/src/forging_blocks/domain/aggregate_root/aggregate_root.py +++ b/src/forging_blocks/domain/aggregate_root/aggregate_root.py @@ -6,8 +6,8 @@ from forging_blocks.domain.entity import Entity from forging_blocks.domain.errors.entity_id_none_error import EntityIdNoneError +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation import FinalABCMeta, runtime_final -from forging_blocks.foundation.messages.event import Event from .aggregate_version import AggregateVersion diff --git a/src/forging_blocks/domain/aggregate_root/aggregate_version.py b/src/forging_blocks/domain/aggregate_root/aggregate_version.py index 0ab6e0fb..9fae8516 100644 --- a/src/forging_blocks/domain/aggregate_root/aggregate_version.py +++ b/src/forging_blocks/domain/aggregate_root/aggregate_version.py @@ -1,6 +1,6 @@ """AggregateVersion value object for optimistic concurrency control.""" -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class AggregateVersion(ValueObject[int]): diff --git a/src/forging_blocks/domain/messages/__init__.py b/src/forging_blocks/domain/messages/__init__.py index 33ad4185..f466a161 100644 --- a/src/forging_blocks/domain/messages/__init__.py +++ b/src/forging_blocks/domain/messages/__init__.py @@ -1,4 +1,4 @@ -"""Foundation messages package.""" +"""Domain messages package.""" from .command import Command from .decorators import ( diff --git a/src/forging_blocks/domain/messages/command.py b/src/forging_blocks/domain/messages/command.py index fc66a683..1bc7d309 100644 --- a/src/forging_blocks/domain/messages/command.py +++ b/src/forging_blocks/domain/messages/command.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import UUID -from forging_blocks.foundation.messages.message import Message +from forging_blocks.domain.messages.message import Message class Command[RawCommandType](Message[RawCommandType]): diff --git a/src/forging_blocks/domain/messages/decorators.py b/src/forging_blocks/domain/messages/decorators.py index 41c45b5c..fb701766 100644 --- a/src/forging_blocks/domain/messages/decorators.py +++ b/src/forging_blocks/domain/messages/decorators.py @@ -8,8 +8,8 @@ Example:: - from forging_blocks.foundation.messages.decorators import event_dataclass - from forging_blocks.foundation.messages.event import Event + from forging_blocks.domain.messages.decorators import event_dataclass + from forging_blocks.domain.messages.event import Event @event_dataclass @@ -27,7 +27,7 @@ class OrderCreated(Event[dict[str, object]]): from dataclasses import dataclass from typing import Any, Protocol, Self, TypeVar, cast, overload, runtime_checkable -from forging_blocks.foundation.messages.message import Message, MessageMetadata +from forging_blocks.domain.messages.message import Message, MessageMetadata _M = TypeVar("_M", bound="Message[Any]") diff --git a/src/forging_blocks/domain/messages/event.py b/src/forging_blocks/domain/messages/event.py index 2ed31ee5..681d0ac8 100644 --- a/src/forging_blocks/domain/messages/event.py +++ b/src/forging_blocks/domain/messages/event.py @@ -3,7 +3,7 @@ from abc import abstractmethod from datetime import datetime -from forging_blocks.foundation.messages.message import Message +from forging_blocks.domain.messages.message import Message class Event[RawEventType](Message[RawEventType]): diff --git a/src/forging_blocks/domain/messages/message/_metadata.py b/src/forging_blocks/domain/messages/message/_metadata.py index cc68706a..fe353aec 100644 --- a/src/forging_blocks/domain/messages/message/_metadata.py +++ b/src/forging_blocks/domain/messages/message/_metadata.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from uuid import UUID, uuid7 -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class MessageMetadata(ValueObject[dict[str, object]]): diff --git a/src/forging_blocks/domain/messages/query.py b/src/forging_blocks/domain/messages/query.py index 299c84d6..addd0342 100644 --- a/src/forging_blocks/domain/messages/query.py +++ b/src/forging_blocks/domain/messages/query.py @@ -2,7 +2,7 @@ from abc import abstractmethod -from forging_blocks.foundation.messages.message import Message +from forging_blocks.domain.messages.message import Message class Query[QueryPayloadType](Message[QueryPayloadType]): diff --git a/src/forging_blocks/domain/permissions/composite_permission_checker.py b/src/forging_blocks/domain/permissions/composite_permission_checker.py index d01bfbeb..b923f0c9 100644 --- a/src/forging_blocks/domain/permissions/composite_permission_checker.py +++ b/src/forging_blocks/domain/permissions/composite_permission_checker.py @@ -1,9 +1,15 @@ """Composite permission checker with OR logic.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from forging_blocks.domain.permissions.permission_checker import PermissionChecker -from forging_blocks.foundation.context import AuthorizationContext from forging_blocks.foundation.permission import Permission +if TYPE_CHECKING: + from forging_blocks.foundation.context import AuthorizationContext + class CompositePermissionChecker: """Combines multiple `PermissionChecker` instances with OR logic.""" diff --git a/src/forging_blocks/domain/permissions/permission_checker.py b/src/forging_blocks/domain/permissions/permission_checker.py index 5da7223c..9fe053d8 100644 --- a/src/forging_blocks/domain/permissions/permission_checker.py +++ b/src/forging_blocks/domain/permissions/permission_checker.py @@ -1,10 +1,14 @@ """Protocol for permission-checking implementations.""" -from typing import Protocol, runtime_checkable +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable -from forging_blocks.foundation.context import AuthorizationContext from forging_blocks.foundation.permission import Permission +if TYPE_CHECKING: + from forging_blocks.foundation.context import AuthorizationContext + @runtime_checkable class PermissionChecker(Protocol): diff --git a/src/forging_blocks/domain/permissions/resource_permission_checker.py b/src/forging_blocks/domain/permissions/resource_permission_checker.py index 0dac2355..85411161 100644 --- a/src/forging_blocks/domain/permissions/resource_permission_checker.py +++ b/src/forging_blocks/domain/permissions/resource_permission_checker.py @@ -1,8 +1,14 @@ """Resource-based permission checker implementation.""" -from forging_blocks.foundation.context import AuthorizationContext +from __future__ import annotations + +from typing import TYPE_CHECKING + from forging_blocks.foundation.permission import Permission +if TYPE_CHECKING: + from forging_blocks.foundation.context import AuthorizationContext + class ResourcePermissionChecker: """Grants permissions based on a resource-type-to-permission mapping. diff --git a/src/forging_blocks/domain/permissions/role_based_permission_checker.py b/src/forging_blocks/domain/permissions/role_based_permission_checker.py index 14e19f69..aa870af6 100644 --- a/src/forging_blocks/domain/permissions/role_based_permission_checker.py +++ b/src/forging_blocks/domain/permissions/role_based_permission_checker.py @@ -1,8 +1,14 @@ """Role-based permission checker implementation.""" -from forging_blocks.foundation.context import AuthorizationContext +from __future__ import annotations + +from typing import TYPE_CHECKING + from forging_blocks.foundation.permission import Permission +if TYPE_CHECKING: + from forging_blocks.foundation.context import AuthorizationContext + class RoleBasedPermissionChecker: """Grants permissions based on a static role-to-permission mapping. diff --git a/src/forging_blocks/domain/specification/logical_operators/and_specification.py b/src/forging_blocks/domain/specification/logical_operators/and_specification.py index 6bbd6085..e34e6600 100644 --- a/src/forging_blocks/domain/specification/logical_operators/and_specification.py +++ b/src/forging_blocks/domain/specification/logical_operators/and_specification.py @@ -1,5 +1,5 @@ -from forging_blocks.foundation.specification.base import Specification -from forging_blocks.foundation.specification.composable import ComposableSpecification +from forging_blocks.domain.specification.base import Specification +from forging_blocks.domain.specification.composable import ComposableSpecification class AndSpecification[T](ComposableSpecification[T]): diff --git a/src/forging_blocks/domain/specification/logical_operators/not_specification.py b/src/forging_blocks/domain/specification/logical_operators/not_specification.py index 3632b6bf..7c41ede5 100644 --- a/src/forging_blocks/domain/specification/logical_operators/not_specification.py +++ b/src/forging_blocks/domain/specification/logical_operators/not_specification.py @@ -1,5 +1,5 @@ -from forging_blocks.foundation.specification.base import Specification -from forging_blocks.foundation.specification.composable import ComposableSpecification +from forging_blocks.domain.specification.base import Specification +from forging_blocks.domain.specification.composable import ComposableSpecification class NotSpecification[T](ComposableSpecification[T]): diff --git a/src/forging_blocks/domain/specification/logical_operators/or_specification.py b/src/forging_blocks/domain/specification/logical_operators/or_specification.py index ee3ef252..ed3b732a 100644 --- a/src/forging_blocks/domain/specification/logical_operators/or_specification.py +++ b/src/forging_blocks/domain/specification/logical_operators/or_specification.py @@ -1,5 +1,5 @@ -from forging_blocks.foundation.specification.base import Specification -from forging_blocks.foundation.specification.composable import ComposableSpecification +from forging_blocks.domain.specification.base import Specification +from forging_blocks.domain.specification.composable import ComposableSpecification class OrSpecification[T](ComposableSpecification[T]): diff --git a/src/forging_blocks/domain/value_object.py b/src/forging_blocks/domain/value_object.py index 97edfc56..0df1f53f 100644 --- a/src/forging_blocks/domain/value_object.py +++ b/src/forging_blocks/domain/value_object.py @@ -1,11 +1,80 @@ """Domain value objects module. -Re-exports the ValueObject base class from the foundation layer -so that domain code can import from a natural location. +This module provides the base ValueObject class for implementing domain value objects +following the principles of Domain-Driven Design (DDD). """ -from forging_blocks.foundation.value_object import ValueObject # noqa: F401 +import inspect +from abc import ABC, abstractmethod +from typing import Any -__all__ = [ - "ValueObject", -] +from forging_blocks.foundation.autoeq import auto_eq +from forging_blocks.foundation.autofreeze import auto_freeze +from forging_blocks.foundation.autohash import auto_hash + + +class ValueObject[RawValueType](ABC): + """Base class for all domain value objects. + + Value objects are immutable objects defined entirely by their attributes + rather than by an identity. Two value objects with the same attributes + are considered equal. + + Concrete subclasses are automatically frozen, hashable, and structurally + comparable via `auto_freeze`, `auto_hash`, and + `auto_eq`. The three decorators are independent — each applies + exactly one concern: + + - `@auto_freeze` enforces immutability. + - `@auto_hash` generates `__hash__` from class fields. + - `@auto_eq` generates `__eq__` from class fields. + + Intermediate abstract classes are skipped so leaf subclasses finish + their own `__init__` without restriction. + + Example: + ```python + class Email(ValueObject[str]): + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + super().__init__() + if "@" not in value: + raise ValueError("Invalid email format") + self._value = value + + @property + def value(self) -> str: + return self._value + ``` + + """ + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Apply `auto_freeze`, `auto_hash`, and `auto_eq` to concrete subclasses. + + `auto_freeze` enforces immutability; `auto_hash` generates + `__hash__` from class fields; `auto_eq` generates `__eq__` + from class fields. All three are independent decorators — none + composes the others. + """ + super().__init_subclass__(**kwargs) + if not inspect.isabstract(cls): + auto_freeze(cls) + auto_hash(cls) + auto_eq(cls) + + def __str__(self) -> str: + field_names = getattr(self, "__auto_hash_fields__", ()) + components = tuple(getattr(self, name) for name in field_names) + if len(components) == 1: + return f"{self.__class__.__name__}({components[0]!r})" + return f"{self.__class__.__name__}{components!r}" + + def __repr__(self) -> str: + return self.__str__() + + @property + @abstractmethod + def value(self) -> RawValueType: + """Return the primary raw value encapsulated by the ValueObject.""" diff --git a/src/forging_blocks/foundation/__init__.py b/src/forging_blocks/foundation/__init__.py index 7d097dad..4f2cb5ba 100644 --- a/src/forging_blocks/foundation/__init__.py +++ b/src/forging_blocks/foundation/__init__.py @@ -23,7 +23,6 @@ ) from .identified import Identified from .mapper import Mapper -from .messages import Command, Event, Message, Query from .meta import FinalABCMeta, FinalMeta, runtime_final from .permission import Permission from .ports import ( @@ -33,35 +32,21 @@ ) from .result import Err, Ok, Result from .rules import ValidationRule -from .specification import ( - AndSpecification, - ComposableSpecification, - ExpressionSpecification, - NotSpecification, - OrSpecification, - Specification, -) -from .value_object import ValueObject __all__ = [ "auto_eq", "auto_hash", "ArchitectureError", - "AndSpecification", "ConfigurationError", "AuthorizationContext", "CombinedErrors", "CombinedRuleViolationErrors", "CombinedValidationErrors", "CantModifyImmutableAttributeError", - "Command", - "ComposableSpecification", "Err", "Error", "ErrorMessage", "ErrorMetadata", - "Event", - "ExpressionSpecification", "FieldErrors", "FieldReference", "FinalABCMeta", @@ -69,24 +54,18 @@ "Identified", "InboundPort", "Mapper", - "Message", "NoneNotAllowedError", - "NotSpecification", "Ok", - "OrSpecification", "OutboundPort", "Permission", "Port", - "Query", "Result", "ResultAccessError", "RuleViolationError", "runtime_final", "ServiceContext", - "Specification", "TransactionContext", "ValidationError", "ValidationFieldErrors", "ValidationRule", - "ValueObject", ] diff --git a/src/forging_blocks/foundation/context/authorization_context.py b/src/forging_blocks/foundation/context/authorization_context.py index 46f5554c..a2ce889e 100644 --- a/src/forging_blocks/foundation/context/authorization_context.py +++ b/src/forging_blocks/foundation/context/authorization_context.py @@ -2,7 +2,7 @@ from collections.abc import Hashable -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class AuthorizationContext(ValueObject[tuple[Hashable, ...]]): diff --git a/src/forging_blocks/foundation/context/service_context.py b/src/forging_blocks/foundation/context/service_context.py index 6234edee..ac7acbc6 100644 --- a/src/forging_blocks/foundation/context/service_context.py +++ b/src/forging_blocks/foundation/context/service_context.py @@ -3,7 +3,7 @@ import uuid from collections.abc import Hashable -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class ServiceContext(ValueObject[tuple[Hashable, ...]]): diff --git a/src/forging_blocks/foundation/context/transaction_context.py b/src/forging_blocks/foundation/context/transaction_context.py index cf80f33f..099d9cdb 100644 --- a/src/forging_blocks/foundation/context/transaction_context.py +++ b/src/forging_blocks/foundation/context/transaction_context.py @@ -4,7 +4,7 @@ from collections.abc import Hashable from datetime import datetime, timezone -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class TransactionContext(ValueObject[tuple[Hashable, ...]]): diff --git a/src/forging_blocks/foundation/errors/not_callable_predicate_error.py b/src/forging_blocks/foundation/errors/not_callable_predicate_error.py index b965c5da..214794a8 100644 --- a/src/forging_blocks/foundation/errors/not_callable_predicate_error.py +++ b/src/forging_blocks/foundation/errors/not_callable_predicate_error.py @@ -24,7 +24,7 @@ class NotCallablePredicateError[MetadataType: Mapping[str, object]](Error[Metada context: Shortcut access to the metadata context dictionary. Example: - >>> from forging_blocks.foundation.specification import ExpressionSpecification + >>> from forging_blocks.domain.specification import ExpressionSpecification >>> spec = ExpressionSpecification(123) # Not callable Traceback (most recent call last): ... diff --git a/src/forging_blocks/infrastructure/event_buses/event_bus_base.py b/src/forging_blocks/infrastructure/event_buses/event_bus_base.py index bc055d38..dbec86a2 100644 --- a/src/forging_blocks/infrastructure/event_buses/event_bus_base.py +++ b/src/forging_blocks/infrastructure/event_buses/event_bus_base.py @@ -8,8 +8,8 @@ from abc import ABC, abstractmethod from forging_blocks.application.errors.event_bus_error import EventBusError -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation.result import Result diff --git a/src/forging_blocks/infrastructure/event_buses/in_memory_event_bus.py b/src/forging_blocks/infrastructure/event_buses/in_memory_event_bus.py index a4da5f2a..b64fab48 100644 --- a/src/forging_blocks/infrastructure/event_buses/in_memory_event_bus.py +++ b/src/forging_blocks/infrastructure/event_buses/in_memory_event_bus.py @@ -9,8 +9,8 @@ from forging_blocks.application.errors.event_bus_error import EventBusError from forging_blocks.application.ports.outbound.event_bus_port import EventBusPort -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation.result import Err, Ok, Result diff --git a/src/forging_blocks/infrastructure/event_buses/in_memory_event_bus_base.py b/src/forging_blocks/infrastructure/event_buses/in_memory_event_bus_base.py index a26a7b73..dcd73ed6 100644 --- a/src/forging_blocks/infrastructure/event_buses/in_memory_event_bus_base.py +++ b/src/forging_blocks/infrastructure/event_buses/in_memory_event_bus_base.py @@ -8,8 +8,8 @@ from typing import Protocol, cast from forging_blocks.application.errors.event_bus_error import EventBusError -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation.result import Err, Ok, Result from forging_blocks.infrastructure.event_buses.event_bus_base import EventBusBase diff --git a/src/forging_blocks/infrastructure/event_stores/event_store_base.py b/src/forging_blocks/infrastructure/event_stores/event_store_base.py index 1dc5e151..f04f6b70 100644 --- a/src/forging_blocks/infrastructure/event_stores/event_store_base.py +++ b/src/forging_blocks/infrastructure/event_stores/event_store_base.py @@ -9,7 +9,7 @@ from uuid import UUID from forging_blocks.application.errors.event_store_error import EventStoreError -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation.result import Result diff --git a/src/forging_blocks/infrastructure/event_stores/in_memory_event_store.py b/src/forging_blocks/infrastructure/event_stores/in_memory_event_store.py index 1ac572ec..f3c62189 100644 --- a/src/forging_blocks/infrastructure/event_stores/in_memory_event_store.py +++ b/src/forging_blocks/infrastructure/event_stores/in_memory_event_store.py @@ -14,7 +14,7 @@ from forging_blocks.application.errors.concurrency_error import ConcurrencyError from forging_blocks.application.errors.event_store_error import EventStoreError from forging_blocks.application.ports.outbound.event_store_port import EventStorePort -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation.result import Err, Ok, Result diff --git a/src/forging_blocks/infrastructure/event_stores/in_memory_event_store_base.py b/src/forging_blocks/infrastructure/event_stores/in_memory_event_store_base.py index 880cd85a..b69f6d55 100644 --- a/src/forging_blocks/infrastructure/event_stores/in_memory_event_store_base.py +++ b/src/forging_blocks/infrastructure/event_stores/in_memory_event_store_base.py @@ -13,7 +13,7 @@ from forging_blocks.application.errors.concurrency_error import ConcurrencyError from forging_blocks.application.errors.event_store_error import EventStoreError -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.event import Event from forging_blocks.foundation.result import Err, Ok, Result from forging_blocks.infrastructure.event_stores.event_store_base import ( EventStoreBase, diff --git a/src/forging_blocks/infrastructure/message_bus/in_memory_message_bus.py b/src/forging_blocks/infrastructure/message_bus/in_memory_message_bus.py index 77d61844..70e397a7 100644 --- a/src/forging_blocks/infrastructure/message_bus/in_memory_message_bus.py +++ b/src/forging_blocks/infrastructure/message_bus/in_memory_message_bus.py @@ -9,7 +9,7 @@ from typing import cast from forging_blocks.application.ports.outbound.message_bus_port import MessageBusPort -from forging_blocks.foundation.messages.message import Message +from forging_blocks.domain.messages.message import Message class InMemoryMessageBus[MessageType: Message[object], MessageBusResultType]( diff --git a/src/forging_blocks/infrastructure/message_bus/message_bus_command_sender.py b/src/forging_blocks/infrastructure/message_bus/message_bus_command_sender.py index f857d412..2e228252 100644 --- a/src/forging_blocks/infrastructure/message_bus/message_bus_command_sender.py +++ b/src/forging_blocks/infrastructure/message_bus/message_bus_command_sender.py @@ -5,7 +5,7 @@ from forging_blocks.application.ports.outbound.command_sender_port import CommandSenderPort from forging_blocks.application.ports.outbound.message_bus_port import MessageBusPort -from forging_blocks.foundation.messages.command import Command +from forging_blocks.domain.messages.command import Command class MessageBusCommandSender[CommandPayloadType](CommandSenderPort[CommandPayloadType]): diff --git a/src/forging_blocks/infrastructure/message_bus/message_bus_event_publisher.py b/src/forging_blocks/infrastructure/message_bus/message_bus_event_publisher.py index aabee1f0..fef2cd9f 100644 --- a/src/forging_blocks/infrastructure/message_bus/message_bus_event_publisher.py +++ b/src/forging_blocks/infrastructure/message_bus/message_bus_event_publisher.py @@ -5,7 +5,7 @@ from forging_blocks.application.ports.outbound.event_publisher_port import EventPublisherPort from forging_blocks.application.ports.outbound.message_bus_port import MessageBusPort -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.event import Event class MessageBusEventPublisher[EventPayloadType](EventPublisherPort[EventPayloadType]): diff --git a/src/forging_blocks/infrastructure/message_bus/message_bus_query_fetcher.py b/src/forging_blocks/infrastructure/message_bus/message_bus_query_fetcher.py index 53456748..744db380 100644 --- a/src/forging_blocks/infrastructure/message_bus/message_bus_query_fetcher.py +++ b/src/forging_blocks/infrastructure/message_bus/message_bus_query_fetcher.py @@ -5,7 +5,7 @@ from forging_blocks.application.ports.outbound.message_bus_port import MessageBusPort from forging_blocks.application.ports.outbound.query_fetcher_port import QueryFetcherPort -from forging_blocks.foundation.messages.query import Query +from forging_blocks.domain.messages.query import Query class MessageBusQueryFetcher[QueryPayloadType, QueryFetcherResult]( diff --git a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py index 479f4a6d..b3062efe 100644 --- a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py +++ b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py @@ -8,7 +8,7 @@ from uuid import UUID from forging_blocks.domain.aggregate_root.aggregate_root import AggregateRoot -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.event import Event from forging_blocks.infrastructure.event_stores.event_store_base import EventStoreBase from forging_blocks.infrastructure.repositories.in_memory_repository import InMemoryRepository diff --git a/src/forging_blocks/infrastructure/repositories/in_memory_read_repository.py b/src/forging_blocks/infrastructure/repositories/in_memory_read_repository.py index d37ca428..a3b2152d 100644 --- a/src/forging_blocks/infrastructure/repositories/in_memory_read_repository.py +++ b/src/forging_blocks/infrastructure/repositories/in_memory_read_repository.py @@ -8,7 +8,7 @@ from collections.abc import Mapping, Sequence from forging_blocks.application.ports.outbound.repository_port import ReadOnlyRepositoryPort -from forging_blocks.foundation.specification import Specification +from forging_blocks.domain.specification import Specification class InMemoryReadRepository[TEntity, TId](ReadOnlyRepositoryPort[TEntity, TId]): diff --git a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py index d29ae4e8..14f858e1 100644 --- a/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py +++ b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py @@ -4,7 +4,7 @@ from typing import cast from uuid import UUID -from forging_blocks.foundation.messages import Message, MessageMetadata +from forging_blocks.domain.messages import Message, MessageMetadata from ._message_codec import MessageCodec From 5076b12bc6285ee149cc118db45ec7b07be70eb8 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 16:03:10 -0300 Subject: [PATCH 65/86] refactor(domain): update test imports from foundation to domain modules Update all test-file imports to reference the new domain module paths for ValueObject, Messages, and Specification classes. --- tests/fixtures/fake_event_publisher.py | 2 +- tests/fixtures/fake_event_with_name.py | 4 ++-- tests/fixtures/fake_event_with_value.py | 4 ++-- tests/fixtures/simple_fake_command.py | 4 ++-- tests/fixtures/simple_fake_command_with_value.py | 4 ++-- .../ports/outbound/test_command_sender.py | 2 +- .../ports/outbound/test_event_publisher.py | 2 +- .../ports/outbound/test_query_fetcher.py | 2 +- .../domain/aggregate_root/test_aggregate_root.py | 4 ++-- .../domain/messages/test_command.py | 2 +- .../domain/messages/test_decorators.py | 16 ++++++++-------- .../forging_blocks/domain/messages/test_event.py | 4 ++-- .../domain/messages/test_message.py | 2 +- .../domain/messages/test_metadata.py | 2 +- .../domain/messages/test_serializable.py | 6 +++--- .../logical_operators/test_and_specification.py | 4 ++-- .../logical_operators/test_not_specification.py | 4 ++-- .../logical_operators/test_or_specification.py | 4 ++-- .../specification/test_base_specification.py | 2 +- .../test_composable_specification.py | 16 ++++++++-------- .../test_expression_specification.py | 4 ++-- .../forging_blocks/domain/test_value_objects.py | 2 +- .../event_buses/test_in_memory_event_bus.py | 4 ++-- .../event_buses/test_in_memory_event_bus_base.py | 4 ++-- .../message_bus/test_in_memory_message_bus.py | 6 +++--- .../test_message_bus_command_sender.py | 2 +- .../test_message_bus_event_publisher.py | 2 +- .../test_message_bus_query_fetcher.py | 2 +- .../repositories/test_aggregate_repository.py | 4 ++-- .../repositories/test_in_memory_repository.py | 2 +- .../serialization/test_dict_message_codec.py | 8 ++++---- .../infrastructure/test_event_bus.py | 6 +++--- .../unit_of_work/test_in_memory_unit_of_work.py | 4 ++-- .../infrastructure/bus/test_release_event_bus.py | 4 ++-- 34 files changed, 72 insertions(+), 72 deletions(-) diff --git a/tests/fixtures/fake_event_publisher.py b/tests/fixtures/fake_event_publisher.py index 94cfe739..97cab0b3 100644 --- a/tests/fixtures/fake_event_publisher.py +++ b/tests/fixtures/fake_event_publisher.py @@ -5,7 +5,7 @@ """ from forging_blocks.application.ports.outbound.event_publisher_port import EventPublisherPort -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.event import Event class FakeEventPublisher(EventPublisherPort[object]): diff --git a/tests/fixtures/fake_event_with_name.py b/tests/fixtures/fake_event_with_name.py index 67b3633a..32616e92 100644 --- a/tests/fixtures/fake_event_with_name.py +++ b/tests/fixtures/fake_event_with_name.py @@ -1,7 +1,7 @@ from typing import Self -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.message import MessageMetadata class FakeEventWithName(Event[dict[str, object]]): diff --git a/tests/fixtures/fake_event_with_value.py b/tests/fixtures/fake_event_with_value.py index 44411071..eeb79b07 100644 --- a/tests/fixtures/fake_event_with_value.py +++ b/tests/fixtures/fake_event_with_value.py @@ -1,7 +1,7 @@ from typing import Self -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.message import MessageMetadata class FakeEventWithValue(Event[dict[str, object]]): diff --git a/tests/fixtures/simple_fake_command.py b/tests/fixtures/simple_fake_command.py index b0f39b6f..82df0a47 100644 --- a/tests/fixtures/simple_fake_command.py +++ b/tests/fixtures/simple_fake_command.py @@ -1,7 +1,7 @@ from typing import Self -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.message import MessageMetadata class SimpleFakeCommand(Command[dict[str, object]]): diff --git a/tests/fixtures/simple_fake_command_with_value.py b/tests/fixtures/simple_fake_command_with_value.py index b04f31ef..db48ea44 100644 --- a/tests/fixtures/simple_fake_command_with_value.py +++ b/tests/fixtures/simple_fake_command_with_value.py @@ -1,7 +1,7 @@ from typing import Self -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.message import MessageMetadata class SimpleFakeCommandWithValue(Command[dict[str, object]]): diff --git a/tests/forging_blocks/application/ports/outbound/test_command_sender.py b/tests/forging_blocks/application/ports/outbound/test_command_sender.py index 456a6856..1186c637 100644 --- a/tests/forging_blocks/application/ports/outbound/test_command_sender.py +++ b/tests/forging_blocks/application/ports/outbound/test_command_sender.py @@ -13,8 +13,8 @@ import pytest from forging_blocks.application import CommandSenderPort +from forging_blocks.domain.messages import Command, MessageMetadata from forging_blocks.foundation import OutboundPort -from forging_blocks.foundation.messages import Command, MessageMetadata class FakeCommand(Command[str]): diff --git a/tests/forging_blocks/application/ports/outbound/test_event_publisher.py b/tests/forging_blocks/application/ports/outbound/test_event_publisher.py index fcd9bbf4..677ffb41 100644 --- a/tests/forging_blocks/application/ports/outbound/test_event_publisher.py +++ b/tests/forging_blocks/application/ports/outbound/test_event_publisher.py @@ -13,8 +13,8 @@ import pytest from forging_blocks.application import EventPublisherPort +from forging_blocks.domain.messages import Event, MessageMetadata from forging_blocks.foundation import OutboundPort -from forging_blocks.foundation.messages import Event, MessageMetadata class FakeEvent(Event[str]): diff --git a/tests/forging_blocks/application/ports/outbound/test_query_fetcher.py b/tests/forging_blocks/application/ports/outbound/test_query_fetcher.py index eeb1a713..af267ed9 100644 --- a/tests/forging_blocks/application/ports/outbound/test_query_fetcher.py +++ b/tests/forging_blocks/application/ports/outbound/test_query_fetcher.py @@ -13,8 +13,8 @@ import pytest from forging_blocks.application import QueryFetcherPort +from forging_blocks.domain.messages import MessageMetadata, Query from forging_blocks.foundation import OutboundPort -from forging_blocks.foundation.messages import MessageMetadata, Query class FakeQuery(Query[str]): diff --git a/tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py b/tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py index 87820b72..7762b62a 100644 --- a/tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py +++ b/tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py @@ -7,8 +7,8 @@ AggregateVersion, EntityIdNoneError, ) -from forging_blocks.foundation import Event -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages import Event +from forging_blocks.domain.messages.message import MessageMetadata raw_event = dict[str, str] diff --git a/tests/forging_blocks/domain/messages/test_command.py b/tests/forging_blocks/domain/messages/test_command.py index cb8b14e2..34462958 100644 --- a/tests/forging_blocks/domain/messages/test_command.py +++ b/tests/forging_blocks/domain/messages/test_command.py @@ -8,7 +8,7 @@ import pytest -from forging_blocks.foundation.messages import Command, Message, MessageMetadata +from forging_blocks.domain.messages import Command, Message, MessageMetadata class PayloadAndValueNotImplCommand(Command): diff --git a/tests/forging_blocks/domain/messages/test_decorators.py b/tests/forging_blocks/domain/messages/test_decorators.py index cbec8298..ff053343 100644 --- a/tests/forging_blocks/domain/messages/test_decorators.py +++ b/tests/forging_blocks/domain/messages/test_decorators.py @@ -6,7 +6,7 @@ import pytest -from forging_blocks.foundation.messages.message import Message +from forging_blocks.domain.messages.message import Message @pytest.mark.unit @@ -18,7 +18,7 @@ def test_message_dataclass_when_patched_message_check_fails_then_type_error( ) -> None: from unittest.mock import patch - from forging_blocks.foundation.messages.decorators import ( + from forging_blocks.domain.messages.decorators import ( _PatchedMessage, message_dataclass, ) @@ -34,7 +34,7 @@ def _selective_isinstance(obj: object, cls: type) -> bool: return original_isinstance(obj, cls) with patch( - "forging_blocks.foundation.messages.decorators.isinstance", + "forging_blocks.domain.messages.decorators.isinstance", side_effect=_selective_isinstance, ): with pytest.raises(TypeError, match="does not satisfy _PatchedMessage"): @@ -44,9 +44,9 @@ def test_decorated_messages_preserve_message_equality_by_id(self) -> None: """Two decorated messages with the same message_id are equal, regardless of field data.""" import uuid - from forging_blocks.foundation.messages.decorators import event_dataclass - from forging_blocks.foundation.messages.event import Event - from forging_blocks.foundation.messages.message import MessageMetadata + from forging_blocks.domain.messages.decorators import event_dataclass + from forging_blocks.domain.messages.event import Event + from forging_blocks.domain.messages.message import MessageMetadata shared_id = uuid.uuid4() @@ -76,8 +76,8 @@ class Shipped(Event[dict[str, object]]): def test_decorated_messages_differ_by_message_id(self) -> None: """Two decorated messages with different message_ids are not equal, even if field data matches.""" - from forging_blocks.foundation.messages.decorators import event_dataclass - from forging_blocks.foundation.messages.event import Event + from forging_blocks.domain.messages.decorators import event_dataclass + from forging_blocks.domain.messages.event import Event @event_dataclass class Shipped(Event[dict[str, object]]): diff --git a/tests/forging_blocks/domain/messages/test_event.py b/tests/forging_blocks/domain/messages/test_event.py index 558ecc3e..1c56a3ac 100644 --- a/tests/forging_blocks/domain/messages/test_event.py +++ b/tests/forging_blocks/domain/messages/test_event.py @@ -3,7 +3,7 @@ import pytest -from forging_blocks.foundation.messages import Event, MessageMetadata +from forging_blocks.domain.messages import Event, MessageMetadata class PayloadNotImplementEvent(Event): @@ -64,7 +64,7 @@ class TestEvent: def test_inheritance_when_instantied_then_is_message(self): event = FakeEvent("order_123", "customer_456", 150.0) - from forging_blocks.foundation.messages.message import Message + from forging_blocks.domain.messages.message import Message assert isinstance(event, Message) assert isinstance(event, Event) diff --git a/tests/forging_blocks/domain/messages/test_message.py b/tests/forging_blocks/domain/messages/test_message.py index 6bb15900..575b486f 100644 --- a/tests/forging_blocks/domain/messages/test_message.py +++ b/tests/forging_blocks/domain/messages/test_message.py @@ -6,7 +6,7 @@ import pytest -from forging_blocks.foundation.messages import Message, MessageMetadata +from forging_blocks.domain.messages import Message, MessageMetadata class FakeMessage(Message[str]): diff --git a/tests/forging_blocks/domain/messages/test_metadata.py b/tests/forging_blocks/domain/messages/test_metadata.py index 2914c6e6..a1538731 100644 --- a/tests/forging_blocks/domain/messages/test_metadata.py +++ b/tests/forging_blocks/domain/messages/test_metadata.py @@ -5,8 +5,8 @@ import pytest +from forging_blocks.domain.messages import MessageMetadata from forging_blocks.foundation import CantModifyImmutableAttributeError -from forging_blocks.foundation.messages import MessageMetadata @pytest.mark.unit diff --git a/tests/forging_blocks/domain/messages/test_serializable.py b/tests/forging_blocks/domain/messages/test_serializable.py index 6f782e9d..a3f547cb 100644 --- a/tests/forging_blocks/domain/messages/test_serializable.py +++ b/tests/forging_blocks/domain/messages/test_serializable.py @@ -5,13 +5,13 @@ import pytest -from forging_blocks.foundation.messages.decorators import ( +from forging_blocks.domain.messages.decorators import ( command_dataclass, event_dataclass, query_dataclass, ) -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.message import MessageMetadata class TestMessageDataclassDecorator: diff --git a/tests/forging_blocks/domain/specification/logical_operators/test_and_specification.py b/tests/forging_blocks/domain/specification/logical_operators/test_and_specification.py index 08e0be46..12e15b9f 100644 --- a/tests/forging_blocks/domain/specification/logical_operators/test_and_specification.py +++ b/tests/forging_blocks/domain/specification/logical_operators/test_and_specification.py @@ -1,7 +1,7 @@ import pytest -from forging_blocks.foundation.specification.base import Specification -from forging_blocks.foundation.specification.logical_operators.and_specification import ( +from forging_blocks.domain.specification.base import Specification +from forging_blocks.domain.specification.logical_operators.and_specification import ( AndSpecification, ) diff --git a/tests/forging_blocks/domain/specification/logical_operators/test_not_specification.py b/tests/forging_blocks/domain/specification/logical_operators/test_not_specification.py index eec98d2e..fc67f809 100644 --- a/tests/forging_blocks/domain/specification/logical_operators/test_not_specification.py +++ b/tests/forging_blocks/domain/specification/logical_operators/test_not_specification.py @@ -1,7 +1,7 @@ import pytest -from forging_blocks.foundation.specification.base import Specification -from forging_blocks.foundation.specification.logical_operators.not_specification import ( +from forging_blocks.domain.specification.base import Specification +from forging_blocks.domain.specification.logical_operators.not_specification import ( NotSpecification, ) diff --git a/tests/forging_blocks/domain/specification/logical_operators/test_or_specification.py b/tests/forging_blocks/domain/specification/logical_operators/test_or_specification.py index 981c2585..efe26267 100644 --- a/tests/forging_blocks/domain/specification/logical_operators/test_or_specification.py +++ b/tests/forging_blocks/domain/specification/logical_operators/test_or_specification.py @@ -1,7 +1,7 @@ import pytest -from forging_blocks.foundation.specification.base import Specification -from forging_blocks.foundation.specification.logical_operators.or_specification import ( +from forging_blocks.domain.specification.base import Specification +from forging_blocks.domain.specification.logical_operators.or_specification import ( OrSpecification, ) diff --git a/tests/forging_blocks/domain/specification/test_base_specification.py b/tests/forging_blocks/domain/specification/test_base_specification.py index df1b2786..8631ac56 100644 --- a/tests/forging_blocks/domain/specification/test_base_specification.py +++ b/tests/forging_blocks/domain/specification/test_base_specification.py @@ -3,7 +3,7 @@ import pytest -from forging_blocks.foundation.specification.base import Specification +from forging_blocks.domain.specification.base import Specification @pytest.mark.unit diff --git a/tests/forging_blocks/domain/specification/test_composable_specification.py b/tests/forging_blocks/domain/specification/test_composable_specification.py index c40b6212..a4b94c98 100644 --- a/tests/forging_blocks/domain/specification/test_composable_specification.py +++ b/tests/forging_blocks/domain/specification/test_composable_specification.py @@ -2,8 +2,8 @@ import pytest -from forging_blocks.foundation.specification.base import Specification -from forging_blocks.foundation.specification.composable import ComposableSpecification +from forging_blocks.domain.specification.base import Specification +from forging_blocks.domain.specification.composable import ComposableSpecification @pytest.mark.unit @@ -50,7 +50,7 @@ def is_satisfied_by(self, candidate: int) -> bool: result = left.and_(right) # Assert - from forging_blocks.foundation.specification.logical_operators import AndSpecification + from forging_blocks.domain.specification.logical_operators import AndSpecification assert isinstance(result, AndSpecification) assert result.is_satisfied_by(5) is True @@ -76,7 +76,7 @@ def is_satisfied_by(self, candidate: int) -> bool: result = left.or_(right) # Assert - from forging_blocks.foundation.specification.logical_operators import OrSpecification + from forging_blocks.domain.specification.logical_operators import OrSpecification assert isinstance(result, OrSpecification) assert result.is_satisfied_by(5) is True @@ -97,7 +97,7 @@ def is_satisfied_by(self, candidate: int) -> bool: result = spec.not_() # Assert - from forging_blocks.foundation.specification.logical_operators import NotSpecification + from forging_blocks.domain.specification.logical_operators import NotSpecification assert isinstance(result, NotSpecification) assert result.is_satisfied_by(-5) is True @@ -122,7 +122,7 @@ def is_satisfied_by(self, candidate: int) -> bool: result = left & right # Assert - from forging_blocks.foundation.specification.logical_operators import AndSpecification + from forging_blocks.domain.specification.logical_operators import AndSpecification assert isinstance(result, AndSpecification) assert result.is_satisfied_by(5) is True @@ -146,7 +146,7 @@ def is_satisfied_by(self, candidate: int) -> bool: result = left | right # Assert - from forging_blocks.foundation.specification.logical_operators import OrSpecification + from forging_blocks.domain.specification.logical_operators import OrSpecification assert isinstance(result, OrSpecification) assert result.is_satisfied_by(5) is True @@ -165,7 +165,7 @@ def is_satisfied_by(self, candidate: int) -> bool: result = ~spec # Assert - from forging_blocks.foundation.specification.logical_operators import NotSpecification + from forging_blocks.domain.specification.logical_operators import NotSpecification assert isinstance(result, NotSpecification) assert result.is_satisfied_by(-5) is True diff --git a/tests/forging_blocks/domain/specification/test_expression_specification.py b/tests/forging_blocks/domain/specification/test_expression_specification.py index 60577696..a2e0719f 100644 --- a/tests/forging_blocks/domain/specification/test_expression_specification.py +++ b/tests/forging_blocks/domain/specification/test_expression_specification.py @@ -3,8 +3,8 @@ import pytest +from forging_blocks.domain.specification.expression import ExpressionSpecification from forging_blocks.foundation.errors.not_callable_predicate_error import NotCallablePredicateError -from forging_blocks.foundation.specification.expression import ExpressionSpecification @pytest.mark.unit @@ -204,7 +204,7 @@ def test_repr_when_builtin_function_then_uses_function_name(self) -> None: def test_is_subclass_of_composable_specification(self) -> None: """ExpressionSpecification should be a subclass of ComposableSpecification.""" # Arrange & Act & Assert - from forging_blocks.foundation.specification.composable import ComposableSpecification + from forging_blocks.domain.specification.composable import ComposableSpecification assert issubclass(ExpressionSpecification, ComposableSpecification) diff --git a/tests/forging_blocks/domain/test_value_objects.py b/tests/forging_blocks/domain/test_value_objects.py index 8a10e8ba..69ca7983 100644 --- a/tests/forging_blocks/domain/test_value_objects.py +++ b/tests/forging_blocks/domain/test_value_objects.py @@ -1,8 +1,8 @@ # pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false import pytest +from forging_blocks.domain.value_object import ValueObject from forging_blocks.foundation import CantModifyImmutableAttributeError -from forging_blocks.foundation.value_object import ValueObject class Email(ValueObject[str]): diff --git a/tests/forging_blocks/infrastructure/event_buses/test_in_memory_event_bus.py b/tests/forging_blocks/infrastructure/event_buses/test_in_memory_event_bus.py index 11695f33..07c844ec 100644 --- a/tests/forging_blocks/infrastructure/event_buses/test_in_memory_event_bus.py +++ b/tests/forging_blocks/infrastructure/event_buses/test_in_memory_event_bus.py @@ -9,8 +9,8 @@ CommandHandlerPort, EventHandlerPort, ) -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.event import Event from forging_blocks.infrastructure.event_buses.in_memory_event_bus import ( InMemoryEventBus, ) diff --git a/tests/forging_blocks/infrastructure/event_buses/test_in_memory_event_bus_base.py b/tests/forging_blocks/infrastructure/event_buses/test_in_memory_event_bus_base.py index a00219af..96214002 100644 --- a/tests/forging_blocks/infrastructure/event_buses/test_in_memory_event_bus_base.py +++ b/tests/forging_blocks/infrastructure/event_buses/test_in_memory_event_bus_base.py @@ -9,8 +9,8 @@ CommandHandlerPort, EventHandlerPort, ) -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.event import Event +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.event import Event from forging_blocks.infrastructure.event_buses.event_bus_base import EventBusBase from forging_blocks.infrastructure.event_buses.in_memory_event_bus_base import ( InMemoryEventBusBase, diff --git a/tests/forging_blocks/infrastructure/message_bus/test_in_memory_message_bus.py b/tests/forging_blocks/infrastructure/message_bus/test_in_memory_message_bus.py index 3ea8ea7c..2b5f2598 100644 --- a/tests/forging_blocks/infrastructure/message_bus/test_in_memory_message_bus.py +++ b/tests/forging_blocks/infrastructure/message_bus/test_in_memory_message_bus.py @@ -2,9 +2,9 @@ import pytest -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.message import MessageMetadata -from forging_blocks.foundation.messages.query import Query +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.message import MessageMetadata +from forging_blocks.domain.messages.query import Query from forging_blocks.infrastructure.message_bus.in_memory_message_bus import ( InMemoryMessageBus, ) diff --git a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_command_sender.py b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_command_sender.py index cdfc2915..f36429e2 100644 --- a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_command_sender.py +++ b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_command_sender.py @@ -3,7 +3,7 @@ import pytest -from forging_blocks.foundation.messages import Command, MessageMetadata +from forging_blocks.domain.messages import Command, MessageMetadata from forging_blocks.infrastructure import MessageBusCommandSender from tests.fixtures.fake_message_bus import FakeMessageBus diff --git a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_event_publisher.py b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_event_publisher.py index e48dafb3..af5a2faa 100644 --- a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_event_publisher.py +++ b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_event_publisher.py @@ -3,7 +3,7 @@ import pytest -from forging_blocks.foundation.messages import Event, MessageMetadata +from forging_blocks.domain.messages import Event, MessageMetadata from forging_blocks.infrastructure import MessageBusEventPublisher from tests.fixtures.fake_message_bus import FakeMessageBus diff --git a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_query_fetcher.py b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_query_fetcher.py index 22440821..10e8fe5d 100644 --- a/tests/forging_blocks/infrastructure/message_bus/test_message_bus_query_fetcher.py +++ b/tests/forging_blocks/infrastructure/message_bus/test_message_bus_query_fetcher.py @@ -3,7 +3,7 @@ import pytest -from forging_blocks.foundation.messages import MessageMetadata, Query +from forging_blocks.domain.messages import MessageMetadata, Query from forging_blocks.infrastructure import MessageBusQueryFetcher from tests.fixtures.fake_message_bus import FakeMessageBus diff --git a/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py b/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py index bbc46b9a..397c8a7b 100644 --- a/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py +++ b/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py @@ -8,8 +8,8 @@ from forging_blocks.application.errors.event_store_error import EventStoreError from forging_blocks.domain.aggregate_root.aggregate_root import AggregateRoot -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.message import MessageMetadata from forging_blocks.foundation.result import Err, Result from forging_blocks.infrastructure.event_stores.in_memory_event_store_base import ( InMemoryEventStoreBase, diff --git a/tests/forging_blocks/infrastructure/repositories/test_in_memory_repository.py b/tests/forging_blocks/infrastructure/repositories/test_in_memory_repository.py index 5bcb5dbd..9db51ae0 100644 --- a/tests/forging_blocks/infrastructure/repositories/test_in_memory_repository.py +++ b/tests/forging_blocks/infrastructure/repositories/test_in_memory_repository.py @@ -4,8 +4,8 @@ import pytest +from forging_blocks.domain.specification import Specification from forging_blocks.foundation.identified import Identified -from forging_blocks.foundation.specification import Specification from forging_blocks.infrastructure.errors.repository_errors import ( RepositoryError, RepositoryNotFoundError, diff --git a/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py b/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py index 3748b417..82644e6e 100644 --- a/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py +++ b/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py @@ -2,15 +2,15 @@ import pytest -from forging_blocks.foundation.messages import ( +from forging_blocks.domain.messages import ( MessageMetadata, command_dataclass, event_dataclass, query_dataclass, ) -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.query import Query +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.query import Query from forging_blocks.infrastructure.serialization import DictMessageCodec PAYLOAD_MSG: dict[str, object] = {"_name": "write_in", "_value": 42} diff --git a/tests/forging_blocks/infrastructure/test_event_bus.py b/tests/forging_blocks/infrastructure/test_event_bus.py index 3860de11..0257b312 100644 --- a/tests/forging_blocks/infrastructure/test_event_bus.py +++ b/tests/forging_blocks/infrastructure/test_event_bus.py @@ -9,9 +9,9 @@ CommandHandlerPort, EventHandlerPort, ) -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.message import MessageMetadata from forging_blocks.infrastructure.event_buses.event_bus_base import EventBusBase from forging_blocks.infrastructure.event_buses.in_memory_event_bus_base import InMemoryEventBusBase from tests.fixtures.fake_event_with_value import FakeEventWithValue diff --git a/tests/forging_blocks/infrastructure/unit_of_work/test_in_memory_unit_of_work.py b/tests/forging_blocks/infrastructure/unit_of_work/test_in_memory_unit_of_work.py index 0dbda5d8..e060460d 100644 --- a/tests/forging_blocks/infrastructure/unit_of_work/test_in_memory_unit_of_work.py +++ b/tests/forging_blocks/infrastructure/unit_of_work/test_in_memory_unit_of_work.py @@ -5,8 +5,8 @@ from forging_blocks.application import UnitOfWorkError from forging_blocks.domain.aggregate_root import AggregateRoot -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.message import MessageMetadata from forging_blocks.infrastructure.unit_of_work.in_memory_unit_of_work import ( InMemoryUnitOfWork, ) diff --git a/tests/scripts/release/infrastructure/bus/test_release_event_bus.py b/tests/scripts/release/infrastructure/bus/test_release_event_bus.py index 8798eb62..726f1d79 100644 --- a/tests/scripts/release/infrastructure/bus/test_release_event_bus.py +++ b/tests/scripts/release/infrastructure/bus/test_release_event_bus.py @@ -7,8 +7,8 @@ ) from forging_blocks.application.ports.inbound.message_handler_port import MessageHandlerPort -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.message import MessageMetadata class FakeCommand(Command): From 18abd5b84f06122f2560710e0c47573068490ec7 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 16:03:19 -0300 Subject: [PATCH 66/86] refactor(domain): update script imports from foundation to domain modules --- .../release/application/ports/outbound/release_command_bus.py | 2 +- .../release/application/services/prepare_release_service.py | 2 +- scripts/release/domain/commands/open_pull_request_command.py | 4 ++-- .../infrastructure/bus/in_memory_release_command_bus.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/release/application/ports/outbound/release_command_bus.py b/scripts/release/application/ports/outbound/release_command_bus.py index 375b20f4..e1db1c25 100644 --- a/scripts/release/application/ports/outbound/release_command_bus.py +++ b/scripts/release/application/ports/outbound/release_command_bus.py @@ -2,7 +2,7 @@ from forging_blocks.application.ports.inbound.message_handler_port import MessageHandlerPort from forging_blocks.application.ports.outbound.message_bus_port import MessageBusPort -from forging_blocks.foundation.messages.command import Command +from forging_blocks.domain.messages.command import Command CommandType = TypeVar("CommandType", bound=Command[Any], contravariant=True) diff --git a/scripts/release/application/services/prepare_release_service.py b/scripts/release/application/services/prepare_release_service.py index 2bcea49b..bd220f5e 100644 --- a/scripts/release/application/services/prepare_release_service.py +++ b/scripts/release/application/services/prepare_release_service.py @@ -1,6 +1,6 @@ from typing import Any -from forging_blocks.foundation.messages.command import Command +from forging_blocks.domain.messages.command import Command from scripts.release.application.ports.inbound import ( PrepareReleaseInput, PrepareReleaseOutput, diff --git a/scripts/release/domain/commands/open_pull_request_command.py b/scripts/release/domain/commands/open_pull_request_command.py index 58aa1bb9..07193e1a 100644 --- a/scripts/release/domain/commands/open_pull_request_command.py +++ b/scripts/release/domain/commands/open_pull_request_command.py @@ -1,7 +1,7 @@ from typing import Self, TypeAlias, cast -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.message import MessageMetadata +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.message import MessageMetadata PayloadType: TypeAlias = dict[str, str | bool] diff --git a/scripts/release/infrastructure/bus/in_memory_release_command_bus.py b/scripts/release/infrastructure/bus/in_memory_release_command_bus.py index d97cc4e5..946b3875 100644 --- a/scripts/release/infrastructure/bus/in_memory_release_command_bus.py +++ b/scripts/release/infrastructure/bus/in_memory_release_command_bus.py @@ -1,7 +1,7 @@ from typing import Any, TypeAlias, cast from forging_blocks.application.ports.inbound.message_handler_port import MessageHandlerPort -from forging_blocks.foundation.messages.command import Command +from forging_blocks.domain.messages.command import Command from scripts.release.application.ports.outbound import ReleaseCommandBus CommandSubscriberType: TypeAlias = dict[type[Command[Any]], MessageHandlerPort[Command[Any], None]] From e2f0f148d190c94ad4ed919132809b9aa2e33eee Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 16:03:35 -0300 Subject: [PATCH 67/86] docs(domain): update reference pages and fix links for domain module move Update documentation to reflect the move of ValueObject, Messages, and Specifications from Foundation to Domain: - foundation.md: remove moved sections from Foundation overview - domain.md: update overview to list new domain modules - domain/value-objects.md, specifications.md: update origin references - auto-eq.md, auto-freeze.md: fix value-objects.md cross-reference links - Guide docs: align import examples with new domain module paths --- docs/guide/examples.md | 2 +- docs/guide/getting-started.md | 2 +- docs/reference/domain.md | 3 +++ docs/reference/domain/specifications.md | 2 +- docs/reference/domain/value-objects.md | 2 +- docs/reference/foundation.md | 15 --------------- docs/reference/foundation/auto-eq.md | 2 +- docs/reference/foundation/auto-freeze.md | 2 +- 8 files changed, 9 insertions(+), 21 deletions(-) diff --git a/docs/guide/examples.md b/docs/guide/examples.md index 28137c4b..5394e3e6 100644 --- a/docs/guide/examples.md +++ b/docs/guide/examples.md @@ -127,7 +127,7 @@ The design is: ## 4. Modeling a value with ValueObject ```python -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class Email(ValueObject[str]): diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index a6848215..b69d1fdc 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -49,7 +49,7 @@ When a value is more than a primitive, you can wrap it in a `ValueObject` to make its rules visible and reusable. ```python -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class Email(ValueObject[str]): diff --git a/docs/reference/domain.md b/docs/reference/domain.md index 2babdcb7..f17436eb 100644 --- a/docs/reference/domain.md +++ b/docs/reference/domain.md @@ -35,6 +35,7 @@ The Domain block is the innermost ring. It imports nothing from outer layers. Wh - **[Value Object](domain/value-objects.md)** — Immutable, defined by its values; prevents primitive obsession - **[Aggregate Root](domain/aggregates.md)** — Consistency boundary; controls mutation and invariants - **[Specification](domain/specifications.md)** — Composable predicates for business rules, querying, and validation +- **[Messages](domain/messages.md)** — Command, Event, Query (immutable, architecture-neutral) - **[Domain Errors](domain/errors.md)** — Invalid states and rule violations in domain terms - **[Validators](domain/validators.md)** — Concrete validation rules (RequiredValidator, EmailValidator, LengthValidator, RangeValidator) - **[Permissions](domain/permissions.md)** — Composable permission checkers (RoleBased, ResourceBased, Composite) @@ -61,3 +62,5 @@ The Domain block is the innermost ring. It imports nothing from outer layers. Wh !!! note "Specification" A composable predicate over a candidate object for business rules, querying, and validation. +!!! note "Message" + An immutable dataclass representing a command, event, or query. diff --git a/docs/reference/domain/specifications.md b/docs/reference/domain/specifications.md index 31ee550a..d7dc574f 100644 --- a/docs/reference/domain/specifications.md +++ b/docs/reference/domain/specifications.md @@ -9,4 +9,4 @@ A `Specification` encapsulates a rule evaluated with `is_satisfied_by(candidate) Subclass `Specification` and implement `is_satisfied_by(candidate) → bool`. Compose with `&`, `|`, `~` instead of nesting if-statements. Each specification is a single, testable unit of logic. !!! note "Where the implementation lives" - The specification pattern is defined in the [Foundation](../foundation.md) block because composable predicates are reusable outside the Domain block. The Domain block re-exports it. + The specification pattern is defined in the Domain block alongside Entity and AggregateRoot. It is imported from `forging_blocks.domain.specification`. diff --git a/docs/reference/domain/value-objects.md b/docs/reference/domain/value-objects.md index e95b560a..0343d3c4 100644 --- a/docs/reference/domain/value-objects.md +++ b/docs/reference/domain/value-objects.md @@ -19,4 +19,4 @@ Inherit from `ValueObject` when you need immutability, value-based equality, and Value Objects prevent domain rules from being scattered across the codebase. Wrapping meaning in explicit types makes constraints visible, reusable, and testable. !!! note "Where the implementation lives" - The `ValueObject` base class lives in the [Foundation](../foundation.md) block because value-based equality and immutability are reusable outside the Domain block. + The `ValueObject` base class lives in the Domain block alongside Entity and AggregateRoot. It is imported from `forging_blocks.domain.value_object`. diff --git a/docs/reference/foundation.md b/docs/reference/foundation.md index 411dec57..7474ac11 100644 --- a/docs/reference/foundation.md +++ b/docs/reference/foundation.md @@ -15,8 +15,6 @@ Each abstraction serves one focused purpose: - `Result` replaces exceptions for predictable control flow. - `Port` defines boundaries as protocols — what is expected, not how. - `Error` gives structure to failure with messages and metadata. -- `ValueObject` adds immutability, value equality, and hashing. -- `Specification` composes business rules into testable predicates. These are not patterns you must use everywhere. They are tools you reach for when plain Python types stop communicating intent clearly enough. @@ -27,7 +25,6 @@ Start with the abstractions that give the most immediate value: 1. **`Result`** — Replace functions that return `None` on failure or raise exceptions for control flow. Return `Ok(value)` or `Err(error)` instead. 2. **`Port`** — Define a protocol for any dependency you might swap later: repositories, event buses, loggers. -3. **`ValueObject`** — Wrap primitive values when validation logic is scattered across functions. The Foundation block is pure Python — standard library only. It introduces no framework dependencies. @@ -37,10 +34,7 @@ The Foundation block is pure Python — standard library only. It introduces no - **[Result](foundation/result.md)** — Explicit Ok/Err outcomes without exceptions for control flow - **[Ports](foundation/ports.md)** — Boundaries between components (InboundPort, OutboundPort) - **[Errors](foundation/errors.md)** — Structured error model (message + metadata, validation, rule violation, combined) -- **[Messages](foundation/messages.md)** — Command, Event, Query (immutable, architecture-neutral) -- **[Value Objects](foundation/value-objects.md)** — Immutable, value-based equality, hashing - **[Auto-Freeze](foundation/auto-freeze.md)** — Lightweight immutability without inheriting from ValueObject -- **[Specifications](foundation/specifications.md)** — Composable predicates (and/or/not) - **[Mappers](foundation/mappers.md)** — Explicit transformations between types - **[Identified](foundation/identified.md)** — Protocol for objects carrying an identifier - **[Meta Utilities](foundation/meta.md)** — Runtime enforcement (final, sealed, abstract) @@ -66,12 +60,3 @@ The Foundation block is pure Python — standard library only. It introduces no !!! note "Error" A structured failure with a message and metadata, used across all layers. - -!!! note "Message" - An immutable dataclass representing a command, event, or query. - -!!! note "ValueObject" - An immutable object with value-based equality and hashing. - -!!! note "Specification" - A composable predicate over a candidate object for rules, validation, and filtering. diff --git a/docs/reference/foundation/auto-eq.md b/docs/reference/foundation/auto-eq.md index 30c138d5..c7dafe56 100644 --- a/docs/reference/foundation/auto-eq.md +++ b/docs/reference/foundation/auto-eq.md @@ -12,7 +12,7 @@ After decoration, instances compare equal when all selected fields match. Compar ## When to use -`@auto_eq` is useful when you need structural equality without hashability — for example, mutable objects where hashing would be incorrect. For immutable value types that need both equality and hashing, use [`@auto_hash`](auto-hash.md) or the [`ValueObject`](value-objects.md) base class instead. +`@auto_eq` is useful when you need structural equality without hashability — for example, mutable objects where hashing would be incorrect. For immutable value types that need both equality and hashing, use [`@auto_hash`](auto-hash.md) or the [`ValueObject`](../domain/value-objects.md) base class instead. ## Generated members diff --git a/docs/reference/foundation/auto-freeze.md b/docs/reference/foundation/auto-freeze.md index 6e27661a..701b2daf 100644 --- a/docs/reference/foundation/auto-freeze.md +++ b/docs/reference/foundation/auto-freeze.md @@ -17,7 +17,7 @@ After `__init__` returns, any attempt to assign a frozen attribute raises `CantM `auto_freeze` is an alternative to inheriting from `ValueObject`. - If your class already has its own base class and you only need immutability — without value-based equality or hashing — prefer `@auto_freeze`. -- If you also need equality-by-value, use the [ValueObject](value-objects.md) base class instead. +- If you also need equality-by-value, use the [ValueObject](../domain/value-objects.md) base class instead. Both are optional; you can use neither. From fad3210e600ad5c8e3f6b887cb2a4b240322d654 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 16:08:04 -0300 Subject: [PATCH 68/86] fix(domain): lazify aggregate_root import to break circular dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AggregateRoot and AggregateVersion are now loaded via module __getattr__ to avoid the foundation → context → domain → aggregate_root → foundation cycle introduced by moving ValueObject to the domain layer. --- src/forging_blocks/domain/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/forging_blocks/domain/__init__.py b/src/forging_blocks/domain/__init__.py index 66f48df7..82a2453e 100644 --- a/src/forging_blocks/domain/__init__.py +++ b/src/forging_blocks/domain/__init__.py @@ -1,6 +1,5 @@ """ForgingBlocks for domain-specific modules.""" -from .aggregate_root import AggregateRoot, AggregateVersion from .entity import Entity from .errors import ( DraftEntityIsNotHashableError, @@ -55,3 +54,16 @@ "Specification", "ValueObject", ] + + +def __getattr__(name: str): + if name == "AggregateRoot": + from .aggregate_root.aggregate_root import AggregateRoot + + return AggregateRoot + if name == "AggregateVersion": + from .aggregate_root.aggregate_version import AggregateVersion + + return AggregateVersion + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) From 0d71784e6dcae7d450126a9650b8afcbd2eb83ff Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 16:19:24 -0300 Subject: [PATCH 69/86] docs: sync mkdocs nav entries with domain module move Remove stale Foundation nav entries for Messages, Value Objects, and Specifications. Add Messages to Domain nav section. --- mkdocs.yml | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 3 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 63d46ac9..537721f0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,10 +78,7 @@ nav: - Result: reference/foundation/result.md - Ports: reference/foundation/ports.md - Errors: reference/foundation/errors.md - - Messages: reference/foundation/messages.md - - Value Objects: reference/foundation/value-objects.md - Auto-Freeze: reference/foundation/auto-freeze.md - - Specifications: reference/foundation/specifications.md - Mappers: reference/foundation/mappers.md - Identified: reference/foundation/identified.md - Meta Utilities: reference/foundation/meta.md @@ -91,6 +88,7 @@ nav: - Overview: reference/domain.md - Entities: reference/domain/entities.md - Value Objects: reference/domain/value-objects.md + - Messages: reference/domain/messages.md - Aggregate Roots: reference/domain/aggregates.md - Specifications: reference/domain/specifications.md - Validators: reference/domain/validators.md @@ -111,6 +109,173 @@ nav: - Adapters: reference/presentation/adapters.md - Error Handling: reference/presentation/error-handling.md - Middleware: reference/presentation/middleware.md + - API Reference: + - Application: + - Errors: + - Concurrency Error: reference/autodoc/application/errors/concurrency_error.md + - Event Bus Error: reference/autodoc/application/errors/event_bus_error.md + - Event Store Error: reference/autodoc/application/errors/event_store_error.md + - Transaction Error: reference/autodoc/application/errors/transaction_error.md + - Unit Of Work Error: reference/autodoc/application/errors/unit_of_work_error.md + - Ports / Inbound: + - Application Service Port: reference/autodoc/application/ports/inbound/application_service_port.md + - Authorization Port: reference/autodoc/application/ports/inbound/authorization_port.md + - Message Handler Port: reference/autodoc/application/ports/inbound/message_handler_port.md + - Validation Port: reference/autodoc/application/ports/inbound/validation_port.md + - Ports / Outbound: + - Cache Port: reference/autodoc/application/ports/outbound/cache_port.md + - Command Sender Port: reference/autodoc/application/ports/outbound/command_sender_port.md + - Event Bus Port: reference/autodoc/application/ports/outbound/event_bus_port.md + - Event Publisher Port: reference/autodoc/application/ports/outbound/event_publisher_port.md + - Event Store Port: reference/autodoc/application/ports/outbound/event_store_port.md + - File System Port: reference/autodoc/application/ports/outbound/file_system_port.md + - Http Client Port: reference/autodoc/application/ports/outbound/http_client_port.md + - Logger Port: reference/autodoc/application/ports/outbound/logger_port.md + - Message Bus Port: reference/autodoc/application/ports/outbound/message_bus_port.md + - Notifier Port: reference/autodoc/application/ports/outbound/notifier_port.md + - Query Fetcher Port: reference/autodoc/application/ports/outbound/query_fetcher_port.md + - Specification Repository Port: reference/autodoc/application/ports/outbound/specification_repository_port.md + - Transaction Manager Port: reference/autodoc/application/ports/outbound/transaction_manager_port.md + - Unit Of Work Port: reference/autodoc/application/ports/outbound/unit_of_work_port.md + - Ports / Outbound / Repository Port: + - Read Only Repository Port: reference/autodoc/application/ports/outbound/repository_port/_read_only_repository_port.md + - Repository Port: reference/autodoc/application/ports/outbound/repository_port/_repository_port.md + - Write Only Repository Port: reference/autodoc/application/ports/outbound/repository_port/_write_only_repository_port.md + - Domain: + - Entity: reference/autodoc/domain/entity.md + - Value Object: reference/autodoc/domain/value_object.md + - Aggregate Root: + - Aggregate Root: reference/autodoc/domain/aggregate_root/aggregate_root.md + - Aggregate Version: reference/autodoc/domain/aggregate_root/aggregate_version.md + - Errors: + - Draft Entity Is Not Hashable Error: reference/autodoc/domain/errors/draft_entity_is_not_hashable_error.md + - Entity Id Deletion Error: reference/autodoc/domain/errors/entity_id_deletion_error.md + - Entity Id Modification Error: reference/autodoc/domain/errors/entity_id_modification_error.md + - Entity Id None Error: reference/autodoc/domain/errors/entity_id_none_error.md + - Messages: + - Command: reference/autodoc/domain/messages/command.md + - Decorators: reference/autodoc/domain/messages/decorators.md + - Event: reference/autodoc/domain/messages/event.md + - Query: reference/autodoc/domain/messages/query.md + - Messages / Message: + - Message: reference/autodoc/domain/messages/message/_message.md + - Metadata: reference/autodoc/domain/messages/message/_metadata.md + - Permissions: + - Composite Permission Checker: reference/autodoc/domain/permissions/composite_permission_checker.md + - Permission Checker: reference/autodoc/domain/permissions/permission_checker.md + - Resource Permission Checker: reference/autodoc/domain/permissions/resource_permission_checker.md + - Role Based Permission Checker: reference/autodoc/domain/permissions/role_based_permission_checker.md + - Specification: + - Base: reference/autodoc/domain/specification/base.md + - Composable: reference/autodoc/domain/specification/composable.md + - Expression: reference/autodoc/domain/specification/expression.md + - Specification / Logical Operators: + - And Specification: reference/autodoc/domain/specification/logical_operators/and_specification.md + - Not Specification: reference/autodoc/domain/specification/logical_operators/not_specification.md + - Or Specification: reference/autodoc/domain/specification/logical_operators/or_specification.md + - Validators: + - Composite Validation Rule: reference/autodoc/domain/validators/composite_validation_rule.md + - Email Validator: reference/autodoc/domain/validators/email_validator.md + - Length Validator: reference/autodoc/domain/validators/length_validator.md + - Range Validator: reference/autodoc/domain/validators/range_validator.md + - Required Validator: reference/autodoc/domain/validators/required_validator.md + - Foundation: + - Debuggable: reference/autodoc/foundation/debuggable.md + - Identified: reference/autodoc/foundation/identified.md + - Mapper: reference/autodoc/foundation/mapper.md + - Permission: reference/autodoc/foundation/permission.md + - Autoeq: + - Auto Eq: reference/autodoc/foundation/autoeq/auto_eq.md + - Autofreeze: + - Auto Freeze: reference/autodoc/foundation/autofreeze/auto_freeze.md + - Autohash: + - Auto Hash: reference/autodoc/foundation/autohash/auto_hash.md + - Context: + - Authorization Context: reference/autodoc/foundation/context/authorization_context.md + - Service Context: reference/autodoc/foundation/context/service_context.md + - Transaction Context: reference/autodoc/foundation/context/transaction_context.md + - Errors: + - Architecture Error: reference/autodoc/foundation/errors/architecture_error.md + - Cant Modify Immutable Attribute Error: reference/autodoc/foundation/errors/cant_modify_immutable_attribute_error.md + - Combined Errors: reference/autodoc/foundation/errors/combined_errors.md + - Configuration Error: reference/autodoc/foundation/errors/configuration_error.md + - Core: reference/autodoc/foundation/errors/core.md + - Error: reference/autodoc/foundation/errors/error.md + - Field Errors: reference/autodoc/foundation/errors/field_errors.md + - Non Hashable Value Error: reference/autodoc/foundation/errors/non_hashable_value_error.md + - None Not Allowed Error: reference/autodoc/foundation/errors/none_not_allowed_error.md + - Not Callable Predicate Error: reference/autodoc/foundation/errors/not_callable_predicate_error.md + - Result Access Error: reference/autodoc/foundation/errors/result_access_error.md + - Rule Violation Error: reference/autodoc/foundation/errors/rule_violation_error.md + - Validation Error: reference/autodoc/foundation/errors/validation_error.md + - Meta: + - Final Abc Meta: reference/autodoc/foundation/meta/final_abc_meta.md + - Final Meta: reference/autodoc/foundation/meta/final_meta.md + - Ports: + - Check Methods: reference/autodoc/foundation/ports/_check_methods.md + - Inbound Port: reference/autodoc/foundation/ports/_inbound_port.md + - Outbound Port: reference/autodoc/foundation/ports/_outbound_port.md + - Port: reference/autodoc/foundation/ports/_port.md + - Result: + - Err: reference/autodoc/foundation/result/err.md + - Ok: reference/autodoc/foundation/result/ok.md + - Result: reference/autodoc/foundation/result/result.md + - Rules: + - Validation Rule: reference/autodoc/foundation/rules/validation_rule.md + - Infrastructure: + - Caching: + - In Memory Cache: reference/autodoc/infrastructure/caching/in_memory_cache.md + - Errors: + - Repository Errors: reference/autodoc/infrastructure/errors/repository_errors.md + - Event Buses: + - Event Bus Base: reference/autodoc/infrastructure/event_buses/event_bus_base.md + - In Memory Event Bus: reference/autodoc/infrastructure/event_buses/in_memory_event_bus.md + - In Memory Event Bus Base: reference/autodoc/infrastructure/event_buses/in_memory_event_bus_base.md + - Event Stores: + - Event Store Base: reference/autodoc/infrastructure/event_stores/event_store_base.md + - In Memory Event Store: reference/autodoc/infrastructure/event_stores/in_memory_event_store.md + - In Memory Event Store Base: reference/autodoc/infrastructure/event_stores/in_memory_event_store_base.md + - File System: + - Os File System: reference/autodoc/infrastructure/file_system/os_file_system.md + - Http Client: + - Urllib Client: reference/autodoc/infrastructure/http_client/urllib_client.md + - Logging: + - Stdlib Logger: reference/autodoc/infrastructure/logging/stdlib_logger.md + - Message Bus: + - In Memory Message Bus: reference/autodoc/infrastructure/message_bus/in_memory_message_bus.md + - Message Bus Command Sender: reference/autodoc/infrastructure/message_bus/message_bus_command_sender.md + - Message Bus Event Publisher: reference/autodoc/infrastructure/message_bus/message_bus_event_publisher.md + - Message Bus Query Fetcher: reference/autodoc/infrastructure/message_bus/message_bus_query_fetcher.md + - Repositories: + - Aggregate Repository: reference/autodoc/infrastructure/repositories/aggregate_repository.md + - In Memory Read Repository: reference/autodoc/infrastructure/repositories/in_memory_read_repository.md + - In Memory Repository: reference/autodoc/infrastructure/repositories/in_memory_repository.md + - In Memory Write Repository: reference/autodoc/infrastructure/repositories/in_memory_write_repository.md + - Serialization: + - Dict Message Codec: reference/autodoc/infrastructure/serialization/_dict_message_codec.md + - Message Codec: reference/autodoc/infrastructure/serialization/_message_codec.md + - Unit Of Work: + - In Memory Unit Of Work: reference/autodoc/infrastructure/unit_of_work/in_memory_unit_of_work.md + - Presentation: + - Presenter Contract: reference/autodoc/presentation/presenter_contract.md + - Adapters: + - Presentation Adapter: reference/autodoc/presentation/adapters/presentation_adapter.md + - Request Adapter: reference/autodoc/presentation/adapters/request_adapter.md + - Response Adapter: reference/autodoc/presentation/adapters/response_adapter.md + - Builtin: + - Error Handling Middleware: reference/autodoc/presentation/builtin/error_handling_middleware.md + - Logging Middleware: reference/autodoc/presentation/builtin/logging_middleware.md + - Timing Middleware: reference/autodoc/presentation/builtin/timing_middleware.md + - Validation Middleware: reference/autodoc/presentation/builtin/validation_middleware.md + - Errors: + - Error Message Model: reference/autodoc/presentation/errors/error_message_model.md + - Error Presenter: reference/autodoc/presentation/errors/error_presenter.md + - Error Status Code Mapper: reference/autodoc/presentation/errors/error_status_code_mapper.md + - Error View Model: reference/autodoc/presentation/errors/error_view_model.md + - Middleware: + - Middleware: reference/autodoc/presentation/middleware/middleware.md + - Next Handler: reference/autodoc/presentation/middleware/next_handler.md + - Pipeline: reference/autodoc/presentation/middleware/pipeline.md - Architectural Styles: - Overview: architectural-styles/index.md - Clean Architecture: architectural-styles/clean-architecture.md From f4a9927dade6620638ae2fdcd3f0d9e6653eb189 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 17:22:09 -0300 Subject: [PATCH 70/86] docs(foundation): create comprehensive auto-decorators reference page --- docs/reference/foundation/auto-decorators.md | 326 +++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/reference/foundation/auto-decorators.md diff --git a/docs/reference/foundation/auto-decorators.md b/docs/reference/foundation/auto-decorators.md new file mode 100644 index 00000000..ed470d34 --- /dev/null +++ b/docs/reference/foundation/auto-decorators.md @@ -0,0 +1,326 @@ +# Auto Decorators + +`@auto_freeze`, `@auto_eq`, and `@auto_hash` are lightweight decorators that add immutability, structural equality, and hashing to plain Python classes — no inheritance from a base class required. + +--- + +## `@auto_freeze` + +Enforces immutability after `__init__` completes. After construction, any attempt to assign a frozen attribute raises `CantModifyImmutableAttributeError`. + +### Usage + +- `@auto_freeze` — Freezes the entire instance +- `@auto_freeze()` — Equivalent, explicit parens form +- `@auto_freeze(attrs=["_id"])` — Selectively freeze specific attributes only + +The decorator handles `__init__` nesting (inheritance chains) and skips abstract classes. + +```python +from forging_blocks.foundation import auto_freeze + +@auto_freeze +class Money: + __slots__ = ("_amount", "_currency") + + def __init__(self, amount: int, currency: str) -> None: + self._amount = amount + self._currency = currency.upper() + +m = Money(100, "usd") +m._amount = 200 # raises CantModifyImmutableAttributeError +``` + +Selective freezing: + +```python +from forging_blocks.foundation import auto_freeze + +@auto_freeze(attrs=["_id"]) +class User: + __slots__ = ("_id", "_name") + + def __init__(self, user_id: str, name: str) -> None: + self._id = user_id + self._name = name + +u = User("usr-1", "Alice") +u._name = "Bob" # ok — not frozen +u._id = "usr-2" # raises CantModifyImmutableAttributeError +``` + +`auto_freeze` injects a `__setattr__` override and a freeze flag. It detects existing custom `__setattr__` implementations and avoids double-wrapping. + +--- + +## `@auto_eq` + +Generates `__eq__` for class instances based on their fields. Does **not** generate `__hash__` — combine with `@auto_hash` when hashability is required. + +### Usage + +- `@auto_eq` — Equality on all fields +- `@auto_eq()` — Equivalent, explicit parens form +- `@auto_eq(fields=["x", "y"])` — Equality on specific fields only + +Comparison is type-strict: `type(self) is type(other)` must be true, so subclasses are not equal to their parent. + +### Generated members + +- `__eq__` — structural equality on the resolved fields +- `__auto_eq_fields__` — tuple of field names used in equality, available for introspection + +### Examples + +```python +from forging_blocks.foundation import auto_eq + +@auto_eq +class Point: + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + +p1 = Point(1, 2) +p2 = Point(1, 2) +p3 = Point(3, 4) + +assert p1 == p2 +assert p1 != p3 +``` + +With explicit field selection: + +```python +from forging_blocks.foundation import auto_eq + +@auto_eq(fields=["x"]) +class Point: + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + +# y is ignored — only x matters +assert Point(1, 2) == Point(1, 999) +assert Point(1, 2) != Point(2, 999) +``` + +--- + +## `@auto_hash` + +Generates `__hash__` for class instances based on their fields. Mutable values (lists, dicts) are automatically converted to hashable equivalents (tuples, frozensets). Does **not** generate `__eq__` — combine with `@auto_eq` for explicit equality. + +### Usage + +- `@auto_hash` — Hash on all fields +- `@auto_hash()` — Equivalent, explicit parens form +- `@auto_hash(fields=["x", "y"])` — Hash on specific fields only + +Equal objects produce equal hashes — suitable for sets and dict keys. Raises `NonHashableValueError` at hash time if a field value cannot be converted to a hashable type. + +### Generated members + +- `__hash__` — hash computed from the resolved field values, with mutable-to-hashable conversion +- `__auto_hash_fields__` — tuple of field names used in hashing, available for introspection + +### Examples + +```python +from forging_blocks.foundation import auto_hash + +@auto_hash +class Point: + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + +p1 = Point(1, 2) +p2 = Point(1, 2) +p3 = Point(3, 4) + +assert hash(p1) == hash(p2) +assert hash(p1) != hash(p3) +``` + +With mutable field conversion: + +```python +from forging_blocks.foundation import auto_hash + +@auto_hash +class Tagged: + __slots__ = ("name", "tags") + + def __init__(self, name: str, tags: list[str]) -> None: + self.name = name + self.tags = tags + +a = Tagged("item", ["a", "b"]) +b = Tagged("item", ["a", "b"]) +assert hash(a) == hash(b) # list converted to tuple automatically +``` + +Selective fields: + +```python +from forging_blocks.foundation import auto_hash + +@auto_hash(fields=["id"]) +class Entity: + __slots__ = ("id", "name") + + def __init__(self, id: str, name: str) -> None: + self.id = id + self.name = name + +e1 = Entity("1", "Alice") +e2 = Entity("1", "Bob") +assert hash(e1) == hash(e2) # only id matters +``` + +--- + +## Combining Decorators + +The three auto decorators compose with each other and with `@dataclass`. Stacking order matters: + +| Position | Decorator | Role | +|----------|-----------|------| +| Top (outermost) | `@auto_hash` | Generates `__hash__` | +| | `@auto_eq` | Generates `__eq__` | +| | `@auto_freeze` | Enforces immutability | +| Bottom (innermost) | `@dataclass` | Generates `__init__`, `__repr__`, etc. | + +!!! note + `@auto_freeze` must be applied before `@dataclass` but after `@auto_eq` / `@auto_hash` so that equality and hashing test the frozen fields. + +--- + +### `auto_eq` + `auto_hash` (POPO) + +Combining `@auto_hash` and `@auto_eq` on a plain Python class gives both structural equality and hashability: + +```python +from forging_blocks.foundation import auto_hash, auto_eq + +@auto_hash +@auto_eq +class Point: + __slots__ = ("x", "y") + + def __init__(self, x: float, y: float) -> None: + self.x = x + self.y = y + +p1 = Point(1.0, 2.0) +p2 = Point(1.0, 2.0) + +assert p1 == p2 # structural equality +assert hash(p1) == hash(p2) # consistent hashing +assert p1 in {p2} # usable in sets +``` + +--- + +### `auto_freeze` with Other Decorators (POPO) + +Add `@auto_freeze` below `@auto_eq` / `@auto_hash` to make the class immutable after construction: + +```python +from forging_blocks.foundation import auto_hash, auto_eq, auto_freeze + +@auto_hash +@auto_eq +@auto_freeze +class Point: + __slots__ = ("x", "y") + + def __init__(self, x: float, y: float) -> None: + self.x = x + self.y = y + +p = Point(1.0, 2.0) +p.x = 5.0 # raises CantModifyImmutableAttributeError +``` + +--- + +### With `@dataclass` + +When combining with `@dataclass`, place the auto decorators above it (outermost first). `@dataclass` generates `__eq__` by default, so `@auto_eq` is optional here. + +#### `@auto_hash` only + +```python +from dataclasses import dataclass +from forging_blocks.foundation import auto_hash + +@auto_hash +@dataclass +class User: + id: str + name: str + +u1 = User("1", "Alice") +u2 = User("1", "Alice") +d = {u1: "data"} +assert d[u2] == "data" +``` + +#### Full stack with `@dataclass` + +```python +from dataclasses import dataclass +from forging_blocks.foundation import auto_hash, auto_eq + +@auto_hash +@auto_eq +@dataclass +class Point: + x: float + y: float + +p1 = Point(1.0, 2.0) +p2 = Point(1.0, 2.0) +assert p1 == p2 +assert hash(p1) == hash(p2) +``` + +#### Full stack with `@auto_freeze` + +```python +from dataclasses import dataclass +from forging_blocks.foundation import auto_hash, auto_eq, auto_freeze + +@auto_hash +@auto_eq +@auto_freeze +@dataclass +class Money: + amount: int + currency: str + +m = Money(100, "usd") +m.amount = 200 # raises CantModifyImmutableAttributeError +``` + +--- + +## Decorator Stacking Order + +The decorator order determines how each layer sees the class: + +1. `@auto_hash` (top) — receives the class after `@auto_eq` and `@auto_freeze` / `@dataclass` have applied. It generates `__hash__` based on the fields visible at that point. +2. `@auto_eq` — receives the class after `@auto_freeze` / `@dataclass` have applied. It generates `__eq__`. +3. `@auto_freeze` — receives the class after `@dataclass` (if present). It wraps `__setattr__` to prevent mutations. +4. `@dataclass` (bottom) — generates `__init__`, `__repr__`, and optionally `__eq__`. + +!!! note "Why `@auto_freeze` goes below `@auto_eq` / `@auto_hash`" + Placing `@auto_freeze` below `@auto_eq` / `@auto_hash` ensures the equality and hash methods are generated before `@auto_freeze` potentially alters attribute access patterns. The freeze applies after all structural methods are in place. From 86da4e69c9808d98f38d715c4362a3cdd682d0aa Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 17:22:12 -0300 Subject: [PATCH 71/86] docs(foundation): remove individual auto decorator reference pages --- docs/reference/foundation/auto-eq.md | 79 ------------------------ docs/reference/foundation/auto-freeze.md | 25 -------- docs/reference/foundation/auto-hash.md | 77 ----------------------- 3 files changed, 181 deletions(-) delete mode 100644 docs/reference/foundation/auto-eq.md delete mode 100644 docs/reference/foundation/auto-freeze.md delete mode 100644 docs/reference/foundation/auto-hash.md diff --git a/docs/reference/foundation/auto-eq.md b/docs/reference/foundation/auto-eq.md deleted file mode 100644 index c7dafe56..00000000 --- a/docs/reference/foundation/auto-eq.md +++ /dev/null @@ -1,79 +0,0 @@ -# Auto-Equality (`auto_eq`) - -The `@auto_eq` decorator generates `__eq__` for class instances based on their fields. It does **not** generate `__hash__` — combine with [`@auto_hash`](auto-hash.md) when hashability is required. - -## Usage - -- `@auto_eq` — Equality on all fields -- `@auto_eq()` — Equivalent, explicit parens form -- `@auto_eq(fields=["x", "y"])` — Equality on specific fields only - -After decoration, instances compare equal when all selected fields match. Comparison is type-strict: `type(self) is type(other)` must be true, so subclasses of the same type are not equal to their parent. - -## When to use - -`@auto_eq` is useful when you need structural equality without hashability — for example, mutable objects where hashing would be incorrect. For immutable value types that need both equality and hashing, use [`@auto_hash`](auto-hash.md) or the [`ValueObject`](../domain/value-objects.md) base class instead. - -## Generated members - -The decorated class receives: - -- `__eq__` — structural equality on the resolved fields -- `__auto_eq_fields__` — tuple of field names used in equality, available for introspection - -## Examples - -```python -from dataclasses import dataclass -from forging_blocks.foundation import auto_eq - -@auto_eq -@dataclass -class Point: - x: int - y: int - -p1 = Point(1, 2) -p2 = Point(1, 2) -p3 = Point(3, 4) - -assert p1 == p2 -assert p1 != p3 -``` - -With explicit field selection: - -```python -from dataclasses import dataclass -from forging_blocks.foundation import auto_eq - -@auto_eq(fields=["x"]) -@dataclass -class Point: - x: int - y: int - -# y is ignored — only x matters -assert Point(1, 2) == Point(1, 999) -assert Point(1, 2) != Point(2, 999) -``` - -Combined with `@auto_hash` for hashability: - -```python -from dataclasses import dataclass -from forging_blocks.foundation import auto_eq, auto_hash - -@auto_hash -@auto_eq -@dataclass -class Money: - amount: int - currency: str - -m1 = Money(100, "USD") -m2 = Money(100, "USD") -s = {m1, m2} -assert len(s) == 1 # hashing works -assert m1 == m2 # equality works -``` diff --git a/docs/reference/foundation/auto-freeze.md b/docs/reference/foundation/auto-freeze.md deleted file mode 100644 index 701b2daf..00000000 --- a/docs/reference/foundation/auto-freeze.md +++ /dev/null @@ -1,25 +0,0 @@ -# Auto-Freeze - -The `@auto_freeze` decorator enforces immutability after `__init__` completes — no `frozen=True` dataclass boilerplate needed. - -## Usage - -Apply to any class: - -- `@auto_freeze` — Freezes the entire instance -- `@auto_freeze()` — Equivalent, explicit parens form -- `@auto_freeze(attrs=["_id"])` — Selectively freeze specific attributes only - -After `__init__` returns, any attempt to assign a frozen attribute raises `CantModifyImmutableAttributeError`. The decorator handles `__init__` nesting (inheritance chains) and skips abstract classes. - -## When to use - -`auto_freeze` is an alternative to inheriting from `ValueObject`. - -- If your class already has its own base class and you only need immutability — without value-based equality or hashing — prefer `@auto_freeze`. -- If you also need equality-by-value, use the [ValueObject](../domain/value-objects.md) base class instead. - -Both are optional; you can use neither. - -!!! note "Design" - `auto_freeze` injects a `__setattr__` override and a freeze flag. It detects existing custom `__setattr__` implementations and avoids double-wrapping. diff --git a/docs/reference/foundation/auto-hash.md b/docs/reference/foundation/auto-hash.md deleted file mode 100644 index 30a15871..00000000 --- a/docs/reference/foundation/auto-hash.md +++ /dev/null @@ -1,77 +0,0 @@ -# Auto-Hash (`auto_hash`) - -The `@auto_hash` decorator generates `__hash__` for class instances based on their fields. Mutable values (lists, dicts) are automatically converted to hashable equivalents (tuples, frozensets) during hashing. It does **not** generate ``__eq__`` — ``__eq__`` in the examples below is provided by ``@dataclass``. Combine with [`@auto_eq`](auto-eq.md) for explicit equality. - -## Usage - -- `@auto_hash` — Hash on all fields -- `@auto_hash()` — Equivalent, explicit parens form -- `@auto_hash(fields=["x", "y"])` — Hash on specific fields only - -After decoration, equal objects produce equal hashes — suitable for use in sets and dict keys. The decorator raises `NonHashableValueError` at hash time if a field value cannot be converted to a hashable type. - -## When to use - -`@auto_hash` is ideal for value types that need hashing. If you also need equality, use [`@auto_eq`](auto-eq.md) or rely on ``@dataclass`` (which generates ``__eq__`` by default). If you only need immutability without structural comparison, use [`@auto_freeze`](auto-freeze.md). - -## Generated members - -The decorated class receives: - -- `__hash__` — hash computed from the resolved field values, with mutable-to-hashable conversion -- `__auto_hash_fields__` — tuple of field names used in hashing, available for introspection - -## Examples - -```python -from dataclasses import dataclass -from forging_blocks.foundation import auto_hash - -@auto_hash -@dataclass -class Point: - x: int - y: int - -p1 = Point(1, 2) -p2 = Point(1, 2) -p3 = Point(3, 4) - -assert hash(p1) == hash(p2) -assert hash(p1) != hash(p3) -assert p1 == p2 -``` - -With mutable field conversion: - -```python -from dataclasses import dataclass -from forging_blocks.foundation import auto_hash - -@auto_hash -@dataclass -class Tagged: - name: str - tags: list[str] - -a = Tagged("item", ["a", "b"]) -b = Tagged("item", ["a", "b"]) -assert hash(a) == hash(b) # list converted to tuple automatically -``` - -Selective fields: - -```python -from dataclasses import dataclass -from forging_blocks.foundation import auto_hash - -@auto_hash(fields=["id"]) -@dataclass -class Entity: - id: str - name: str - -e1 = Entity("1", "Alice") -e2 = Entity("1", "Bob") -assert hash(e1) == hash(e2) # only id matters -``` From 20adff6222cf6901a86497d0d2633e389ba47b19 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 17:22:16 -0300 Subject: [PATCH 72/86] docs: update cross-references to auto-decorators page --- docs/guide/examples.md | 2 +- docs/reference/foundation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/examples.md b/docs/guide/examples.md index 5394e3e6..52ddb1a4 100644 --- a/docs/guide/examples.md +++ b/docs/guide/examples.md @@ -82,7 +82,7 @@ class Task(Entity[int]): return self ``` -`Entity` uses **selective freezing** via `@auto_freeze(attrs=["_id"])` — the identity field (`_id`) is frozen after `__init__`, while other attributes remain mutable. This ensures the entity's identity never changes, while its state can evolve. See [Domain > Entities](../reference/domain/entities.md) for why identity matters, and [Foundation > Auto-freeze](../reference/foundation/auto-freeze.md) for the mechanism. +`Entity` uses **selective freezing** via `@auto_freeze(attrs=["_id"])` — the identity field (`_id`) is frozen after `__init__`, while other attributes remain mutable. This ensures the entity's identity never changes, while its state can evolve. See [Domain > Entities](../reference/domain/entities.md) for why identity matters, and [Foundation > Auto Decorators](../reference/foundation/auto-decorators.md) for the mechanism. --- diff --git a/docs/reference/foundation.md b/docs/reference/foundation.md index 7474ac11..2fef86c7 100644 --- a/docs/reference/foundation.md +++ b/docs/reference/foundation.md @@ -34,7 +34,7 @@ The Foundation block is pure Python — standard library only. It introduces no - **[Result](foundation/result.md)** — Explicit Ok/Err outcomes without exceptions for control flow - **[Ports](foundation/ports.md)** — Boundaries between components (InboundPort, OutboundPort) - **[Errors](foundation/errors.md)** — Structured error model (message + metadata, validation, rule violation, combined) -- **[Auto-Freeze](foundation/auto-freeze.md)** — Lightweight immutability without inheriting from ValueObject +- **[Auto Decorators](foundation/auto-decorators.md)** — Immutability, equality, and hashing decorators (auto_freeze, auto_eq, auto_hash) - **[Mappers](foundation/mappers.md)** — Explicit transformations between types - **[Identified](foundation/identified.md)** — Protocol for objects carrying an identifier - **[Meta Utilities](foundation/meta.md)** — Runtime enforcement (final, sealed, abstract) From 3e13380e2ed58944dc0edf83f31aa5f94393a912 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 17:22:19 -0300 Subject: [PATCH 73/86] docs(foundation): clean auto-freeze module docstring examples --- src/forging_blocks/foundation/autofreeze/auto_freeze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/foundation/autofreeze/auto_freeze.py b/src/forging_blocks/foundation/autofreeze/auto_freeze.py index 442df4bd..e4e27564 100644 --- a/src/forging_blocks/foundation/autofreeze/auto_freeze.py +++ b/src/forging_blocks/foundation/autofreeze/auto_freeze.py @@ -7,7 +7,7 @@ Can be used as ``@auto_freeze``, ``@auto_freeze()``, or ``@auto_freeze(attrs=[...])`` for selective freezing. -Useful for: Value objects, immutable data types, and any class that +Useful for: Immutable data types and any class that should be immutable after construction. Example: From 1f92e5ee1ba1c815473d786a3f52abda907a7abd Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 17:22:23 -0300 Subject: [PATCH 74/86] docs(foundation): clean auto-eq module docstrings and README --- .../foundation/autoeq/README.md | 18 ++++---- .../foundation/autoeq/auto_eq.py | 42 ++++++++++++------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/forging_blocks/foundation/autoeq/README.md b/src/forging_blocks/foundation/autoeq/README.md index 3cd6f061..bfb1fa31 100644 --- a/src/forging_blocks/foundation/autoeq/README.md +++ b/src/forging_blocks/foundation/autoeq/README.md @@ -7,28 +7,30 @@ Generates equality based on class fields but does **not** generate === "Bare decorator" ```python - from dataclasses import dataclass from forging_blocks.foundation.autoeq import auto_eq @auto_eq - @dataclass class Point: - x: int - y: int + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y assert Point(1, 2) == Point(1, 2) ``` === "With parameters" ```python - from dataclasses import dataclass from forging_blocks.foundation.autoeq import auto_eq @auto_eq(fields=["x"]) - @dataclass class Point: - x: int - y: int + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y assert Point(1, 2) == Point(1, 999) ``` diff --git a/src/forging_blocks/foundation/autoeq/auto_eq.py b/src/forging_blocks/foundation/autoeq/auto_eq.py index 01f888ed..a1b675b0 100644 --- a/src/forging_blocks/foundation/autoeq/auto_eq.py +++ b/src/forging_blocks/foundation/autoeq/auto_eq.py @@ -1,8 +1,7 @@ """Auto-eq decorator for generating ``__eq__`` on class instances. Provides the `auto_eq` decorator that generates ``__eq__`` based on -class fields. Works with ``@dataclass`` (``@auto_eq`` must be the outermost -decorator — apply it *above* ``@dataclass``) and on plain classes with +class fields. Works on plain classes with ``__slots__`` or ``__annotations__``. Can be used as ``@auto_eq``, ``@auto_eq()``, or @@ -11,21 +10,21 @@ class fields. Works with ``@dataclass`` (``@auto_eq`` must be the outermost Does NOT generate ``__hash__`` — use `auto_hash` when hashability is required. -Useful for: Value objects, plain data classes, and any type that requires +Useful for: Plain data classes and any type that requires structural equality comparisons. Example: ```python - from dataclasses import dataclass - from forging_blocks.foundation.autoeq import auto_eq @auto_eq - @dataclass class Point: - x: int - y: int + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y p1 = Point(1, 2) @@ -35,6 +34,20 @@ class Point: assert p1 != p3 ``` + With selective fields: + ```python + @auto_eq(fields=["x"]) + class Point: + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + + + assert Point(1, 2) == Point(1, 999) + ``` + """ from collections.abc import Callable, Sequence @@ -58,8 +71,8 @@ def __init__(self, *, fields: Sequence[str] | None = None) -> None: Args: fields: Specific field names to compare in equality. - When ``None``, all dataclass fields (or ``__slots__``/ - ``__annotations__`` keys) are used. + When ``None``, field names are resolved from ``__slots__`` + (across the MRO) or ``__annotations__`` keys. """ self._fields = fields @@ -124,17 +137,16 @@ def auto_eq[T]( generate ``__hash__``. Use `auto_hash` when hashability is required. - When the class is a ``@dataclass``, *fields* defaults to all declared - dataclass fields (via ``dataclasses.fields``). Otherwise it falls back - to ``__slots__`` (across the MRO) or ``__annotations__`` keys. + Field names are resolved from ``__slots__`` (across the MRO) or + ``__annotations__`` keys when *fields* is ``None``. Args: class_: The target class (when used directly as ``@auto_eq``). ``None`` when used with parentheses (``@auto_eq()`` or ``@auto_eq(fields=...)``). fields: Optional sequence of field names to include in equality. - When ``None``, all dataclass fields (or ``__slots__``/ - ``__annotations__`` keys) are used. + When ``None``, field names are resolved from ``__slots__`` + (across the MRO) or ``__annotations__`` keys. Returns: The decorated class if *class_* is provided; otherwise a callable From 1d8325e6affe3cddfa64ad501cf3e759df19b659 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 17:22:27 -0300 Subject: [PATCH 75/86] docs(foundation): clean auto-hash module docstrings and README --- .../foundation/autohash/README.md | 21 ++++----- .../foundation/autohash/auto_hash.py | 45 ++++++++++++------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/src/forging_blocks/foundation/autohash/README.md b/src/forging_blocks/foundation/autohash/README.md index 90027990..5dd65586 100644 --- a/src/forging_blocks/foundation/autohash/README.md +++ b/src/forging_blocks/foundation/autohash/README.md @@ -1,7 +1,6 @@ """The `auto_hash` decorator generates `__hash__` for class instances. -Does **not** generate ``__eq__`` or apply ``auto_freeze``. ``__eq__`` in the -examples below is provided by ``@dataclass``. Combine with +Does **not** generate ``__eq__`` or apply ``auto_freeze``. Combine with `auto_eq` for explicit equality, and `auto_freeze` for immutability. @@ -9,14 +8,15 @@ and `auto_freeze` for immutability. === "Bare decorator" ```python - from dataclasses import dataclass from forging_blocks.foundation.autohash import auto_hash @auto_hash - @dataclass class Point: - x: int - y: int + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y p1 = Point(1, 2) p2 = Point(1, 2) @@ -25,14 +25,15 @@ and `auto_freeze` for immutability. === "With parameters" ```python - from dataclasses import dataclass from forging_blocks.foundation.autohash import auto_hash @auto_hash(fields=["x"]) - @dataclass class Point: - x: int - y: int + __slots__ = ("x", "y") + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y p1 = Point(1, 2) p2 = Point(1, 999) diff --git a/src/forging_blocks/foundation/autohash/auto_hash.py b/src/forging_blocks/foundation/autohash/auto_hash.py index a6df6bb0..7e64149c 100644 --- a/src/forging_blocks/foundation/autohash/auto_hash.py +++ b/src/forging_blocks/foundation/autohash/auto_hash.py @@ -1,9 +1,8 @@ """Auto-hash decorator for generating ``__hash__`` on class instances. Provides the `auto_hash` decorator that generates ``__hash__`` -based on class fields. Works with ``@dataclass`` (``@auto_hash`` must be -the outermost decorator — apply it *above* ``@dataclass``) and on plain -classes with ``__slots__`` or ``__annotations__``. +based on class fields. Works on plain classes with ``__slots__`` or +``__annotations__``. Can be used as ``@auto_hash``, ``@auto_hash()``, or ``@auto_hash(fields=[...])`` to hash only specific attributes. @@ -11,19 +10,20 @@ Does NOT generate ``__eq__`` — combine with `auto_eq` when structural equality is needed alongside hashing. -Useful for: Value objects, hashable data types, and any type that requires +Useful for: Hashable data types and any type that requires consistent hashing for sets or dictionary keys. Example: ```python - from dataclasses import dataclass from forging_blocks.foundation.autohash import auto_hash @auto_hash - @dataclass class UserId: - value: str + __slots__ = ("value",) + + def __init__(self, value: str) -> None: + self.value = value u1 = UserId("abc") @@ -31,6 +31,22 @@ class UserId: assert hash(u1) == hash(u2) ``` + With selective fields: + ```python + @auto_hash(fields=["id"]) + class Entity: + __slots__ = ("id", "name") + + def __init__(self, id: str, name: str) -> None: + self.id = id + self.name = name + + + e1 = Entity("1", "Alice") + e2 = Entity("1", "Bob") + assert hash(e1) == hash(e2) + ``` + """ import dataclasses @@ -56,8 +72,8 @@ def __init__(self, *, fields: Sequence[str] | None = None) -> None: Args: fields: Specific field names to hash. When ``None``, all - dataclass fields (or ``__slots__``/``__annotations__`` keys) are - used. + all fields declared in ``__slots__`` or + ``__annotations__`` are used. """ self._fields = fields @@ -93,9 +109,8 @@ def _resolve_field_names(self, class_: type[object]) -> list[str]: Priority: 1. Explicit *fields* argument passed to the decorator. - 2. Dataclass fields (via ``dataclasses.fields``). - 3. ``__slots__`` across the full MRO, excluding dunder names. - 4. ``__annotations__`` if defined. + 2. ``__slots__`` across the full MRO, excluding dunder names. + 3. ``__annotations__`` if defined. Args: class_: The class being decorated. @@ -123,7 +138,7 @@ def _resolve_field_names(self, class_: type[object]) -> list[str]: return [k for k in annotations if not k.startswith("__")] msg = ( - f"Cannot determine hash fields for non-dataclass {class_.__name__}. " + f"Cannot determine hash fields for {class_.__name__}. " f"Pass fields= explicitly, e.g. @auto_hash(fields=['x', 'y'])." ) raise TypeError(msg) @@ -183,8 +198,8 @@ def auto_hash[T]( ``None`` when used with parentheses (``@auto_hash()`` or ``@auto_hash(fields=...)``). fields: Optional sequence of field names to include in the hash. - When ``None``, all dataclass fields (or ``__slots__``/ - ``__annotations__`` keys) are used. + When ``None``, all fields declared in ``__slots__`` or + ``__annotations__`` are used. Returns: The decorated class if *class_* is provided; otherwise a callable From 6fdc60a6f4d85a0d568b2cd11a0b54a5c7b7fd78 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 17:24:29 -0300 Subject: [PATCH 76/86] chore(mkdocs): replace auto freeze to auto decorators --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 537721f0..9f7a3ff8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,7 +78,7 @@ nav: - Result: reference/foundation/result.md - Ports: reference/foundation/ports.md - Errors: reference/foundation/errors.md - - Auto-Freeze: reference/foundation/auto-freeze.md + - Auto Decorators: reference/foundation/auto-decorators.md - Mappers: reference/foundation/mappers.md - Identified: reference/foundation/identified.md - Meta Utilities: reference/foundation/meta.md From 2050a5015aee81cb2a88e8efe37c198373121776 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 18:21:33 -0300 Subject: [PATCH 77/86] fix(auto-eq, auto-hash): use type.__setattr__ for attribute assignment on ABCMeta classes --- src/forging_blocks/foundation/autoeq/auto_eq.py | 2 +- src/forging_blocks/foundation/autohash/auto_hash.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/forging_blocks/foundation/autoeq/auto_eq.py b/src/forging_blocks/foundation/autoeq/auto_eq.py index a1b675b0..e78b320c 100644 --- a/src/forging_blocks/foundation/autoeq/auto_eq.py +++ b/src/forging_blocks/foundation/autoeq/auto_eq.py @@ -101,7 +101,7 @@ def _eq_impl(self: Any, other: object) -> bool: _eq_impl.__qualname__ = f"{class_.__name__}.__eq__" class_.__eq__ = _eq_impl - class_.__auto_eq_fields__ = _field_names + type.__setattr__(class_, "__auto_eq_fields__", _field_names) return class_ diff --git a/src/forging_blocks/foundation/autohash/auto_hash.py b/src/forging_blocks/foundation/autohash/auto_hash.py index 7e64149c..4aef4255 100644 --- a/src/forging_blocks/foundation/autohash/auto_hash.py +++ b/src/forging_blocks/foundation/autohash/auto_hash.py @@ -101,7 +101,7 @@ def _hash_impl(self: Any) -> int: _hash_impl.__qualname__ = f"{class_.__name__}.__hash__" class_.__hash__ = _hash_impl - class_.__auto_hash_fields__ = _field_names + type.__setattr__(class_, "__auto_hash_fields__", _field_names) return class_ def _resolve_field_names(self, class_: type[object]) -> list[str]: From f212692e5c4a081338add8feda7261db9460155d Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 18:21:37 -0300 Subject: [PATCH 78/86] fix(aggregate-repository): use UUID | None instead of unbounded TId --- .../infrastructure/repositories/aggregate_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py index b3062efe..98487b9a 100644 --- a/src/forging_blocks/infrastructure/repositories/aggregate_repository.py +++ b/src/forging_blocks/infrastructure/repositories/aggregate_repository.py @@ -81,7 +81,7 @@ async def save(self, aggregate: TAggregateRoot) -> None: """ events = cast(list[Event[EventPayloadType]], aggregate.uncommitted_changes) - aggregate_id: TId | None = aggregate.id + aggregate_id: UUID | None = aggregate.id if events and aggregate_id is not None: version = aggregate.version.value - len(events) result = await self._event_store.append_events( From eed0dfbf4242b4e05e6dd70e2ef34a58657a890c Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 18:21:42 -0300 Subject: [PATCH 79/86] refactor(domain): keep TYPE_CHECKING + __getattr__ for circular import safety The circular import is real: foundation/__init__ imports foundation.context.authorization_context which imports domain.value_object which triggers domain/__init__.py which attempts to import aggregate_root.aggregate_root which needs forging_blocks.foundation.FinalABCMeta -- but foundation is still loading. __getattr__ lazy-imports AggregateRoot and AggregateVersion to break the cycle. --- src/forging_blocks/domain/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/forging_blocks/domain/__init__.py b/src/forging_blocks/domain/__init__.py index 82a2453e..95e58bcc 100644 --- a/src/forging_blocks/domain/__init__.py +++ b/src/forging_blocks/domain/__init__.py @@ -1,5 +1,7 @@ """ForgingBlocks for domain-specific modules.""" +from typing import TYPE_CHECKING + from .entity import Entity from .errors import ( DraftEntityIsNotHashableError, @@ -26,6 +28,10 @@ from .validators.required_validator import RequiredValidator from .value_object import ValueObject +if TYPE_CHECKING: + from .aggregate_root.aggregate_root import AggregateRoot + from .aggregate_root.aggregate_version import AggregateVersion + __all__ = [ "AggregateRoot", "AggregateVersion", From 6d5fc580f762f338395f37b47af53c8016e1be99 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 18:21:51 -0300 Subject: [PATCH 80/86] test: add FieldResolver, DictMessageCodec, and domain init coverage tests --- tests/forging_blocks/domain/test_init.py | 11 +++ .../foundation/autoeq/test_field_resolver.py | 88 +++++++++++++++++++ .../serialization/test_dict_message_codec.py | 11 +++ 3 files changed, 110 insertions(+) create mode 100644 tests/forging_blocks/domain/test_init.py create mode 100644 tests/forging_blocks/foundation/autoeq/test_field_resolver.py diff --git a/tests/forging_blocks/domain/test_init.py b/tests/forging_blocks/domain/test_init.py new file mode 100644 index 00000000..6eb62fe5 --- /dev/null +++ b/tests/forging_blocks/domain/test_init.py @@ -0,0 +1,11 @@ +import importlib + +import pytest + + +class TestDomainInit: + def test_when_getattr_called_with_unknown_name_then_raises_attribute_error(self) -> None: + domain = importlib.import_module("forging_blocks.domain") + + with pytest.raises(AttributeError): + domain.__getattr__("NonExistent") diff --git a/tests/forging_blocks/foundation/autoeq/test_field_resolver.py b/tests/forging_blocks/foundation/autoeq/test_field_resolver.py new file mode 100644 index 00000000..59d91c92 --- /dev/null +++ b/tests/forging_blocks/foundation/autoeq/test_field_resolver.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import dataclasses + +import pytest + +from forging_blocks.foundation.autoeq.helpers.field_resolver import FieldResolver + + +@pytest.mark.unit +class TestFieldResolver: + def test_when_fields_explicit_then_returns_list(self) -> None: + class A: + def __init__(self) -> None: + self.x = 1 + + result = FieldResolver.resolve(A, fields=["x", "y"]) + assert result == ["x", "y"] + + def test_when_dataclass_then_returns_field_names(self) -> None: + @dataclasses.dataclass + class A: + x: int + y: str + + result = FieldResolver.resolve(A) + assert result == ["x", "y"] + + def test_when_slots_present_then_returns_sorted_slot_names(self) -> None: + class A: + __slots__ = ("z", "a", "m") + + def __init__(self, z: int, a: str, m: float) -> None: + self.z = z + self.a = a + self.m = m + + result = FieldResolver.resolve(A) + assert result == ["a", "m", "z"] + + def test_when_slots_single_element_then_returns_slot(self) -> None: + class A: + __slots__ = ("only_slot",) + + def __init__(self, only_slot: int) -> None: + self.only_slot = only_slot + + result = FieldResolver.resolve(A) + assert result == ["only_slot"] + + def test_when_slots_from_inheritance_chain_then_returns_all(self) -> None: + class Base: + __slots__ = ("x",) + + class Child(Base): + __slots__ = ("y",) + + result = FieldResolver.resolve(Child) + assert result == ["x", "y"] + + def test_when_slots_exclude_dunder_names(self) -> None: + class A: + __slots__ = ("__private", "public", "__another_private") + + def __init__(self) -> None: + self.public = 1 + + result = FieldResolver.resolve(A) + assert result == ["public"] + + def test_when_no_slots_but_annotations_present_then_returns_annotation_keys(self) -> None: + class A: + x: int + y: str + + def __init__(self, x: int, y: str) -> None: + self.x = x + self.y = y + + result = FieldResolver.resolve(A) + assert sorted(result) == ["x", "y"] + + def test_when_no_explicit_fields_slots_or_annotations_then_raises_typeerror(self) -> None: + class A: + pass + + with pytest.raises(TypeError, match="Cannot determine eq fields"): + FieldResolver.resolve(A) diff --git a/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py b/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py index 82644e6e..968a63e9 100644 --- a/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py +++ b/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py @@ -138,3 +138,14 @@ def test_decode_preserves_message_type_in_metadata(self) -> None: decoded = e_codec.decode(encoded, SimpleEvent) assert decoded.metadata.message_type == "SimpleEvent" + + def test_decode_raises_value_error_when_required_metadata_key_missing(self) -> None: + original = SimpleEvent(name="test") + e_codec: DictMessageCodec[SimpleEvent] = DictMessageCodec() + encoded = e_codec.encode(original) + del encoded["metadata"]["message_id"] + + with pytest.raises( + ValueError, match="Missing required key 'message_id' in message metadata" + ): + e_codec.decode(encoded, SimpleEvent) From de1f61284280e1cbaa8ba56e27d01d77d89d61ea Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 18:29:18 -0300 Subject: [PATCH 81/86] test(field-resolver): add coverage for string-form __slots__ conversion --- .../foundation/autoeq/test_field_resolver.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/forging_blocks/foundation/autoeq/test_field_resolver.py b/tests/forging_blocks/foundation/autoeq/test_field_resolver.py index 59d91c92..230e3967 100644 --- a/tests/forging_blocks/foundation/autoeq/test_field_resolver.py +++ b/tests/forging_blocks/foundation/autoeq/test_field_resolver.py @@ -86,3 +86,15 @@ class A: with pytest.raises(TypeError, match="Cannot determine eq fields"): FieldResolver.resolve(A) + + def test_when_slots_is_string_then_converts_to_tuple(self) -> None: + class A: + __slots__ = ("only_slot",) + + def __init__(self, only_slot: int) -> None: + self.only_slot = only_slot + + type.__setattr__(A, "__slots__", "only_slot") + + result = FieldResolver.resolve(A) + assert result == ["only_slot"] From af8ab3cbb5280f3c69baecf288a81448156fd806 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 18:29:26 -0300 Subject: [PATCH 82/86] test(decorators): add coverage for from_payload_fields, get_payload_fields, and frozen enforcement --- .../domain/messages/test_decorators.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/forging_blocks/domain/messages/test_decorators.py b/tests/forging_blocks/domain/messages/test_decorators.py index ff053343..729bf5ea 100644 --- a/tests/forging_blocks/domain/messages/test_decorators.py +++ b/tests/forging_blocks/domain/messages/test_decorators.py @@ -87,3 +87,60 @@ class Shipped(Event[dict[str, object]]): b = Shipped(tracking_code="TRK-001") assert a != b, "messages with different message_ids should not be equal" + + def test_from_payload_fields_raises_typeerror_when_unknown_keys_present(self) -> None: + from forging_blocks.domain.messages.decorators import event_dataclass + from forging_blocks.domain.messages.event import Event + from forging_blocks.domain.messages.message import MessageMetadata + + @event_dataclass + class Shipped(Event[dict[str, object]]): + tracking_code: str + + with pytest.raises(TypeError, match="Unknown field"): + Shipped.from_payload_fields( + {"tracking_code": "TRK-001", "garbage": "bad"}, + MessageMetadata(message_type="Shipped", message_id="test-id"), + ) + + def test_from_payload_fields_succeeds_when_all_keys_match(self) -> None: + from forging_blocks.domain.messages.decorators import event_dataclass + from forging_blocks.domain.messages.event import Event + from forging_blocks.domain.messages.message import MessageMetadata + + @event_dataclass + class Shipped(Event[dict[str, object]]): + tracking_code: str + + result = Shipped.from_payload_fields( + {"tracking_code": "TRK-001"}, + MessageMetadata(message_type="Shipped", message_id="test-id"), + ) + assert result.tracking_code == "TRK-001" + assert result.metadata.message_type == "Shipped" + + def test_get_payload_fields_returns_non_private_dataclass_fields(self) -> None: + from forging_blocks.domain.messages.decorators import event_dataclass + from forging_blocks.domain.messages.event import Event + + @event_dataclass + class Shipped(Event[dict[str, object]]): + tracking_code: str + + msg = Shipped(tracking_code="TRK-001") + result = msg.get_payload_fields() + assert result == {"tracking_code": "TRK-001"} + + def test_setattr_on_frozen_message_raises_frozen_instance_error(self) -> None: + from forging_blocks.domain.messages.decorators import event_dataclass + from forging_blocks.domain.messages.event import Event + + @event_dataclass + class Shipped(Event[dict[str, object]]): + tracking_code: str + + msg = Shipped(tracking_code="TRK-001") + import dataclasses + + with pytest.raises(dataclasses.FrozenInstanceError, match="cannot assign to field"): + msg.tracking_code = "changed" From aacb68d3e6ddc7e9b35b61d94da614bb1e647561 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 19:36:42 -0300 Subject: [PATCH 83/86] refactor(foundation): remove concrete context value objects AuthorizationContext, ServiceContext, and TransactionContext are too application-specific for the reusable library. Downstream code should define its own context types and supply them via the generic type parameters on PermissionChecker, AuthorizationPort, and TransactionManagerPort. --- docs/reference/foundation.md | 2 +- docs/reference/foundation/context.md | 53 ----------- mkdocs.yml | 1 - src/forging_blocks/foundation/__init__.py | 4 - .../foundation/context/__init__.py | 11 --- .../context/authorization_context.py | 90 ------------------- .../foundation/context/service_context.py | 71 --------------- .../foundation/context/transaction_context.py | 75 ---------------- .../foundation/context/__init__.py | 0 .../context/test_authorization_context.py | 31 ------- .../context/test_service_context.py | 39 -------- .../context/test_transaction_context.py | 33 ------- 12 files changed, 1 insertion(+), 409 deletions(-) delete mode 100644 docs/reference/foundation/context.md delete mode 100644 src/forging_blocks/foundation/context/__init__.py delete mode 100644 src/forging_blocks/foundation/context/authorization_context.py delete mode 100644 src/forging_blocks/foundation/context/service_context.py delete mode 100644 src/forging_blocks/foundation/context/transaction_context.py delete mode 100644 tests/forging_blocks/foundation/context/__init__.py delete mode 100644 tests/forging_blocks/foundation/context/test_authorization_context.py delete mode 100644 tests/forging_blocks/foundation/context/test_service_context.py delete mode 100644 tests/forging_blocks/foundation/context/test_transaction_context.py diff --git a/docs/reference/foundation.md b/docs/reference/foundation.md index 2fef86c7..6b9874b6 100644 --- a/docs/reference/foundation.md +++ b/docs/reference/foundation.md @@ -38,7 +38,7 @@ The Foundation block is pure Python — standard library only. It introduces no - **[Mappers](foundation/mappers.md)** — Explicit transformations between types - **[Identified](foundation/identified.md)** — Protocol for objects carrying an identifier - **[Meta Utilities](foundation/meta.md)** — Runtime enforcement (final, sealed, abstract) -- **[Context](foundation/context.md)** — Immutable context objects (ServiceContext, AuthorizationContext, TransactionContext) + - **[Rules](foundation/rules.md)** — Composable validation rules (ValidationRule) --- diff --git a/docs/reference/foundation/context.md b/docs/reference/foundation/context.md deleted file mode 100644 index 9483de26..00000000 --- a/docs/reference/foundation/context.md +++ /dev/null @@ -1,53 +0,0 @@ -# Context - -Context objects carry metadata through application boundaries — tracing identifiers, user identity, permissions, and transaction state. - -## AuthorizationContext - -`AuthorizationContext` bundles the information needed for a single authorization decision: the requesting user, their roles, the target resource, and the action being performed. - -### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `user_id` | `str` | Unique identifier of the user requesting access | -| `roles` | `list[str] \| None` | Roles assigned to the user | -| `resource_id` | `str \| None` | Identifier of the resource being accessed | -| `resource_type` | `str \| None` | Discriminator for the resource kind | -| `action` | `str \| None` | Name of the action being performed | -| `metadata` | `dict[str, Any] \| None` | Arbitrary key-value pairs for cross-cutting concerns | - -## ServiceContext - -`ServiceContext` carries cross-cutting metadata through every application-service call: a correlation identifier for tracing, the authenticated user, and their permissions. - -### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `correlation_id` | `UUID` | Unique identifier tracing the entire request lifecycle (auto-generated) | -| `user_id` | `str \| None` | Identifier of the authenticated user | -| `permissions` | `list[str]` | Permissions held by the current user | -| `metadata` | `dict[str, Any]` | Arbitrary key-value pairs for tracing and feature flags | - -## TransactionContext - -`TransactionContext` carries metadata for a single transactional boundary: a unique transaction identifier, the timestamp it began, and the requested isolation level. - -### Fields - -| Field | Type | Description | -|-------|------|-------------| -| `transaction_id` | `UUID` | Unique identifier for the transaction (auto-generated) | -| `started_at` | `datetime` | UTC timestamp when the transaction began | -| `isolation_level` | `IsolationLevel` | Requested isolation level (defaults to `READ_COMMITTED`) | -| `metadata` | `dict[str, Any] \| None` | Arbitrary key-value pairs for cross-cutting concerns | - -## When to use - -Pass a `ServiceContext` into every application service call for tracing and identity propagation. Use `AuthorizationContext` to bundle the inputs for a permission check. Wrap transactional operations with `TransactionContext` to carry transaction metadata through the boundary. - -All context objects are **immutable** — once created, their attributes cannot change. This guarantees that context does not mutate as it passes through layers. - -!!! note "Related" - See [Application Ports](../application/ports.md) for how contexts are consumed by inbound and outbound ports. diff --git a/mkdocs.yml b/mkdocs.yml index 9f7a3ff8..e8bd60bb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,7 +82,6 @@ nav: - Mappers: reference/foundation/mappers.md - Identified: reference/foundation/identified.md - Meta Utilities: reference/foundation/meta.md - - Context: reference/foundation/context.md - Rules: reference/foundation/rules.md - Domain: - Overview: reference/domain.md diff --git a/src/forging_blocks/foundation/__init__.py b/src/forging_blocks/foundation/__init__.py index 4f2cb5ba..4e66444e 100644 --- a/src/forging_blocks/foundation/__init__.py +++ b/src/forging_blocks/foundation/__init__.py @@ -2,7 +2,6 @@ from .autoeq import auto_eq from .autohash import auto_hash -from .context import AuthorizationContext, ServiceContext, TransactionContext from .errors import ( ArchitectureError, CantModifyImmutableAttributeError, @@ -38,7 +37,6 @@ "auto_hash", "ArchitectureError", "ConfigurationError", - "AuthorizationContext", "CombinedErrors", "CombinedRuleViolationErrors", "CombinedValidationErrors", @@ -63,8 +61,6 @@ "ResultAccessError", "RuleViolationError", "runtime_final", - "ServiceContext", - "TransactionContext", "ValidationError", "ValidationFieldErrors", "ValidationRule", diff --git a/src/forging_blocks/foundation/context/__init__.py b/src/forging_blocks/foundation/context/__init__.py deleted file mode 100644 index 318c053f..00000000 --- a/src/forging_blocks/foundation/context/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Context objects carried through application boundaries.""" - -from .authorization_context import AuthorizationContext -from .service_context import ServiceContext -from .transaction_context import TransactionContext - -__all__ = [ - "AuthorizationContext", - "ServiceContext", - "TransactionContext", -] diff --git a/src/forging_blocks/foundation/context/authorization_context.py b/src/forging_blocks/foundation/context/authorization_context.py deleted file mode 100644 index a2ce889e..00000000 --- a/src/forging_blocks/foundation/context/authorization_context.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Authorization context bundled for a single authorization decision.""" - -from collections.abc import Hashable - -from forging_blocks.domain.value_object import ValueObject - - -class AuthorizationContext(ValueObject[tuple[Hashable, ...]]): - """Bundles the information needed for a single authorization decision. - - Args: - user_id: The unique identifier of the user requesting access. - roles: Roles assigned to the user (e.g. ``("admin", "editor")``). - resource_id: Optional identifier of the resource being accessed. - resource_type: Optional discriminator for the resource kind - (e.g. ``"document"``, ``"project"``). - action: Optional name of the action being performed - (e.g. ``"publish"``, ``"archive"``). - metadata: Arbitrary key-value pairs that checkers may inspect. - - """ - - __slots__ = ( - "_user_id", - "_roles", - "_resource_id", - "_resource_type", - "_action", - "_metadata", - ) - - def __init__( - self, - user_id: str, - *, - roles: tuple[str, ...] = (), - resource_id: str | None = None, - resource_type: str | None = None, - action: str | None = None, - metadata: tuple[tuple[str, Hashable], ...] = (), - ) -> None: - super().__init__() - self._user_id = user_id - self._roles = roles - self._resource_id = resource_id - self._resource_type = resource_type - self._action = action - self._metadata = metadata - - @property - def user_id(self) -> str: - """The unique identifier of the user requesting access.""" - return self._user_id - - @property - def roles(self) -> tuple[str, ...]: - """Roles assigned to the user (e.g. ``("admin", "editor")``).""" - return self._roles - - @property - def resource_id(self) -> str | None: - """Optional identifier of the resource being accessed.""" - return self._resource_id - - @property - def resource_type(self) -> str | None: - """Optional discriminator for the resource kind.""" - return self._resource_type - - @property - def action(self) -> str | None: - """Optional name of the action being performed.""" - return self._action - - @property - def metadata(self) -> tuple[tuple[str, Hashable], ...]: - """Arbitrary key-value pairs that checkers may inspect.""" - return self._metadata - - @property - def value(self) -> tuple[Hashable, ...]: - """Composite value: all fields as a tuple.""" - return ( - self._user_id, - self._roles, - self._resource_id, - self._resource_type, - self._action, - self._metadata, - ) diff --git a/src/forging_blocks/foundation/context/service_context.py b/src/forging_blocks/foundation/context/service_context.py deleted file mode 100644 index ac7acbc6..00000000 --- a/src/forging_blocks/foundation/context/service_context.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Immutable context carried through every application-service call.""" - -import uuid -from collections.abc import Hashable - -from forging_blocks.domain.value_object import ValueObject - - -class ServiceContext(ValueObject[tuple[Hashable, ...]]): - """Immutable context carried through every application-service call. - - Args: - correlation_id: A unique identifier that traces the entire - request/response lifecycle. Auto-generated when omitted. - user_id: The identifier of the authenticated user, if any. - permissions: The permissions held by the current user. - metadata: Arbitrary key-value pairs for cross-cutting concerns - (tracing, feature flags, tenant id, etc.). - - """ - - __slots__ = ( - "_correlation_id", - "_user_id", - "_permissions", - "_metadata", - ) - - def __init__( - self, - *, - correlation_id: uuid.UUID | None = None, - user_id: str | None = None, - permissions: tuple[str, ...] | None = None, - metadata: tuple[tuple[str, Hashable], ...] | None = None, - ) -> None: - super().__init__() - self._correlation_id = correlation_id if correlation_id is not None else uuid.uuid4() - self._user_id = user_id - self._permissions = permissions if permissions is not None else () - self._metadata = metadata if metadata is not None else () - - @property - def correlation_id(self) -> uuid.UUID: - """A unique identifier that traces the entire request/response lifecycle.""" - return self._correlation_id - - @property - def user_id(self) -> str | None: - """The identifier of the authenticated user, if any.""" - return self._user_id - - @property - def permissions(self) -> tuple[str, ...]: - """The permissions held by the current user.""" - return self._permissions - - @property - def metadata(self) -> tuple[tuple[str, Hashable], ...]: - """Arbitrary key-value pairs for cross-cutting concerns.""" - return self._metadata - - @property - def value(self) -> tuple[Hashable, ...]: - """Composite value: all fields as a tuple.""" - return ( - self._correlation_id, - self._user_id, - self._permissions, - self._metadata, - ) diff --git a/src/forging_blocks/foundation/context/transaction_context.py b/src/forging_blocks/foundation/context/transaction_context.py deleted file mode 100644 index 099d9cdb..00000000 --- a/src/forging_blocks/foundation/context/transaction_context.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Metadata for a single transactional boundary.""" - -import uuid -from collections.abc import Hashable -from datetime import datetime, timezone - -from forging_blocks.domain.value_object import ValueObject - - -class TransactionContext(ValueObject[tuple[Hashable, ...]]): - """Metadata for a single transactional boundary. - - Args: - transaction_id: Unique identifier for the transaction. - Auto-generated when omitted. - started_at: UTC timestamp marking when the transaction began. - Defaults to the current time. - isolation_level: The isolation level requested for this - transaction (e.g. ``"read_committed"``, ``"serializable"``). - Any ``str``-compatible type (including ``StrEnum`` subclasses) - is accepted. Defaults to ``"read_committed"``. - metadata: Arbitrary key-value pairs for cross-cutting concerns. - - """ - - __slots__ = ( - "_transaction_id", - "_started_at", - "_isolation_level", - "_metadata", - ) - - def __init__( - self, - *, - transaction_id: uuid.UUID | None = None, - started_at: datetime | None = None, - isolation_level: str = "read_committed", - metadata: tuple[tuple[str, Hashable], ...] = (), - ) -> None: - super().__init__() - self._transaction_id = transaction_id if transaction_id is not None else uuid.uuid4() - self._started_at = started_at if started_at is not None else datetime.now(timezone.utc) - self._isolation_level = isolation_level - self._metadata = metadata - - @property - def transaction_id(self) -> uuid.UUID: - """Unique identifier for the transaction.""" - return self._transaction_id - - @property - def started_at(self) -> datetime: - """UTC timestamp marking when the transaction began.""" - return self._started_at - - @property - def isolation_level(self) -> str: - """The isolation level requested for this transaction.""" - return self._isolation_level - - @property - def metadata(self) -> tuple[tuple[str, Hashable], ...]: - """Arbitrary key-value pairs for cross-cutting concerns.""" - return self._metadata - - @property - def value(self) -> tuple[Hashable, ...]: - """Composite value: all fields as a tuple.""" - return ( - self._transaction_id, - self._started_at, - self._isolation_level, - self._metadata, - ) diff --git a/tests/forging_blocks/foundation/context/__init__.py b/tests/forging_blocks/foundation/context/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/forging_blocks/foundation/context/test_authorization_context.py b/tests/forging_blocks/foundation/context/test_authorization_context.py deleted file mode 100644 index 2a07f0f8..00000000 --- a/tests/forging_blocks/foundation/context/test_authorization_context.py +++ /dev/null @@ -1,31 +0,0 @@ -# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false - -import pytest - -from forging_blocks.foundation.context import AuthorizationContext - - -@pytest.mark.unit -class TestAuthorizationContext: - def test_when_created_with_user_identifier_then_stores_it(self) -> None: - context = AuthorizationContext(user_id="user-1") - - assert context.user_id == "user-1" - assert context.roles == () - - def test_when_created_with_full_arguments_then_stores_all(self) -> None: - context = AuthorizationContext( - user_id="user-1", - roles=("admin", "editor"), - resource_id="document-42", - resource_type="document", - action="publish", - metadata=(("ip_address", "127.0.0.1"),), - ) - - assert context.user_id == "user-1" - assert context.roles == ("admin", "editor") - assert context.resource_id == "document-42" - assert context.resource_type == "document" - assert context.action == "publish" - assert context.metadata == (("ip_address", "127.0.0.1"),) diff --git a/tests/forging_blocks/foundation/context/test_service_context.py b/tests/forging_blocks/foundation/context/test_service_context.py deleted file mode 100644 index 812ea6e3..00000000 --- a/tests/forging_blocks/foundation/context/test_service_context.py +++ /dev/null @@ -1,39 +0,0 @@ -# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false - -import uuid - -import pytest - -from forging_blocks.foundation.context import ServiceContext - - -@pytest.mark.unit -class TestServiceContext: - def test_when_created_without_arguments_then_auto_generates_correlation_identifier( - self, - ) -> None: - context = ServiceContext() - - assert isinstance(context.correlation_id, uuid.UUID) - assert context.user_id is None - assert context.permissions == () - assert context.metadata == () - - def test_when_created_with_arguments_then_stores_values(self) -> None: - context = ServiceContext( - correlation_id=uuid.UUID("12345678-1234-5678-1234-567812345678"), - user_id="user-1", - permissions=("read", "write"), - metadata=(("tenant", "acme"),), - ) - - assert context.correlation_id == uuid.UUID("12345678-1234-5678-1234-567812345678") - assert context.user_id == "user-1" - assert context.permissions == ("read", "write") - assert context.metadata == (("tenant", "acme"),) - - def test_when_two_default_contexts_then_have_different_identifiers(self) -> None: - first = ServiceContext() - second = ServiceContext() - - assert first.correlation_id != second.correlation_id diff --git a/tests/forging_blocks/foundation/context/test_transaction_context.py b/tests/forging_blocks/foundation/context/test_transaction_context.py deleted file mode 100644 index 599d71bb..00000000 --- a/tests/forging_blocks/foundation/context/test_transaction_context.py +++ /dev/null @@ -1,33 +0,0 @@ -# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false - -import uuid - -import pytest - -from forging_blocks.foundation.context import TransactionContext - - -@pytest.mark.unit -class TestTransactionContext: - def test_when_created_without_arguments_then_auto_generates_identifier(self) -> None: - context = TransactionContext() - - assert isinstance(context.transaction_id, uuid.UUID) - assert context.isolation_level == "read_committed" - assert context.metadata == () - - def test_when_two_contexts_then_different_identifiers(self) -> None: - first = TransactionContext() - second = TransactionContext() - - assert first.transaction_id != second.transaction_id - - def test_when_custom_isolation_then_stores_it(self) -> None: - context = TransactionContext(isolation_level="serializable") - - assert context.isolation_level == "serializable" - - def test_when_metadata_provided_then_stores_it(self) -> None: - context = TransactionContext(metadata=(("trace", "abc-123"),)) - - assert context.metadata == (("trace", "abc-123"),) From 3b98a5a428d9380e5cfd98eaff6c43e0dcba5991 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 19:37:36 -0300 Subject: [PATCH 84/86] refactor(domain): remove concrete permission checker implementations ResourcePermissionChecker and RoleBasedPermissionChecker are too application-specific for the reusable library. Downstream code should implement PermissionChecker directly with its own permission logic. --- mkdocs.yml | 6 ---- src/forging_blocks/domain/__init__.py | 4 --- .../domain/permissions/__init__.py | 4 --- .../resource_permission_checker.py | 34 ------------------- .../role_based_permission_checker.py | 34 ------------------- .../test_resource_permission_checker.py | 34 ------------------- .../test_role_based_permission_checker.py | 30 ---------------- 7 files changed, 146 deletions(-) delete mode 100644 src/forging_blocks/domain/permissions/resource_permission_checker.py delete mode 100644 src/forging_blocks/domain/permissions/role_based_permission_checker.py delete mode 100644 tests/forging_blocks/domain/permissions/test_resource_permission_checker.py delete mode 100644 tests/forging_blocks/domain/permissions/test_role_based_permission_checker.py diff --git a/mkdocs.yml b/mkdocs.yml index e8bd60bb..9ee1f251 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -162,8 +162,6 @@ nav: - Permissions: - Composite Permission Checker: reference/autodoc/domain/permissions/composite_permission_checker.md - Permission Checker: reference/autodoc/domain/permissions/permission_checker.md - - Resource Permission Checker: reference/autodoc/domain/permissions/resource_permission_checker.md - - Role Based Permission Checker: reference/autodoc/domain/permissions/role_based_permission_checker.md - Specification: - Base: reference/autodoc/domain/specification/base.md - Composable: reference/autodoc/domain/specification/composable.md @@ -189,10 +187,6 @@ nav: - Auto Freeze: reference/autodoc/foundation/autofreeze/auto_freeze.md - Autohash: - Auto Hash: reference/autodoc/foundation/autohash/auto_hash.md - - Context: - - Authorization Context: reference/autodoc/foundation/context/authorization_context.md - - Service Context: reference/autodoc/foundation/context/service_context.md - - Transaction Context: reference/autodoc/foundation/context/transaction_context.md - Errors: - Architecture Error: reference/autodoc/foundation/errors/architecture_error.md - Cant Modify Immutable Attribute Error: reference/autodoc/foundation/errors/cant_modify_immutable_attribute_error.md diff --git a/src/forging_blocks/domain/__init__.py b/src/forging_blocks/domain/__init__.py index 95e58bcc..127f5bbc 100644 --- a/src/forging_blocks/domain/__init__.py +++ b/src/forging_blocks/domain/__init__.py @@ -12,8 +12,6 @@ from .messages import Command, Event, Message, Query from .permissions.composite_permission_checker import CompositePermissionChecker from .permissions.permission_checker import PermissionChecker -from .permissions.resource_permission_checker import ResourcePermissionChecker -from .permissions.role_based_permission_checker import RoleBasedPermissionChecker from .specification import ( AndSpecification, ExpressionSpecification, @@ -55,8 +53,6 @@ "Query", "RangeValidator", "RequiredValidator", - "ResourcePermissionChecker", - "RoleBasedPermissionChecker", "Specification", "ValueObject", ] diff --git a/src/forging_blocks/domain/permissions/__init__.py b/src/forging_blocks/domain/permissions/__init__.py index 0d28e2ce..381e4fc3 100644 --- a/src/forging_blocks/domain/permissions/__init__.py +++ b/src/forging_blocks/domain/permissions/__init__.py @@ -2,12 +2,8 @@ from .composite_permission_checker import CompositePermissionChecker from .permission_checker import PermissionChecker -from .resource_permission_checker import ResourcePermissionChecker -from .role_based_permission_checker import RoleBasedPermissionChecker __all__ = [ "CompositePermissionChecker", "PermissionChecker", - "ResourcePermissionChecker", - "RoleBasedPermissionChecker", ] diff --git a/src/forging_blocks/domain/permissions/resource_permission_checker.py b/src/forging_blocks/domain/permissions/resource_permission_checker.py deleted file mode 100644 index 85411161..00000000 --- a/src/forging_blocks/domain/permissions/resource_permission_checker.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Resource-based permission checker implementation.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from forging_blocks.foundation.permission import Permission - -if TYPE_CHECKING: - from forging_blocks.foundation.context import AuthorizationContext - - -class ResourcePermissionChecker: - """Grants permissions based on a resource-type-to-permission mapping. - - Args: - resource_permissions: A dictionary mapping resource-type names - (``str``) to the list of `Permission` values allowed - for that resource type. - - """ - - __match_args__ = ("_resource_permissions",) - - def __init__(self, resource_permissions: dict[str, list[Permission]]) -> None: - self._resource_permissions = resource_permissions - - async def check(self, context: AuthorizationContext, permission: Permission) -> bool: - if not context.resource_type: - return False - allowed = self._resource_permissions.get(context.resource_type) - if allowed is None: - return False - return permission in allowed diff --git a/src/forging_blocks/domain/permissions/role_based_permission_checker.py b/src/forging_blocks/domain/permissions/role_based_permission_checker.py deleted file mode 100644 index aa870af6..00000000 --- a/src/forging_blocks/domain/permissions/role_based_permission_checker.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Role-based permission checker implementation.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from forging_blocks.foundation.permission import Permission - -if TYPE_CHECKING: - from forging_blocks.foundation.context import AuthorizationContext - - -class RoleBasedPermissionChecker: - """Grants permissions based on a static role-to-permission mapping. - - Args: - role_permissions: A dictionary mapping role names (``str``) to the list - of `Permission` values that role is allowed. - - """ - - __match_args__ = ("_role_permissions",) - - def __init__(self, role_permissions: dict[str, list[Permission]]) -> None: - self._role_permissions = role_permissions - - async def check(self, context: AuthorizationContext, permission: Permission) -> bool: - if not context.roles: - return False - for role in context.roles: - allowed = self._role_permissions.get(role) - if allowed is not None and permission in allowed: - return True - return False diff --git a/tests/forging_blocks/domain/permissions/test_resource_permission_checker.py b/tests/forging_blocks/domain/permissions/test_resource_permission_checker.py deleted file mode 100644 index 159f6b2c..00000000 --- a/tests/forging_blocks/domain/permissions/test_resource_permission_checker.py +++ /dev/null @@ -1,34 +0,0 @@ -# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false - -import pytest - -from forging_blocks.domain.permissions.resource_permission_checker import ResourcePermissionChecker -from forging_blocks.foundation.context import AuthorizationContext -from forging_blocks.foundation.permission import Permission - - -@pytest.mark.unit -class TestResourcePermissionChecker: - async def test_when_resource_type_allows_permission_then_grants(self) -> None: - checker = ResourcePermissionChecker({"document": [Permission.READ, Permission.WRITE]}) - context = AuthorizationContext(user_id="user-1", resource_type="document") - - assert await checker.check(context, Permission.READ) is True - - async def test_when_resource_type_lacks_permission_then_denies(self) -> None: - checker = ResourcePermissionChecker({"image": [Permission.READ]}) - context = AuthorizationContext(user_id="user-1", resource_type="image") - - assert await checker.check(context, Permission.DELETE) is False - - async def test_when_resource_type_missing_then_denies(self) -> None: - checker = ResourcePermissionChecker({"document": [Permission.READ]}) - context = AuthorizationContext(user_id="user-1") - - assert await checker.check(context, Permission.READ) is False - - async def test_when_resource_type_unknown_then_denies(self) -> None: - checker = ResourcePermissionChecker({"document": [Permission.READ]}) - context = AuthorizationContext(user_id="user-1", resource_type="unknown_type") - - assert await checker.check(context, Permission.READ) is False diff --git a/tests/forging_blocks/domain/permissions/test_role_based_permission_checker.py b/tests/forging_blocks/domain/permissions/test_role_based_permission_checker.py deleted file mode 100644 index 82b5e4f8..00000000 --- a/tests/forging_blocks/domain/permissions/test_role_based_permission_checker.py +++ /dev/null @@ -1,30 +0,0 @@ -# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false - -import pytest - -from forging_blocks.domain.permissions.role_based_permission_checker import ( - RoleBasedPermissionChecker, -) -from forging_blocks.foundation.context import AuthorizationContext -from forging_blocks.foundation.permission import Permission - - -@pytest.mark.unit -class TestRoleBasedPermissionChecker: - async def test_when_role_has_permission_then_grants(self) -> None: - checker = RoleBasedPermissionChecker({"admin": [Permission.READ, Permission.WRITE]}) - context = AuthorizationContext(user_id="user-1", roles=("admin",)) - - assert await checker.check(context, Permission.READ) is True - - async def test_when_role_lacks_permission_then_denies(self) -> None: - checker = RoleBasedPermissionChecker({"user": [Permission.READ]}) - context = AuthorizationContext(user_id="user-1", roles=("user",)) - - assert await checker.check(context, Permission.DELETE) is False - - async def test_when_no_roles_then_denies(self) -> None: - checker = RoleBasedPermissionChecker({"admin": [Permission.READ]}) - context = AuthorizationContext(user_id="user-1") - - assert await checker.check(context, Permission.READ) is False From 67dfe8726e163a3afeaa9a296db5b6489cb1ec89 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 20:08:41 -0300 Subject: [PATCH 85/86] refactor: generify permission and port interfaces with domain type parameters Replace deleted concrete context types with domain-appropriate type parameters: PermissionCheckContext (permission checkers), AuthorizationCheckContext (authorization port), and TransactionSessionContext (transaction manager port). Docs updated to reflect generic signatures and remove stale references to deleted concrete classes. --- .github/copilot-instructions.md | 2 +- docs/reference/domain.md | 2 +- docs/reference/domain/permissions.md | 58 +++++++++---------- .../ports/inbound/authorization_port.py | 10 ++-- .../outbound/transaction_manager_port.py | 11 ++-- .../composite_permission_checker.py | 15 +++-- .../domain/permissions/permission_checker.py | 15 ++--- .../ports/inbound/test_authorization_port.py | 18 +++--- .../test_composite_permission_checker.py | 40 ++++++------- .../permissions/test_permission_checker.py | 5 +- 10 files changed, 85 insertions(+), 91 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b612d44e..9e48ae13 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -166,7 +166,7 @@ Do **not** implement `_payload` manually on decorated classes — the decorator ### ValueObject: Immutable Domain Values ```python -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class Email(ValueObject[str]): diff --git a/docs/reference/domain.md b/docs/reference/domain.md index f17436eb..285894f8 100644 --- a/docs/reference/domain.md +++ b/docs/reference/domain.md @@ -38,7 +38,7 @@ The Domain block is the innermost ring. It imports nothing from outer layers. Wh - **[Messages](domain/messages.md)** — Command, Event, Query (immutable, architecture-neutral) - **[Domain Errors](domain/errors.md)** — Invalid states and rule violations in domain terms - **[Validators](domain/validators.md)** — Concrete validation rules (RequiredValidator, EmailValidator, LengthValidator, RangeValidator) -- **[Permissions](domain/permissions.md)** — Composable permission checkers (RoleBased, ResourceBased, Composite) +- **[Permissions](domain/permissions.md)** — Composable permission checkers (protocol-based, application-defined) --- ## What it does not do diff --git a/docs/reference/domain/permissions.md b/docs/reference/domain/permissions.md index 62d1f90a..bd25c61d 100644 --- a/docs/reference/domain/permissions.md +++ b/docs/reference/domain/permissions.md @@ -1,57 +1,57 @@ # Permissions -Composable permission-checking strategies for authorization decisions. Each checker evaluates an `AuthorizationContext` against a specific `Permission` and returns `True` when granted. +Composable permission-checking strategies for authorization decisions. Each checker evaluates an application-defined context against a specific `Permission` and returns `True` when granted. ## PermissionChecker -`PermissionChecker` is a `Protocol` that any permission-checking implementation must satisfy. +`PermissionChecker[PermissionCheckContext]` is a `Protocol` that any permission-checking implementation must satisfy. The context type is provided by the application. ```python -class PermissionChecker(Protocol): - async def check(self, context: AuthorizationContext, permission: Permission) -> bool: +class PermissionChecker[PermissionCheckContext](Protocol): + async def check(self, context: PermissionCheckContext, permission: Permission) -> bool: ... ``` Implementations may internally use synchronous logic, but must expose an `async def check(...)` method — callers always `await` the result. -## RoleBasedPermissionChecker +## CompositePermissionChecker -Grants permissions based on a static mapping of roles to allowed permissions. Looks up the user's roles in the `AuthorizationContext` and checks whether any assigned role includes the requested permission. +Combines multiple `PermissionChecker[PermissionCheckContext]` instances with OR logic. A check passes as soon as **any** inner checker approves. Returns `True` immediately on the first success; otherwise `False` after all checkers have been consulted. ```python -checker = RoleBasedPermissionChecker({ - "admin": [Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN], - "editor": [Permission.READ, Permission.WRITE], -}) +checker = CompositePermissionChecker([ + my_role_checker, + my_resource_checker, +]) +result = await checker.check(context, Permission.READ) ``` -## ResourcePermissionChecker +## Designing your own checkers -Grants permissions based on a static mapping of resource types to allowed permissions. Inspects `AuthorizationContext.resource_type` and checks whether the targeted resource type permits the requested permission. +Applications define concrete `PermissionChecker` implementations that inspect their own context type. A role-based checker might look up permissions from a role-to-permission mapping: ```python -checker = ResourcePermissionChecker({ - "document": [Permission.READ, Permission.WRITE], - "image": [Permission.READ], -}) +from forging_blocks.domain.permissions import PermissionChecker + +class RoleBasedChecker[PermissionCheckContext](PermissionChecker[PermissionCheckContext]): + def __init__(self, role_map: dict[str, list[Permission]]) -> None: + self._role_map = role_map + + async def check(self, context: PermissionCheckContext, permission: Permission) -> bool: + roles = getattr(context, "roles", []) + for role in roles: + if permission in self._role_map.get(role, []): + return True + return False ``` -## CompositePermissionChecker - -Combines multiple `PermissionChecker` instances with OR logic. A check passes as soon as **any** inner checker approves. Returns `True` immediately on the first success; otherwise `False` after all checkers have been consulted. - -```python -checker = CompositePermissionChecker([ - RoleBasedPermissionChecker({"admin": [Permission.READ]}), - ResourcePermissionChecker({"document": [Permission.READ]}), -]) -``` +A resource-based checker would similarly inspect resource metadata on the context object. ## When to use -Use `RoleBasedPermissionChecker` for role-driven authorization (RBAC). Use `ResourcePermissionChecker` for resource-level access control. Combine them with `CompositePermissionChecker` when authorization depends on multiple factors — an admin role OR ownership of a document, for example. +Use `CompositePermissionChecker` to combine multiple checkers when authorization depends on multiple factors — an admin role OR ownership of a document, for example. Define application-specific `PermissionChecker` subclasses for role-driven authorization (RBAC), resource-level access control, or any custom authorization logic. -All checkers operate on the foundation `AuthorizationContext` and `Permission` types, keeping the domain free of infrastructure concerns. +All checkers operate on the foundation `Permission` type and an application-defined context, keeping the domain free of infrastructure concerns. !!! note "Related" - See [Foundation Context](../foundation/context.md) for `AuthorizationContext` and `Permission`, and [Foundation Errors](../foundation/errors.md) for error types. + Permissions use the `Permission` enum (in `forging_blocks.foundation.permission`). See [Foundation Errors](../foundation/errors.md) for error types. diff --git a/src/forging_blocks/application/ports/inbound/authorization_port.py b/src/forging_blocks/application/ports/inbound/authorization_port.py index aaf34567..761a4484 100644 --- a/src/forging_blocks/application/ports/inbound/authorization_port.py +++ b/src/forging_blocks/application/ports/inbound/authorization_port.py @@ -12,14 +12,16 @@ from abc import abstractmethod -from forging_blocks.foundation.context import AuthorizationContext from forging_blocks.foundation.permission import Permission from forging_blocks.foundation.ports import InboundPort -class AuthorizationPort(InboundPort): +class AuthorizationPort[AuthorizationCheckContext](InboundPort): """Inbound port for authorization decisions. + Type Args: + AuthorizationCheckContext: The application-defined context for authorization checks. + Responsibilities: - Evaluate permissions against an authorization context. - Support resource-level and global permission checks. @@ -33,7 +35,7 @@ class AuthorizationPort(InboundPort): @abstractmethod async def check_permission( self, - context: AuthorizationContext, + context: AuthorizationCheckContext, permission: Permission, ) -> bool: """Evaluate whether *permission* is granted in *context*.""" @@ -42,7 +44,7 @@ async def check_permission( @abstractmethod async def check_resource_permission( self, - context: AuthorizationContext, + context: AuthorizationCheckContext, resource: str, action: str, ) -> bool: diff --git a/src/forging_blocks/application/ports/outbound/transaction_manager_port.py b/src/forging_blocks/application/ports/outbound/transaction_manager_port.py index f501f9c2..518d301c 100644 --- a/src/forging_blocks/application/ports/outbound/transaction_manager_port.py +++ b/src/forging_blocks/application/ports/outbound/transaction_manager_port.py @@ -15,14 +15,17 @@ from abc import abstractmethod from collections.abc import Awaitable, Callable -from forging_blocks.foundation.context import TransactionContext from forging_blocks.foundation.ports import OutboundPort from forging_blocks.foundation.result import Result -class TransactionManagerPort[TransactionErrorType](OutboundPort): +class TransactionManagerPort[TransactionSessionContext, TransactionErrorType](OutboundPort): """Outbound port for explicit transaction management. + Type Args: + TransactionSessionContext: The application-defined session context for transactions. + TransactionErrorType: The error type for transactional failures. + Responsibilities: - Begin, commit, and roll back transactions. - Execute arbitrary functions within transactional boundaries. @@ -33,7 +36,7 @@ class TransactionManagerPort[TransactionErrorType](OutboundPort): """ @abstractmethod - async def begin(self, context: TransactionContext) -> Result[None, TransactionErrorType]: + async def begin(self, context: TransactionSessionContext) -> Result[None, TransactionErrorType]: """Start a new transaction.""" ... @@ -51,7 +54,7 @@ async def rollback(self) -> Result[None, TransactionErrorType]: async def execute_in_transaction[ResponseType]( self, fn: Callable[..., Awaitable[ResponseType]], - context: TransactionContext, + context: TransactionSessionContext, *args: object, **kwargs: object, ) -> Result[ResponseType, TransactionErrorType]: diff --git a/src/forging_blocks/domain/permissions/composite_permission_checker.py b/src/forging_blocks/domain/permissions/composite_permission_checker.py index b923f0c9..2f57463f 100644 --- a/src/forging_blocks/domain/permissions/composite_permission_checker.py +++ b/src/forging_blocks/domain/permissions/composite_permission_checker.py @@ -2,24 +2,23 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from forging_blocks.domain.permissions.permission_checker import PermissionChecker from forging_blocks.foundation.permission import Permission -if TYPE_CHECKING: - from forging_blocks.foundation.context import AuthorizationContext +class CompositePermissionChecker[PermissionCheckContext]: + """Combines multiple `PermissionChecker` instances with OR logic. -class CompositePermissionChecker: - """Combines multiple `PermissionChecker` instances with OR logic.""" + Type Args: + PermissionCheckContext: The application-defined context for permission checks. + """ __match_args__ = ("_checkers",) - def __init__(self, checkers: list[PermissionChecker]) -> None: + def __init__(self, checkers: list[PermissionChecker[PermissionCheckContext]]) -> None: self._checkers = checkers - async def check(self, context: AuthorizationContext, permission: Permission) -> bool: + async def check(self, context: PermissionCheckContext, permission: Permission) -> bool: for checker in self._checkers: if await checker.check(context, permission): return True diff --git a/src/forging_blocks/domain/permissions/permission_checker.py b/src/forging_blocks/domain/permissions/permission_checker.py index 9fe053d8..73fea7d5 100644 --- a/src/forging_blocks/domain/permissions/permission_checker.py +++ b/src/forging_blocks/domain/permissions/permission_checker.py @@ -2,18 +2,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable from forging_blocks.foundation.permission import Permission -if TYPE_CHECKING: - from forging_blocks.foundation.context import AuthorizationContext - @runtime_checkable -class PermissionChecker(Protocol): - """Protocol for any callable that decides whether a permission is granted.""" +class PermissionChecker[PermissionCheckContext](Protocol): + """Protocol for any callable that decides whether a permission is granted. + + Type Args: + PermissionCheckContext: The application-defined context for permission checks. + """ - async def check(self, context: AuthorizationContext, permission: Permission) -> bool: + async def check(self, context: PermissionCheckContext, permission: Permission) -> bool: """Return ``True`` if *permission* is granted in *context*.""" ... diff --git a/tests/forging_blocks/application/ports/inbound/test_authorization_port.py b/tests/forging_blocks/application/ports/inbound/test_authorization_port.py index e018ead1..6d3f0b8a 100644 --- a/tests/forging_blocks/application/ports/inbound/test_authorization_port.py +++ b/tests/forging_blocks/application/ports/inbound/test_authorization_port.py @@ -1,17 +1,16 @@ import pytest from forging_blocks.application.ports.inbound.authorization_port import AuthorizationPort -from forging_blocks.foundation.context import AuthorizationContext from forging_blocks.foundation.permission import Permission @pytest.mark.unit class TestAuthorizationPort: async def test_when_check_permission_granted_then_returns_true(self) -> None: - class PermissiveAuthorizationPort(AuthorizationPort): + class PermissiveAuthorizationPort(AuthorizationPort[object]): async def check_permission( self, - context: AuthorizationContext, + context: object, permission: Permission, ) -> bool: del context, permission @@ -19,7 +18,7 @@ async def check_permission( async def check_resource_permission( self, - context: AuthorizationContext, + context: object, resource: str, action: str, ) -> bool: @@ -35,15 +34,14 @@ async def get_user_roles(self, user_id: str) -> list[str]: return ["admin"] service = PermissiveAuthorizationPort() - context = AuthorizationContext(user_id="user-1") - assert await service.check_permission(context, Permission.READ) is True + assert await service.check_permission(object(), Permission.READ) is True async def test_when_check_permission_denied_then_returns_false(self) -> None: - class RestrictiveAuthorizationPort(AuthorizationPort): + class RestrictiveAuthorizationPort(AuthorizationPort[object]): async def check_permission( self, - context: AuthorizationContext, + context: object, permission: Permission, ) -> bool: del context, permission @@ -51,7 +49,7 @@ async def check_permission( async def check_resource_permission( self, - context: AuthorizationContext, + context: object, resource: str, action: str, ) -> bool: @@ -70,7 +68,7 @@ async def get_user_roles(self, user_id: str) -> list[str]: assert ( await service.check_permission( - AuthorizationContext(user_id="user-1"), + object(), Permission.ADMIN, ) is False diff --git a/tests/forging_blocks/domain/permissions/test_composite_permission_checker.py b/tests/forging_blocks/domain/permissions/test_composite_permission_checker.py index cf17cde0..71023258 100644 --- a/tests/forging_blocks/domain/permissions/test_composite_permission_checker.py +++ b/tests/forging_blocks/domain/permissions/test_composite_permission_checker.py @@ -1,42 +1,36 @@ -# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false - import pytest from forging_blocks.domain.permissions.composite_permission_checker import ( CompositePermissionChecker, ) -from forging_blocks.domain.permissions.role_based_permission_checker import ( - RoleBasedPermissionChecker, -) -from forging_blocks.foundation.context import AuthorizationContext from forging_blocks.foundation.permission import Permission +class _AlwaysGrant: + async def check(self, context: object, permission: Permission) -> bool: + del context, permission + return True + + +class _AlwaysDeny: + async def check(self, context: object, permission: Permission) -> bool: + del context, permission + return False + + @pytest.mark.unit class TestCompositePermissionChecker: async def test_when_any_inner_grants_then_grants(self) -> None: - checker = CompositePermissionChecker( - [ - RoleBasedPermissionChecker({"admin": [Permission.READ]}), - RoleBasedPermissionChecker({"user": []}), - ] - ) - context = AuthorizationContext(user_id="user-1", roles=("admin",)) + checker = CompositePermissionChecker([_AlwaysGrant(), _AlwaysDeny()]) - assert await checker.check(context, Permission.READ) is True + assert await checker.check(object(), Permission.READ) is True async def test_when_all_deny_then_denies(self) -> None: - checker = CompositePermissionChecker( - [ - RoleBasedPermissionChecker({"user": [Permission.READ]}), - ] - ) - context = AuthorizationContext(user_id="user-1", roles=("guest",)) + checker = CompositePermissionChecker([_AlwaysDeny()]) - assert await checker.check(context, Permission.READ) is False + assert await checker.check(object(), Permission.READ) is False async def test_when_empty_checkers_then_denies(self) -> None: checker = CompositePermissionChecker([]) - context = AuthorizationContext(user_id="user-1") - assert await checker.check(context, Permission.READ) is False + assert await checker.check(object(), Permission.READ) is False diff --git a/tests/forging_blocks/domain/permissions/test_permission_checker.py b/tests/forging_blocks/domain/permissions/test_permission_checker.py index fa7e3a94..9f4ad882 100644 --- a/tests/forging_blocks/domain/permissions/test_permission_checker.py +++ b/tests/forging_blocks/domain/permissions/test_permission_checker.py @@ -1,9 +1,6 @@ -# pyright: reportPrivateUsage=false, reportMissingTypeArgument=false, reportUnknownParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingParameterType=false, reportIncompatibleMethodOverride=false, reportUnusedClass=false, reportFunctionMemberAccess=false - import pytest from forging_blocks.domain.permissions.permission_checker import PermissionChecker -from forging_blocks.foundation.context import AuthorizationContext from forging_blocks.foundation.permission import Permission @@ -11,7 +8,7 @@ class TestPermissionCheckerProtocol: def test_when_implements_async_check_then_is_permission_checker(self) -> None: class CustomChecker: - async def check(self, context: AuthorizationContext, permission: Permission) -> bool: + async def check(self, context: object, permission: Permission) -> bool: del context, permission return True From 25ecf50572cc6ca999036bfffeaed3ee34670500 Mon Sep 17 00:00:00 2001 From: Glauber Brennon Date: Thu, 23 Jul 2026 20:20:35 -0300 Subject: [PATCH 86/86] style(domain): add explicit PermissionChecker inheritance to CompositePermissionChecker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align shipped implementation with documented convention in docs/permissions.md. Functionally equivalent — PermissionChecker is @runtime_checkable — but explicit inheritance improves code navigation and removes a doc/code inconsistency. --- .../domain/permissions/composite_permission_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forging_blocks/domain/permissions/composite_permission_checker.py b/src/forging_blocks/domain/permissions/composite_permission_checker.py index 2f57463f..edaa61b3 100644 --- a/src/forging_blocks/domain/permissions/composite_permission_checker.py +++ b/src/forging_blocks/domain/permissions/composite_permission_checker.py @@ -6,7 +6,7 @@ from forging_blocks.foundation.permission import Permission -class CompositePermissionChecker[PermissionCheckContext]: +class CompositePermissionChecker[PermissionCheckContext](PermissionChecker[PermissionCheckContext]): """Combines multiple `PermissionChecker` instances with OR logic. Type Args: