Coverage for src / lexigram / contracts / events / messages.py: 0%
84 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-15 18:57 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-15 18:57 +0800
1"""Base message classes for Event Sourcing and CQRS.
3This module defines the foundational Message class and MessageMetadata
4that all messages inherit from. These are defined in contracts to allow
5cross-package interoperability without direct sibling dependencies.
7Also defines MessageSerializerProtocol for serializing/deserializing events.
8"""
10from __future__ import annotations
12from dataclasses import MISSING, asdict, dataclass, field, replace
13from datetime import UTC, datetime
14from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, runtime_checkable
15from uuid import UUID, uuid4
17if TYPE_CHECKING:
18 from lexigram.contracts.domain import DomainModelProtocol
20TResult = TypeVar("TResult")
23@runtime_checkable
24class MessageSerializerProtocol(Protocol):
25 """Protocol for serializing and deserializing messages.
27 Different message formats (JSON, Avro, Protobuf) can implement
28 this protocol to provide serialization for event stores and message buses.
29 """
31 def serialize(self, message: Message) -> bytes:
32 """Serialize a message to bytes.
34 Args:
35 message: Message to serialize.
37 Returns:
38 Serialized message bytes.
39 """
40 ...
42 def deserialize(self, data: bytes, message_type: type[Message]) -> Message:
43 """Deserialize bytes to a message.
45 Args:
46 data: Serialized message bytes.
47 message_type: Type of message to deserialize to.
49 Returns:
50 Deserialized message instance.
51 """
52 ...
54 def serialize_event(self, event: DomainModelProtocol) -> bytes:
55 """Serialize a domain event to bytes.
57 Args:
58 event: Domain event to serialize.
60 Returns:
61 Serialized event bytes.
62 """
63 ...
65 def deserialize_event(
66 self, data: bytes, event_type: type[DomainModelProtocol]
67 ) -> DomainModelProtocol:
68 """Deserialize bytes to a domain event.
70 Args:
71 data: Serialized event bytes.
72 event_type: Type of event to deserialize to.
74 Returns:
75 Deserialized event instance.
76 """
77 ...
80@dataclass(frozen=True)
81class MessageMetadata:
82 """Metadata attached to messages for tracking and correlation."""
84 correlation_id: UUID | None = None
85 causation_id: UUID | None = None
86 user_id: str | None = None
87 tenant_id: str | None = None
88 trace_id: str | None = None
89 span_id: str | None = None
90 custom: dict[str, Any] = field(default_factory=dict)
92 def with_correlation(self, correlation_id: UUID) -> MessageMetadata:
93 """Create new metadata with correlation ID."""
94 return replace(self, correlation_id=correlation_id)
96 def with_causation(self, causation_id: UUID) -> MessageMetadata:
97 """Create new metadata with causation ID."""
98 return replace(self, causation_id=causation_id)
100 def with_user(self, user_id: str) -> MessageMetadata:
101 """Create new metadata with user ID."""
102 return replace(self, user_id=user_id)
104 def with_tenant(self, tenant_id: str) -> MessageMetadata:
105 """Create new metadata with tenant ID."""
106 return replace(self, tenant_id=tenant_id)
108 def with_custom(self, key: str, value: Any) -> MessageMetadata:
109 """Create new metadata with additional custom field."""
110 new_custom = {**self.custom, key: value}
111 return replace(self, custom=new_custom)
114@dataclass(frozen=True)
115class Message:
116 """Base class for all messages in the Events system."""
118 id: UUID = field(default_factory=uuid4)
119 timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
120 metadata: MessageMetadata = field(default_factory=MessageMetadata)
122 def with_metadata(self, metadata: MessageMetadata) -> Message:
123 """Create a copy of the message with new metadata."""
124 return replace(self, metadata=metadata)
126 def with_correlation_id(self, correlation_id: UUID) -> Message:
127 """Create a copy with a correlation ID."""
128 new_metadata = self.metadata.with_correlation(correlation_id)
129 return self.with_metadata(new_metadata)
131 def with_causation_id(self, causation_id: UUID) -> Message:
132 """Create a copy with a causation ID."""
133 new_metadata = self.metadata.with_causation(causation_id)
134 return self.with_metadata(new_metadata)
136 @property
137 def correlation_id(self) -> UUID | None:
138 """Get correlation ID from metadata."""
139 return self.metadata.correlation_id
141 @property
142 def causation_id(self) -> UUID | None:
143 """Get causation ID from metadata."""
144 return self.metadata.causation_id
146 def to_dict(self) -> dict[str, Any]:
147 """Convert message to dictionary."""
148 return asdict(self)
150 @classmethod
151 def from_dict(cls, data: dict[str, Any]) -> Message:
152 """Create message from dictionary."""
153 return cls(**data)
156# ``Event`` and ``DomainEventContext`` were removed in the 2026 refactor.
157# ``DomainEvent`` in ``lexigram.contracts.domain.events`` now serves as the
158# single canonical representation of domain events, with an optional
159# ``actor_id`` field replacing the earlier context wrapper.
162@dataclass(frozen=True)
163class Query(Message, Generic[TResult]):
164 """Represents a request for data."""
166 include_deleted: bool = False
167 cache_key: str | None = None
168 skip_cache: bool = False
171@dataclass(frozen=True)
172class PaginatedQuery(Query[TResult]):
173 """Query with built-in pagination support."""
175 page: int = 1
176 page_size: int = 20
177 sort_by: str | None = None
178 sort_desc: bool = False
181@dataclass(init=False, frozen=True)
182class Command(Message, Generic[TResult]):
183 """Represents an intent to change state.
185 A frozen dataclass that accepts all declared fields as keyword arguments,
186 plus any extra kwargs (stored as object attributes for forward-compatibility).
187 We intentionally disable dataclass-generated ``__init__`` and supply our
188 own so that subclasses can be instantiated with their specific fields without
189 receiving ``TypeError`` for unknown extras.
191 Note: This class intentionally does *not* inherit from ``DomainModel``
192 (which lives in ``lexigram``, not ``lexigram-contracts``) to avoid a
193 circular dependency. All dataclass bookkeeping is done directly here.
194 """
196 def __init__(self, *args: Any, **kwargs: Any) -> None:
197 dc_fields = getattr(self.__class__, "__dataclass_fields__", {})
198 for fname, f in dc_fields.items():
199 if fname in kwargs:
200 object.__setattr__(self, fname, kwargs.pop(fname))
201 elif f.default is not MISSING:
202 object.__setattr__(self, fname, f.default)
203 elif f.default_factory is not MISSING:
204 object.__setattr__(self, fname, f.default_factory())
205 # else: required field not provided — TypeError raised naturally
206 # Store any remaining extra kwargs as attributes (forward compatibility)
207 for k, v in kwargs.items():
208 object.__setattr__(self, k, v)
211@dataclass(frozen=True)
212class IdempotentCommand(Command[TResult]):
213 """Represents a command that can be safely retried."""
215 idempotency_key: str | None = None
218__all__ = [
219 "Command",
220 "IdempotentCommand",
221 "Message",
222 "MessageMetadata",
223 "MessageSerializerProtocol",
224 "PaginatedQuery",
225 "Query",
226]