diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4d0707e69..9e48ae13d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -166,8 +166,8 @@ Do **not** implement `_payload` manually on decorated classes — the decorator ### ValueObject: Immutable Domain Values ```python -from forging_blocks.foundation.value_object import ValueObject -from collections.abc import Hashable +from forging_blocks.domain.value_object import ValueObject + 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 +`auto_freeze` and `auto_hash` (applied through ``__init_subclass__``). +After ``__init__`` completes, any mutation raises ``CantModifyImmutableAttributeError``. ### Specifications: Composable Predicates @@ -459,7 +457,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 9aa4fa108..52ddb1a4c 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. --- @@ -127,8 +127,7 @@ The design is: ## 4. Modeling a value with ValueObject ```python -from collections.abc import Hashable -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class Email(ValueObject[str]): @@ -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 `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 +mutate one after construction raises a ``CantModifyImmutableAttributeError``. --- diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index e997b2060..b69d1fdc2 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -49,8 +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 collections.abc import Hashable -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class Email(ValueObject[str]): @@ -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 `auto_hash` and `auto_freeze`, so that you +can focus on the rules of *your* value. --- diff --git a/docs/reference/domain.md b/docs/reference/domain.md index 2babdcb7f..285894f8a 100644 --- a/docs/reference/domain.md +++ b/docs/reference/domain.md @@ -35,9 +35,10 @@ 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) +- **[Permissions](domain/permissions.md)** — Composable permission checkers (protocol-based, application-defined) --- ## What it does not do @@ -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/foundation/messages.md b/docs/reference/domain/messages.md similarity index 55% rename from docs/reference/foundation/messages.md rename to docs/reference/domain/messages.md index ca2750b46..80b859ebf 100644 --- a/docs/reference/foundation/messages.md +++ b/docs/reference/domain/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 `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/domain/permissions.md b/docs/reference/domain/permissions.md index 62d1f90a0..bd25c61de 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/docs/reference/domain/specifications.md b/docs/reference/domain/specifications.md index 31ee550a2..d7dc574f2 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 e95b560ad..0343d3c4a 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 411dec573..6b9874b6c 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,14 +34,11 @@ 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) +- **[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) -- **[Context](foundation/context.md)** — Immutable context objects (ServiceContext, AuthorizationContext, TransactionContext) + - **[Rules](foundation/rules.md)** — Composable validation rules (ValidationRule) --- @@ -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-decorators.md b/docs/reference/foundation/auto-decorators.md new file mode 100644 index 000000000..ed470d344 --- /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. diff --git a/docs/reference/foundation/auto-freeze.md b/docs/reference/foundation/auto-freeze.md deleted file mode 100644 index 6e27661a8..000000000 --- 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](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/context.md b/docs/reference/foundation/context.md deleted file mode 100644 index 9483de26b..000000000 --- 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/docs/reference/foundation/specifications.md b/docs/reference/foundation/specifications.md deleted file mode 100644 index fc23fbf74..000000000 --- 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 1e698e474..000000000 --- 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/docs/reference/index.md b/docs/reference/index.md index 03c8cbe20..4fd76f8c7 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 c72f41fe5..188cd3316 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 diff --git a/mkdocs.yml b/mkdocs.yml index 63d46ac9f..9ee1f2519 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,19 +78,16 @@ 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 + - Auto Decorators: reference/foundation/auto-decorators.md - 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 - 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 +108,167 @@ 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 + - 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 + - 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 diff --git a/scripts/generate_autodoc_pages.py b/scripts/generate_autodoc_pages.py index 84f7e1216..8e3be97f5 100755 --- a/scripts/generate_autodoc_pages.py +++ b/scripts/generate_autodoc_pages.py @@ -7,9 +7,11 @@ from __future__ import annotations +import ast import re import shutil import sys +import textwrap from pathlib import Path SRC_DIR = Path("src/forging_blocks") @@ -133,6 +135,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(textwrap.dedent(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 +232,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") diff --git a/scripts/release/application/ports/outbound/release_command_bus.py b/scripts/release/application/ports/outbound/release_command_bus.py index 375b20f44..e1db1c257 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 2bcea49b2..bd220f5ed 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 3a807265f..07193e1ac 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] @@ -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/scripts/release/domain/value_objects/release_branch_name.py b/scripts/release/domain/value_objects/release_branch_name.py index db3c9d9bb..d8a674f72 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 62c2b5b2d..239a446ac 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 66e60c65f..301b03f8a 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 e811cd5fd..14741f2e4 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/scripts/release/infrastructure/bus/in_memory_release_command_bus.py b/scripts/release/infrastructure/bus/in_memory_release_command_bus.py index d97cc4e5e..946b38755 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]] diff --git a/src/forging_blocks/application/ports/inbound/authorization_port.py b/src/forging_blocks/application/ports/inbound/authorization_port.py index aaf345679..761a44844 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/inbound/message_handler_port.py b/src/forging_blocks/application/ports/inbound/message_handler_port.py index b670beb91..e7a5970c1 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 bbaa49947..29a0cb822 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 6084c445b..29d06c0ea 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 e2d06901a..058f14f6c 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 3a50a23f8..3a42c4ca6 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 a7de0ce1b..ae2b78c61 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 fe3f0618e..520a4df5f 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/application/ports/outbound/transaction_manager_port.py b/src/forging_blocks/application/ports/outbound/transaction_manager_port.py index f501f9c2f..518d301cf 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/README.md b/src/forging_blocks/domain/README.md index c87db74a8..21e340d68 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 6e795d081..127f5bbc2 100644 --- a/src/forging_blocks/domain/__init__.py +++ b/src/forging_blocks/domain/__init__.py @@ -1,14 +1,7 @@ """ForgingBlocks for domain-specific modules.""" -from forging_blocks.foundation.specification import ( - AndSpecification, - ExpressionSpecification, - NotSpecification, - OrSpecification, - Specification, -) +from typing import TYPE_CHECKING -from .aggregate_root import AggregateRoot, AggregateVersion from .entity import Entity from .errors import ( DraftEntityIsNotHashableError, @@ -16,10 +9,16 @@ 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 @@ -27,10 +26,15 @@ 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", "AndSpecification", + "Command", "CompositePermissionChecker", "CompositeValidationRule", "DraftEntityIsNotHashableError", @@ -39,15 +43,29 @@ "EntityIdDeletionError", "EntityIdModificationError", "EntityIdNoneError", + "Event", "ExpressionSpecification", "LengthValidator", + "Message", "NotSpecification", "OrSpecification", "PermissionChecker", + "Query", "RangeValidator", "RequiredValidator", - "ResourcePermissionChecker", - "RoleBasedPermissionChecker", "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) diff --git a/src/forging_blocks/domain/aggregate_root/aggregate_root.py b/src/forging_blocks/domain/aggregate_root/aggregate_root.py index 636f19dab..9f743baa1 100644 --- a/src/forging_blocks/domain/aggregate_root/aggregate_root.py +++ b/src/forging_blocks/domain/aggregate_root/aggregate_root.py @@ -1,12 +1,13 @@ """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 +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 @@ -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 `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/domain/aggregate_root/aggregate_version.py b/src/forging_blocks/domain/aggregate_root/aggregate_version.py index 0877d1ced..9fae85162 100644 --- a/src/forging_blocks/domain/aggregate_root/aggregate_version.py +++ b/src/forging_blocks/domain/aggregate_root/aggregate_version.py @@ -1,8 +1,6 @@ """AggregateVersion value object for optimistic concurrency control.""" -from collections.abc import Hashable - -from forging_blocks.foundation.value_object import ValueObject +from forging_blocks.domain.value_object import ValueObject class AggregateVersion(ValueObject[int]): @@ -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/messages/__init__.py b/src/forging_blocks/domain/messages/__init__.py similarity index 92% rename from src/forging_blocks/foundation/messages/__init__.py rename to src/forging_blocks/domain/messages/__init__.py index 33ad41859..f466a1610 100644 --- a/src/forging_blocks/foundation/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/foundation/messages/command.py b/src/forging_blocks/domain/messages/command.py similarity index 82% rename from src/forging_blocks/foundation/messages/command.py rename to src/forging_blocks/domain/messages/command.py index 657eea7cb..1bc7d3097 100644 --- a/src/forging_blocks/foundation/messages/command.py +++ b/src/forging_blocks/domain/messages/command.py @@ -1,20 +1,21 @@ -"""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 -from forging_blocks.foundation.messages.message import Message +from forging_blocks.domain.messages.message import Message 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/domain/messages/decorators.py similarity index 73% rename from src/forging_blocks/foundation/messages/decorators.py rename to src/forging_blocks/domain/messages/decorators.py index 9eea7c137..fb7017664 100644 --- a/src/forging_blocks/foundation/messages/decorators.py +++ b/src/forging_blocks/domain/messages/decorators.py @@ -4,12 +4,12 @@ ``@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:: - 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 @@ -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 @@ -28,9 +27,9 @@ 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[dict[str, object]]") +_M = TypeVar("_M", bound="Message[Any]") @runtime_checkable @@ -45,11 +44,11 @@ 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, - ) -> type[Self]: ... + ) -> Self: ... @overload @@ -59,33 +58,35 @@ 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. + `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. 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__ @@ -119,27 +120,24 @@ 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, ) -> _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. - # 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 + 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. 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 +149,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/domain/messages/event.py similarity index 77% rename from src/forging_blocks/foundation/messages/event.py rename to src/forging_blocks/domain/messages/event.py index 2efb9d5a2..681d0ac83 100644 --- a/src/forging_blocks/foundation/messages/event.py +++ b/src/forging_blocks/domain/messages/event.py @@ -1,20 +1,21 @@ -"""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 -from forging_blocks.foundation.messages.message import Message +from forging_blocks.domain.messages.message import Message 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,8 +43,8 @@ def occurred_at(self) -> datetime: @property @abstractmethod - def _payload(self) -> dict[str, object]: - """Retrieve the event's payload as a dictionary. + def _payload(self) -> RawEventType: + """Return the event-specific payload data. Subclasses MUST implement this property to return the event-specific data. diff --git a/src/forging_blocks/domain/messages/message/__init__.py b/src/forging_blocks/domain/messages/message/__init__.py new file mode 100644 index 000000000..a00452fb4 --- /dev/null +++ b/src/forging_blocks/domain/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/domain/messages/message/_message.py b/src/forging_blocks/domain/messages/message/_message.py new file mode 100644 index 000000000..4ede37089 --- /dev/null +++ b/src/forging_blocks/domain/messages/message/_message.py @@ -0,0 +1,127 @@ +"""Base Message class for messaging patterns.""" + +import inspect +from abc import ABC, abstractmethod +from datetime import datetime +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 + + +class Message[MessageRawType](ABC): + """Base class for all foundation messages. + + 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). + + Messages are immutable and each instance is unique — equality and + hash are determined solely by the ``message_id`` carried in + `MessageMetadata`, enforced via `auto_hash` and + `auto_eq` with ``fields=["message_id"]``. + + This class should not be used directly. Import `Event` or + `Command` instead. + """ + + def __init_subclass__(cls, **kwargs: Any) -> None: + """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) + + 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) + + @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 + @abstractmethod + def value(self) -> MessageRawType: + """Return the raw message payload as a single value.""" diff --git a/src/forging_blocks/domain/messages/message/_metadata.py b/src/forging_blocks/domain/messages/message/_metadata.py new file mode 100644 index 000000000..fe353aec6 --- /dev/null +++ b/src/forging_blocks/domain/messages/message/_metadata.py @@ -0,0 +1,134 @@ +"""MessageMetadata value object for messaging patterns.""" + +from datetime import datetime, timezone +from uuid import UUID, uuid7 + +from forging_blocks.domain.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 + 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 needing to 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 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: + """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: + The timestamp (preserved as given when provided, or the + current UTC time when defaulted). + + """ + 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, + } diff --git a/src/forging_blocks/foundation/messages/query.py b/src/forging_blocks/domain/messages/query.py similarity index 69% rename from src/forging_blocks/foundation/messages/query.py rename to src/forging_blocks/domain/messages/query.py index 73de4139e..addd03420 100644 --- a/src/forging_blocks/foundation/messages/query.py +++ b/src/forging_blocks/domain/messages/query.py @@ -1,14 +1,14 @@ -"""Module defining the base Query class for domain queries.""" +"""Module defining the base Query class for queries.""" 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]): - """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,8 +30,8 @@ def _payload(self) -> dict[str, object]: @property @abstractmethod - def _payload(self) -> dict[str, object]: - """Retrieve the query's payload as a dictionary. + def _payload(self) -> QueryPayloadType: + """Return the query-specific payload data. Subclasses MUST implement this property to return the query-specific data. diff --git a/src/forging_blocks/domain/permissions/__init__.py b/src/forging_blocks/domain/permissions/__init__.py index 0d28e2cec..381e4fc33 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/composite_permission_checker.py b/src/forging_blocks/domain/permissions/composite_permission_checker.py index ce02c4b4c..edaa61b33 100644 --- a/src/forging_blocks/domain/permissions/composite_permission_checker.py +++ b/src/forging_blocks/domain/permissions/composite_permission_checker.py @@ -1,19 +1,24 @@ """Composite permission checker with OR logic.""" +from __future__ import annotations + from forging_blocks.domain.permissions.permission_checker import PermissionChecker -from forging_blocks.foundation.context import AuthorizationContext from forging_blocks.foundation.permission import Permission -class CompositePermissionChecker: - """Combines multiple :class:`PermissionChecker` instances with OR logic.""" +class CompositePermissionChecker[PermissionCheckContext](PermissionChecker[PermissionCheckContext]): + """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 5da7223c4..73fea7d50 100644 --- a/src/forging_blocks/domain/permissions/permission_checker.py +++ b/src/forging_blocks/domain/permissions/permission_checker.py @@ -1,15 +1,20 @@ """Protocol for permission-checking implementations.""" +from __future__ import annotations + from typing import Protocol, runtime_checkable -from forging_blocks.foundation.context import AuthorizationContext from forging_blocks.foundation.permission import Permission @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/src/forging_blocks/domain/permissions/resource_permission_checker.py b/src/forging_blocks/domain/permissions/resource_permission_checker.py deleted file mode 100644 index a94127e53..000000000 --- a/src/forging_blocks/domain/permissions/resource_permission_checker.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Resource-based permission checker implementation.""" - -from forging_blocks.foundation.context import AuthorizationContext -from forging_blocks.foundation.permission import Permission - - -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 :class:`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 a1178ec15..000000000 --- a/src/forging_blocks/domain/permissions/role_based_permission_checker.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Role-based permission checker implementation.""" - -from forging_blocks.foundation.context import AuthorizationContext -from forging_blocks.foundation.permission import Permission - - -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 :class:`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/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 85% rename from src/forging_blocks/foundation/specification/logical_operators/and_specification.py rename to src/forging_blocks/domain/specification/logical_operators/and_specification.py index 6bbd60853..e34e6600c 100644 --- a/src/forging_blocks/foundation/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/foundation/specification/logical_operators/not_specification.py b/src/forging_blocks/domain/specification/logical_operators/not_specification.py similarity index 81% rename from src/forging_blocks/foundation/specification/logical_operators/not_specification.py rename to src/forging_blocks/domain/specification/logical_operators/not_specification.py index 3632b6bf3..7c41ede53 100644 --- a/src/forging_blocks/foundation/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/foundation/specification/logical_operators/or_specification.py b/src/forging_blocks/domain/specification/logical_operators/or_specification.py similarity index 84% rename from src/forging_blocks/foundation/specification/logical_operators/or_specification.py rename to src/forging_blocks/domain/specification/logical_operators/or_specification.py index ee3ef252b..ed3b732a5 100644 --- a/src/forging_blocks/foundation/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 97edfc562..0df1f53f4 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 322408170..4e66444e5 100644 --- a/src/forging_blocks/foundation/__init__.py +++ b/src/forging_blocks/foundation/__init__.py @@ -1,7 +1,7 @@ """ForgingBlocks foundation modules.""" +from .autoeq import auto_eq from .autohash import auto_hash -from .context import AuthorizationContext, ServiceContext, TransactionContext from .errors import ( ArchitectureError, CantModifyImmutableAttributeError, @@ -22,7 +22,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 ( @@ -32,34 +31,20 @@ ) 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", @@ -67,24 +52,16 @@ "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/autoeq/README.md b/src/forging_blocks/foundation/autoeq/README.md new file mode 100644 index 000000000..bfb1fa319 --- /dev/null +++ b/src/forging_blocks/foundation/autoeq/README.md @@ -0,0 +1,43 @@ +"""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 forging_blocks.foundation.autoeq import auto_eq + + @auto_eq + 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, 2) + ``` + +=== "With parameters" + ```python + from forging_blocks.foundation.autoeq 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 + + 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 000000000..fdac1547e --- /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 000000000..e78b320cc --- /dev/null +++ b/src/forging_blocks/foundation/autoeq/auto_eq.py @@ -0,0 +1,164 @@ +"""Auto-eq decorator for generating ``__eq__`` on class instances. + +Provides the `auto_eq` decorator that generates ``__eq__`` based on +class fields. Works 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__`` — use `auto_hash` when +hashability is required. + +Useful for: Plain data classes and any type that requires +structural equality comparisons. + +Example: + ```python + from forging_blocks.foundation.autoeq 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 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 +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 `auto_hash` separately when hashability + is required. + """ + + 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``, field names are resolved from ``__slots__`` + (across the MRO) or ``__annotations__`` keys. + + """ + 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 `auto_hash` + separately when hashability is required. + + """ + 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 + type.__setattr__(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. + + Can be used as ``@auto_eq``, ``@auto_eq()``, or + ``@auto_eq(fields=[...])``. Generates ``__eq__`` only — does NOT + generate ``__hash__``. Use `auto_hash` when hashability + is required. + + 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``, field names are resolved from ``__slots__`` + (across the MRO) or ``__annotations__`` keys. + + Returns: + 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 = _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 000000000..a1b566ea9 --- /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 000000000..85f8210ca --- /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 000000000..6a0d57103 --- /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/autofreeze/auto_freeze.py b/src/forging_blocks/foundation/autofreeze/auto_freeze.py index 6ac2644a9..e4e27564a 100644 --- a/src/forging_blocks/foundation/autofreeze/auto_freeze.py +++ b/src/forging_blocks/foundation/autofreeze/auto_freeze.py @@ -1,19 +1,18 @@ """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. 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: Immutable data types and any class that should be immutable after construction. Example: ```python from forging_blocks.foundation.autofreeze import auto_freeze - from forging_blocks.foundation.errors import CantModifyImmutableAttributeError @auto_freeze @@ -33,7 +32,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/autofreeze/helpers/frozen_state.py b/src/forging_blocks/foundation/autofreeze/helpers/frozen_state.py index 0c38967d4..5bd0ef972 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. diff --git a/src/forging_blocks/foundation/autohash/README.md b/src/forging_blocks/foundation/autohash/README.md new file mode 100644 index 000000000..5dd65586a --- /dev/null +++ b/src/forging_blocks/foundation/autohash/README.md @@ -0,0 +1,48 @@ +"""The `auto_hash` decorator generates `__hash__` for class instances. + +Does **not** generate ``__eq__`` or apply ``auto_freeze``. Combine with +`auto_eq` for explicit equality, +and `auto_freeze` for immutability. + +## Usage + +=== "Bare decorator" + ```python + from forging_blocks.foundation.autohash 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) + assert hash(p1) == hash(p2) + ``` + +=== "With parameters" + ```python + from forging_blocks.foundation.autohash import auto_hash + + @auto_hash(fields=["x"]) + 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, 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 a5b226b44..4aef42555 100644 --- a/src/forging_blocks/foundation/autohash/auto_hash.py +++ b/src/forging_blocks/foundation/autohash/auto_hash.py @@ -1,44 +1,50 @@ -"""Auto-hash decorator for generating ``__hash__`` on dataclass instances. +"""Auto-hash decorator for generating ``__hash__`` 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. -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 +Provides the `auto_hash` decorator that generates ``__hash__`` +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. -Explicit ``@auto_freeze`` is NOT required — ``@auto_hash`` applies it -automatically. Applying both is harmless (idempotent). +Does NOT generate ``__eq__`` — combine with `auto_eq` when structural +equality is needed alongside hashing. + +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 Money: - amount: int - currency: str + class UserId: + __slots__ = ("value",) + + def __init__(self, value: str) -> None: + self.value = value + + u1 = UserId("abc") + u2 = UserId("abc") + assert hash(u1) == hash(u2) + ``` + + With selective fields: + ```python + @auto_hash(fields=["id"]) + class Entity: + __slots__ = ("id", "name") - m1 = Money(100, "USD") - m2 = Money(100, "USD") - assert m1 == m2 - assert hash(m1) == hash(m2) + 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) ``` """ @@ -47,7 +53,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, ) @@ -56,9 +61,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 + 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. + """ def __init__(self, *, fields: Sequence[str] | None = None) -> None: @@ -66,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 @@ -79,7 +85,9 @@ 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__`` generated from its fields. + Equality (``__eq__``) is NOT generated — use `auto_eq` + separately for structural equality comparisons. """ field_names = self._resolve_field_names(class_) @@ -93,7 +101,7 @@ def _hash_impl(self: Any) -> int: _hash_impl.__qualname__ = f"{class_.__name__}.__hash__" class_.__hash__ = _hash_impl - _auto_freeze(class_) + type.__setattr__(class_, "__auto_hash_fields__", _field_names) return class_ def _resolve_field_names(self, class_: type[object]) -> list[str]: @@ -101,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. @@ -131,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) @@ -181,26 +188,26 @@ def auto_hash[T]( ) -> type[T] | Callable[[type[T]], type[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 - applying ``@auto_freeze`` internally, which forbids attribute assignment - after ``__init__`` completes. - 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. + Can be used as ``@auto_hash``, ``@auto_hash()``, or + ``@auto_hash(fields=[...])``. Generates ``__hash__`` only — does NOT + generate ``__eq__``. Use `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. + 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 fields declared in ``__slots__`` or + ``__annotations__`` are used. Returns: - The decorated class (or a decorator when used parameterised), - with both ``__hash__`` and immutability applied. + 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 = _AutoHashDecorator(fields=fields) diff --git a/src/forging_blocks/foundation/autohash/helpers/hashable_converter.py b/src/forging_blocks/foundation/autohash/helpers/hashable_converter.py index 057405ed9..3f8c17570 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()) diff --git a/src/forging_blocks/foundation/context/__init__.py b/src/forging_blocks/foundation/context/__init__.py deleted file mode 100644 index 318c053f7..000000000 --- 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 8887d52bf..000000000 --- a/src/forging_blocks/foundation/context/authorization_context.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Authorization context bundled for a single authorization decision.""" - -from collections.abc import Hashable - -from forging_blocks.foundation.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._equality_components - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - 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 fb04f7c30..000000000 --- a/src/forging_blocks/foundation/context/service_context.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Immutable context carried through every application-service call.""" - -import uuid -from collections.abc import Hashable - -from forging_blocks.foundation.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._equality_components - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - 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 cf24dea05..000000000 --- a/src/forging_blocks/foundation/context/transaction_context.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Metadata for a single transactional boundary.""" - -import uuid -from collections.abc import Hashable -from datetime import datetime, timezone - -from forging_blocks.foundation.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._equality_components - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return ( - self._transaction_id, - self._started_at, - self._isolation_level, - self._metadata, - ) 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 f235398c0..ae86820fb 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``). """ 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 b965c5da2..214794a8c 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/foundation/messages/message.py b/src/forging_blocks/foundation/messages/message.py deleted file mode 100644 index 752425209..000000000 --- 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/meta/final_abc_meta.py b/src/forging_blocks/foundation/meta/final_abc_meta.py index 709fd5c50..5a03ab9a3 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:: diff --git a/src/forging_blocks/foundation/meta/final_meta.py b/src/forging_blocks/foundation/meta/final_meta.py index de2c87066..595dedc2e 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 6bc2a9c41..e2a4a381a 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 1b78b357f..10ad86743 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 deleted file mode 100644 index 93ec77e89..000000000 --- a/src/forging_blocks/foundation/value_object.py +++ /dev/null @@ -1,81 +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 collections.abc import Hashable -from typing import Any - -from forging_blocks.foundation.autofreeze import auto_freeze - - -@auto_freeze -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 after ``__init__`` completes. - Intermediate abstract classes remain unfrozen so their concrete leaf - subclasses can finish setting up via ``super().__init__()``. - - 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 - - @property - def _equality_components(self) -> tuple[Hashable, ...]: - return (self._value,) - ``` - - """ - - 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 __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) - - def __str__(self) -> str: - components = self._equality_components - 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.""" - - @property - @abstractmethod - def _equality_components(self) -> tuple[Hashable, ...]: - """Return the components used for equality and hashing.""" diff --git a/src/forging_blocks/infrastructure/__init__.py b/src/forging_blocks/infrastructure/__init__.py index fa7bcc0e2..ede4518a9 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/event_buses/event_bus_base.py b/src/forging_blocks/infrastructure/event_buses/event_bus_base.py index bc055d38f..dbec86a2a 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 a4da5f2af..b64fab48a 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 a26a7b730..dcd73ed6c 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 1dc5e151d..f04f6b70a 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 1ac572ec0..f3c621894 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 880cd85ad..b69f6d555 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 77d618446..70e397a71 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 f857d4128..2e2282521 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 aabee1f03..fef2cd9fb 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 53456748d..744db380c 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 10f1f047f..98487b9a3 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 @@ -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``. """ @@ -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. @@ -84,12 +89,15 @@ 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: """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. @@ -104,7 +112,18 @@ 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) + await super().save(aggregate) + + return aggregate 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 d37ca4287..a3b2152d1 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/__init__.py b/src/forging_blocks/infrastructure/serialization/__init__.py index 0706126de..09a6159b3 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 000000000..14f858e1d --- /dev/null +++ b/src/forging_blocks/infrastructure/serialization/_dict_message_codec.py @@ -0,0 +1,57 @@ +"""Dict-based message codec that serializes messages to ``dict[str, object]``.""" + +from datetime import datetime +from typing import cast +from uuid import UUID + +from forging_blocks.domain.messages import Message, MessageMetadata + +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]``. + + 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 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 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", {})) + + metadata = MessageMetadata( + message_type=str(raw_metadata.get("message_type", message_type.__name__)), + 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) 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 000000000..10ca79e33 --- /dev/null +++ b/src/forging_blocks/infrastructure/serialization/_message_codec.py @@ -0,0 +1,42 @@ +"""Message codec ABC for encoding/decoding foundation messages.""" + +from abc import ABC, abstractmethod + + +class MessageCodec[M, Raw](ABC): + """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``). + """ + + @abstractmethod + def encode(self, message: M) -> Raw: + """Encode *message* to its raw representation. + + Args: + message: The message instance to encode. + + Returns: + The raw representation of the message. + """ + ... + + @abstractmethod + 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. + 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 8dccf9896..000000000 --- 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*. - - """ - ... diff --git a/src/forging_blocks/presentation/builtin/logging_middleware.py b/src/forging_blocks/presentation/builtin/logging_middleware.py index 3e98151b1..2e843bc53 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 3ed57afbf..53ad12601 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) diff --git a/src/forging_blocks/presentation/errors/error_message_model.py b/src/forging_blocks/presentation/errors/error_message_model.py index 515b5a7ba..8bd33e608 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 0ad1407be..95132cb40 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/fixtures/fake_event_publisher.py b/tests/fixtures/fake_event_publisher.py index 94cfe7397..97cab0b35 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 d05bc3a7c..32616e927 100644 --- a/tests/fixtures/fake_event_with_name.py +++ b/tests/fixtures/fake_event_with_name.py @@ -1,5 +1,7 @@ -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.message import MessageMetadata +from typing import Self + +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.message import MessageMetadata class FakeEventWithName(Event[dict[str, object]]): @@ -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 bd0a2b73e..eeb79b07b 100644 --- a/tests/fixtures/fake_event_with_value.py +++ b/tests/fixtures/fake_event_with_value.py @@ -1,5 +1,7 @@ -from forging_blocks.foundation.messages.event import Event -from forging_blocks.foundation.messages.message import MessageMetadata +from typing import Self + +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.message import MessageMetadata class FakeEventWithValue(Event[dict[str, object]]): @@ -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 cf1be3ce2..82df0a47b 100644 --- a/tests/fixtures/simple_fake_command.py +++ b/tests/fixtures/simple_fake_command.py @@ -1,5 +1,7 @@ -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.message import MessageMetadata +from typing import Self + +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.message import MessageMetadata class SimpleFakeCommand(Command[dict[str, object]]): @@ -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 66e8961da..db48ea442 100644 --- a/tests/fixtures/simple_fake_command_with_value.py +++ b/tests/fixtures/simple_fake_command_with_value.py @@ -1,5 +1,7 @@ -from forging_blocks.foundation.messages.command import Command -from forging_blocks.foundation.messages.message import MessageMetadata +from typing import Self + +from forging_blocks.domain.messages.command import Command +from forging_blocks.domain.messages.message import MessageMetadata class SimpleFakeCommandWithValue(Command[dict[str, object]]): @@ -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/inbound/test_authorization_port.py b/tests/forging_blocks/application/ports/inbound/test_authorization_port.py index e018ead1b..6d3f0b8a6 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/application/ports/outbound/test_command_sender.py b/tests/forging_blocks/application/ports/outbound/test_command_sender.py index 212ced426..1186c6375 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]): @@ -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 abc006ca0..677ffb41c 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]): @@ -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 39281ea46..af267ed9c 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]): @@ -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/domain/aggregate_root/test_aggregate_root.py b/tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py index 03268fc0b..7762b62a4 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 @@ -8,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] @@ -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/context/__init__.py b/tests/forging_blocks/domain/messages/__init__.py similarity index 100% rename from tests/forging_blocks/foundation/context/__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 64% rename from tests/forging_blocks/foundation/messages/test_command.py rename to tests/forging_blocks/domain/messages/test_command.py index 9d97ff49d..344629583 100644 --- a/tests/forging_blocks/foundation/messages/test_command.py +++ b/tests/forging_blocks/domain/messages/test_command.py @@ -4,18 +4,16 @@ """ # 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 +from typing import Any, Self, cast import pytest -from forging_blocks.foundation.messages import Command, Message, MessageMetadata +from forging_blocks.domain.messages import Command, Message, MessageMetadata 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) @@ -123,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/domain/messages/test_decorators.py b/tests/forging_blocks/domain/messages/test_decorators.py new file mode 100644 index 000000000..729bf5ea4 --- /dev/null +++ b/tests/forging_blocks/domain/messages/test_decorators.py @@ -0,0 +1,146 @@ +# 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.domain.messages.message import Message + + +@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.domain.messages.decorators import ( + _PatchedMessage, + 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.domain.messages.decorators.isinstance", + side_effect=_selective_isinstance, + ): + with pytest.raises(TypeError, match="does not satisfy _PatchedMessage"): + 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.""" + import uuid + + 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() + + @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.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 + + a = Shipped(tracking_code="TRK-001") + 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" diff --git a/tests/forging_blocks/foundation/messages/test_event.py b/tests/forging_blocks/domain/messages/test_event.py similarity index 83% rename from tests/forging_blocks/foundation/messages/test_event.py rename to tests/forging_blocks/domain/messages/test_event.py index 0f475e181..1c56a3ac5 100644 --- a/tests/forging_blocks/foundation/messages/test_event.py +++ b/tests/forging_blocks/domain/messages/test_event.py @@ -1,14 +1,14 @@ # 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 -from forging_blocks.foundation.messages import Event, MessageMetadata +from forging_blocks.domain.messages import Event, MessageMetadata 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", "")), @@ -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) @@ -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/domain/messages/test_message.py b/tests/forging_blocks/domain/messages/test_message.py new file mode 100644 index 000000000..575b486f3 --- /dev/null +++ b/tests/forging_blocks/domain/messages/test_message.py @@ -0,0 +1,146 @@ +"""Unit tests for the Message base class.""" + +from datetime import datetime, timezone +from typing import Any, Self +from uuid import UUID, uuid7 + +import pytest + +from forging_blocks.domain.messages import Message, MessageMetadata + + +class FakeMessage(Message[str]): + """A fake message for testing the abstract Message class.""" + + def __init__(self, data: str, metadata: MessageMetadata | None = None): + super().__init__(metadata) + self._data = data + + @property + def value(self) -> str: + return self._data + + @property + def _payload(self) -> dict[str, Any]: + return {"data": self._data} + + @classmethod + 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 TestMessage: + """Tests for Message class.""" + + def test_init_when_no_metadata_then_creates_default_metadata(self): + message = FakeMessage("test_data") + + assert isinstance(message.metadata, MessageMetadata) + assert isinstance(message.message_id, UUID) + assert isinstance(message.created_at, datetime) + + def test_init_when_custom_metadata_then_uses_provided_metadata(self): + custom_metadata = MessageMetadata( + message_type="CustomType", + message_id=uuid7(), + created_at=datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc), + ) + + message = FakeMessage("test_data", metadata=custom_metadata) + + assert message.metadata == custom_metadata + assert message.message_id == custom_metadata.message_id + assert message.created_at == custom_metadata.created_at + + def test_convenience_properties_when_called_then_delegates_to_metadata(self): + custom_metadata = MessageMetadata( + message_type="CustomType", + message_id=uuid7(), + created_at=datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc), + ) + + message = FakeMessage("test_data", metadata=custom_metadata) + + assert message.message_id == custom_metadata.message_id + assert message.created_at == custom_metadata.created_at + + def test_message_type_when_called_then_returns_class_name(self): + message = FakeMessage("test_data") + + assert message.metadata.message_type == "FakeMessage" + + def test_eq_when_same_message_id_then_true(self): + metadata = MessageMetadata(message_type="CustomType") + message1 = FakeMessage("data1", metadata=metadata) + message2 = FakeMessage("data2", metadata=metadata) + + assert message1 == message2 + + def test_eq_when_different_message_id_then_false(self): + message1 = FakeMessage("same_data") + message2 = FakeMessage("same_data") + + assert message1 != message2 + + def test_eq_when_type_different_than_message_then_false(self) -> None: + metadata = MessageMetadata(message_type="CustomType") + message = FakeMessage("data", metadata=metadata) + another_type_instance = 5 + + assert message != another_type_instance + + def test_hash_when_same_message_id_then_same_hash(self): + metadata = MessageMetadata("FakeMessage", message_id=uuid7()) + + message1 = FakeMessage("data1", metadata=metadata) + message2 = FakeMessage("data2", metadata=metadata) + + assert hash(message1) == hash(message2) + + def test_hash_when_different_message_id_then_different_hash(self): + message1 = FakeMessage("same_data") + message2 = FakeMessage("same_data") + + assert hash(message1) != hash(message2) + + 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") + + message = FakeMessage("test_data", metadata=metadata) + + assert message.metadata is metadata + + 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 + + @property + def _payload(self) -> str: + return self._data + + @classmethod + def from_payload_fields( + cls, data: dict[str, object], metadata: MessageMetadata + ) -> Self: + return cls(str(data.get("data", "")), metadata=metadata) + + fake = FakeMessage("data") + other = _("data") + + assert fake != other diff --git a/tests/forging_blocks/domain/messages/test_metadata.py b/tests/forging_blocks/domain/messages/test_metadata.py new file mode 100644 index 000000000..a15387313 --- /dev/null +++ b/tests/forging_blocks/domain/messages/test_metadata.py @@ -0,0 +1,217 @@ +"""Unit tests for MessageMetadata.""" + +from datetime import datetime, timezone +from uuid import UUID, uuid7 + +import pytest + +from forging_blocks.domain.messages import MessageMetadata +from forging_blocks.foundation import CantModifyImmutableAttributeError + + +@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_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 + + 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_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) + + 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() diff --git a/tests/forging_blocks/domain/messages/test_serializable.py b/tests/forging_blocks/domain/messages/test_serializable.py new file mode 100644 index 000000000..a3f547cbd --- /dev/null +++ b/tests/forging_blocks/domain/messages/test_serializable.py @@ -0,0 +1,129 @@ +"""Tests for message decorators and frozen instance behavior.""" + +import dataclasses +from typing import Any, cast + +import pytest + +from forging_blocks.domain.messages.decorators import ( + command_dataclass, + event_dataclass, + query_dataclass, +) +from forging_blocks.domain.messages.event import Event +from forging_blocks.domain.messages.message import MessageMetadata + + +class TestMessageDataclassDecorator: + """@event_dataclass, @command_dataclass, @query_dataclass.""" + + def test_event_dataclass_produces_payload_from_fields(self) -> None: + """Decorated event _payload reflects its fields.""" + + @event_dataclass + class OrderCreated(Event[dict[str, object]]): + order_id: str + customer_id: str + total: float + + def __init__( + self, + order_id: str, + customer_id: str, + total: float, + metadata: MessageMetadata | None = None, + ) -> None: + super().__init__(metadata) + object.__setattr__(self, "order_id", order_id) + object.__setattr__(self, "customer_id", customer_id) + object.__setattr__(self, "total", total) + + @property + def _payload(self) -> dict[str, object]: + return { + "order_id": self.order_id, + "customer_id": self.customer_id, + "total": self.total, + } + + @property + def value(self) -> dict[str, object]: + return self._payload + + event = OrderCreated(order_id="O1", customer_id="C1", total=99.95) + 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.""" + + @command_dataclass + class CreateOrder(Event[dict[str, object]]): + product: str + quantity: int + + def __init__( + self, product: str, quantity: int, metadata: MessageMetadata | None = None + ) -> None: + super().__init__(metadata) + object.__setattr__(self, "product", product) + object.__setattr__(self, "quantity", quantity) + + @property + def _payload(self) -> dict[str, object]: + return {"product": self.product, "quantity": self.quantity} + + @property + def value(self) -> dict[str, object]: + return self._payload + + cmd = CreateOrder(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.""" + + @query_dataclass + class GetOrder(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 + + q = GetOrder(order_id="O1") + assert q._payload == {"order_id": "O1"} + + 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") + assert evt._payload == {"order_id": "O1"} + + def test_decorated_instance_is_frozen_after_init(self) -> None: + """Assigning to a field after construction raises FrozenInstanceError.""" + + @event_dataclass + class OrderEvent(Event[dict[str, object]]): + order_id: str + + evt: Event[dict[str, object]] = cast(Any, OrderEvent)(order_id="O1") + assert evt._payload == {"order_id": "O1"} + + with pytest.raises(dataclasses.FrozenInstanceError): + cast(Any, evt).order_id = "mutated" 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 cf17cde02..710232588 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 fa7e3a949..9f4ad882b 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 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 159f6b2c8..000000000 --- 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 82b5e4f81..000000000 --- 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 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 97% 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 index 08e0be46d..12e15b9fe 100644 --- a/tests/forging_blocks/foundation/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/foundation/specification/logical_operators/test_not_specification.py b/tests/forging_blocks/domain/specification/logical_operators/test_not_specification.py similarity index 93% 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 index eec98d2e3..fc67f809c 100644 --- a/tests/forging_blocks/foundation/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/foundation/specification/logical_operators/test_or_specification.py b/tests/forging_blocks/domain/specification/logical_operators/test_or_specification.py similarity index 96% 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 index 981c2585a..efe26267f 100644 --- a/tests/forging_blocks/foundation/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/foundation/specification/test_base_specification.py b/tests/forging_blocks/domain/specification/test_base_specification.py similarity index 96% rename from tests/forging_blocks/foundation/specification/test_base_specification.py rename to tests/forging_blocks/domain/specification/test_base_specification.py index df1b2786c..8631ac567 100644 --- a/tests/forging_blocks/foundation/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/foundation/specification/test_composable_specification.py b/tests/forging_blocks/domain/specification/test_composable_specification.py similarity index 90% rename from tests/forging_blocks/foundation/specification/test_composable_specification.py rename to tests/forging_blocks/domain/specification/test_composable_specification.py index c40b6212e..a4b94c98e 100644 --- a/tests/forging_blocks/foundation/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/foundation/specification/test_expression_specification.py b/tests/forging_blocks/domain/specification/test_expression_specification.py similarity index 97% rename from tests/forging_blocks/foundation/specification/test_expression_specification.py rename to tests/forging_blocks/domain/specification/test_expression_specification.py index 60577696f..a2e0719fb 100644 --- a/tests/forging_blocks/foundation/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_init.py b/tests/forging_blocks/domain/test_init.py new file mode 100644 index 000000000..6eb62fe52 --- /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/test_value_objects.py b/tests/forging_blocks/domain/test_value_objects.py similarity index 90% rename from tests/forging_blocks/foundation/test_value_objects.py rename to tests/forging_blocks/domain/test_value_objects.py index aac618a5d..69ca7983c 100644 --- a/tests/forging_blocks/foundation/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]): @@ -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: @@ -94,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") @@ -194,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" diff --git a/tests/forging_blocks/foundation/messages/__init__.py b/tests/forging_blocks/foundation/autoeq/__init__.py similarity index 100% rename from tests/forging_blocks/foundation/messages/__init__.py rename to tests/forging_blocks/foundation/autoeq/__init__.py 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 000000000..b896e4871 --- /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/autoeq/test_field_resolver.py b/tests/forging_blocks/foundation/autoeq/test_field_resolver.py new file mode 100644 index 000000000..230e39678 --- /dev/null +++ b/tests/forging_blocks/foundation/autoeq/test_field_resolver.py @@ -0,0 +1,100 @@ +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) + + 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"] diff --git a/tests/forging_blocks/foundation/autohash/test_auto_hash.py b/tests/forging_blocks/foundation/autohash/test_auto_hash.py index 2e94122d9..34cfcc64f 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 @@ -159,8 +161,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 +173,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 +318,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 +333,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 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 2a07f0f80..000000000 --- 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 812ea6e3e..000000000 --- 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 599d71bb9..000000000 --- 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"),) diff --git a/tests/forging_blocks/foundation/errors/test_core.py b/tests/forging_blocks/foundation/errors/test_core.py index ed5fe0cc6..4998fc4cd 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 b1749e28a..74ba1d995 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_message.py b/tests/forging_blocks/foundation/messages/test_message.py deleted file mode 100644 index 2f8a7fb50..000000000 --- a/tests/forging_blocks/foundation/messages/test_message.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Unit tests for the Message module. - -Tests for MessageMetadata and Message classes. -""" - -from datetime import datetime, timezone -from typing import Any, Self, cast -from uuid import UUID, uuid7 - -import pytest - -from forging_blocks.foundation.messages import Message, MessageMetadata - - -class FakeMessage(Message[str]): - """A fake message for testing the abstract Message class.""" - - def __init__(self, data: str, metadata: MessageMetadata | None = None): - super().__init__(metadata) - self._data = data - - @property - def value(self) -> str: - return self._data - - @property - def _payload(self) -> dict[str, Any]: - return {"data": self._data} - - @classmethod - 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.""" - - def test_init_when_no_metadata_then_creates_default_metadata(self): - message = FakeMessage("test_data") - - assert isinstance(message.metadata, MessageMetadata) - assert isinstance(message.message_id, UUID) - assert isinstance(message.created_at, datetime) - - def test_init_when_custom_metadata_then_uses_provided_metadata(self): - custom_metadata = MessageMetadata( - message_type="CustomType", - message_id=uuid7(), - created_at=datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc), - ) - - message = FakeMessage("test_data", metadata=custom_metadata) - - assert message.metadata == custom_metadata - assert message.message_id == custom_metadata.message_id - assert message.created_at == custom_metadata.created_at - - def test_convenience_properties_when_called_then_delegates_to_metadata(self): - custom_metadata = MessageMetadata( - message_type="CustomType", - message_id=uuid7(), - created_at=datetime(2025, 6, 11, 19, 44, 14, tzinfo=timezone.utc), - ) - - message = FakeMessage("test_data", metadata=custom_metadata) - - assert message.message_id == custom_metadata.message_id - assert message.created_at == custom_metadata.created_at - - def test_message_type_when_called_then_returns_class_name(self): - message = FakeMessage("test_data") - - assert message.metadata.message_type == "FakeMessage" - - def test_eq_when_same_message_id_then_true(self): - metadata = MessageMetadata(message_type="CustomType") - message1 = FakeMessage("data1", metadata=metadata) - message2 = FakeMessage("data2", metadata=metadata) - - assert message1 == message2 - - def test_eq_when_different_message_id_then_false(self): - message1 = FakeMessage("same_data") - message2 = FakeMessage("same_data") - - assert message1 != message2 - - def test_eq_when_type_different_than_message_then_false(self) -> None: - metadata = MessageMetadata(message_type="CustomType") - message = FakeMessage("data", metadata=metadata) - another_type_instance = 5 - - 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()) - - message1 = FakeMessage("data1", metadata=metadata) - message2 = FakeMessage("data2", metadata=metadata) - - assert hash(message1) == hash(message2) - - def test_hash_when_different_message_id_then_different_hash(self): - message1 = FakeMessage("same_data") - message2 = FakeMessage("same_data") - - assert hash(message1) != hash(message2) - - def test_cannot_instantiate_abstract_message_directly(self): - with pytest.raises(TypeError, match="abstract"): - type.__call__(Message) - - -@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_serializable.py b/tests/forging_blocks/foundation/messages/test_serializable.py deleted file mode 100644 index 621edc1dc..000000000 --- a/tests/forging_blocks/foundation/messages/test_serializable.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Tests for the Serializable protocol and message serialisation support.""" - -import dataclasses -from datetime import datetime, timezone -from typing import Any, Self, cast -from uuid import uuid7 - -import pytest - -from forging_blocks.foundation.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 - - -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.""" - - @event_dataclass - class OrderCreated(Event[dict[str, object]]): - order_id: str - customer_id: str - total: float - - def __init__( - self, - order_id: str, - customer_id: str, - total: float, - metadata: MessageMetadata | None = None, - ) -> None: - super().__init__(metadata) - object.__setattr__(self, "order_id", order_id) - object.__setattr__(self, "customer_id", customer_id) - object.__setattr__(self, "total", total) - - @property - def _payload(self) -> dict[str, object]: - return { - "order_id": self.order_id, - "customer_id": self.customer_id, - "total": self.total, - } - - @property - 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" - - def test_command_dataclass_alias(self) -> None: - """@command_dataclass is an alias for @message_dataclass.""" - - @command_dataclass - class CreateOrder(Event[dict[str, object]]): - product: str - quantity: int - - def __init__( - self, product: str, quantity: int, metadata: MessageMetadata | None = None - ) -> None: - super().__init__(metadata) - object.__setattr__(self, "product", product) - object.__setattr__(self, "quantity", quantity) - - @property - def _payload(self) -> dict[str, object]: - return {"product": self.product, "quantity": self.quantity} - - @property - def value(self) -> dict[str, object]: - return self._payload - - cmd = CreateOrder(product="Widget", quantity=3) - assert cmd.get_domain_data() == {"product": "Widget", "quantity": 3} - - def test_query_dataclass_alias(self) -> None: - """@query_dataclass is an alias for @message_dataclass.""" - - @query_dataclass - class GetOrder(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 - - q = GetOrder(order_id="O1") - assert q.get_domain_data() == {"order_id": "O1"} - - def test_metadata_excluded_from_domain_data(self) -> None: - """Private and reserved fields are excluded from get_domain_data().""" - - @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"} - - def test_decorated_instance_is_frozen_after_init(self) -> None: - """Assigning to a field after construction raises FrozenInstanceError.""" - - @event_dataclass - 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"} - - 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/foundation/meta/test_final_abc_meta.py b/tests/forging_blocks/foundation/meta/test_final_abc_meta.py index ae5846159..25c8cb05f 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 a28873d10..9a46e4b01 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 634a0d9e6..22c6a51ff 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/infrastructure/event_buses/test_in_memory_event_bus.py b/tests/forging_blocks/infrastructure/event_buses/test_in_memory_event_bus.py index 11695f339..07c844ecb 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 a00219afd..96214002c 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 01ca754f2..2b5f2598e 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, ) @@ -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 1429f8d5e..f36429e2a 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 @@ -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 5b9e361e6..af5a2faa7 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 @@ -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 c587e7fad..10e8fe5d4 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 @@ -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 80676b70a..397c8a7b7 100644 --- a/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py +++ b/tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py @@ -8,7 +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.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, @@ -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/repositories/test_in_memory_repository.py b/tests/forging_blocks/infrastructure/repositories/test_in_memory_repository.py index 5bcb5dbd2..9db51ae0e 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/__init__.py b/tests/forging_blocks/infrastructure/serialization/__init__.py new file mode 100644 index 000000000..e69de29bb 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 000000000..968a63e99 --- /dev/null +++ b/tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py @@ -0,0 +1,151 @@ +"""Unit tests for DictMessageCodec round-trip encoding/decoding.""" + +import pytest + +from forging_blocks.domain.messages import ( + MessageMetadata, + command_dataclass, + event_dataclass, + query_dataclass, +) +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} + + +@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" + + 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) diff --git a/tests/forging_blocks/infrastructure/test_event_bus.py b/tests/forging_blocks/infrastructure/test_event_bus.py index 7a4a0418f..0257b3125 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 @@ -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 a0807f14f..e060460db 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, ) @@ -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/domain/value_objects/test_release_level.py b/tests/scripts/release/domain/value_objects/test_release_level.py index baf9e03d7..bbaf99ff4 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") 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 18035d5bc..06c66c53b 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",) 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 7caa2be67..726f1d793 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 ( @@ -7,7 +7,8 @@ ) 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 forging_blocks.domain.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()) diff --git a/tests/scripts/test_generate_autodoc_pages.py b/tests/scripts/test_generate_autodoc_pages.py index d68ba5be3..118583931 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 000000000..2b1a636e5 --- /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]