nlbone 0.6.19__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. nlbone/adapters/db/postgres/__init__.py +1 -1
  2. nlbone/adapters/db/postgres/base.py +2 -1
  3. nlbone/adapters/db/postgres/query_builder.py +1 -1
  4. nlbone/adapters/db/postgres/repository.py +254 -29
  5. nlbone/adapters/db/postgres/uow.py +36 -1
  6. nlbone/adapters/messaging/__init__.py +1 -1
  7. nlbone/adapters/messaging/event_bus.py +97 -17
  8. nlbone/adapters/messaging/rabbitmq.py +45 -0
  9. nlbone/adapters/outbox/__init__.py +1 -0
  10. nlbone/adapters/outbox/outbox_consumer.py +112 -0
  11. nlbone/adapters/outbox/outbox_repo.py +191 -0
  12. nlbone/adapters/ticketing/client.py +39 -0
  13. nlbone/config/settings.py +14 -5
  14. nlbone/container.py +1 -8
  15. nlbone/core/application/bus.py +169 -0
  16. nlbone/core/application/di.py +128 -0
  17. nlbone/core/application/registry.py +129 -0
  18. nlbone/core/domain/base.py +30 -9
  19. nlbone/core/domain/models.py +46 -3
  20. nlbone/core/ports/__init__.py +0 -2
  21. nlbone/core/ports/event_bus.py +23 -6
  22. nlbone/core/ports/outbox.py +73 -0
  23. nlbone/core/ports/repository.py +116 -0
  24. nlbone/core/ports/uow.py +20 -1
  25. nlbone/interfaces/api/additional_filed/field_registry.py +2 -0
  26. nlbone/interfaces/cli/crypto.py +22 -0
  27. nlbone/interfaces/cli/init_db.py +39 -2
  28. nlbone/interfaces/cli/main.py +4 -0
  29. nlbone/interfaces/cli/ticket.py +29 -0
  30. nlbone/interfaces/jobs/dispatch_outbox.py +3 -2
  31. nlbone/utils/crypto.py +32 -0
  32. nlbone/utils/normalize_mobile.py +33 -0
  33. {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/METADATA +4 -2
  34. {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/RECORD +38 -31
  35. nlbone/adapters/repositories/outbox_repo.py +0 -20
  36. nlbone/core/application/command_bus.py +0 -25
  37. nlbone/core/application/events.py +0 -20
  38. nlbone/core/application/services.py +0 -0
  39. nlbone/core/domain/events.py +0 -0
  40. nlbone/core/ports/messaging.py +0 -0
  41. nlbone/core/ports/repo.py +0 -19
  42. /nlbone/adapters/{messaging/redis.py → ticketing/__init__.py} +0 -0
  43. {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/WHEEL +0 -0
  44. {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/entry_points.txt +0 -0
  45. {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from contextlib import asynccontextmanager, contextmanager
6
+ from datetime import timedelta
7
+ from typing import AsyncIterator, Iterable, Iterator, Optional
8
+
9
+ from nlbone.adapters.outbox.outbox_repo import AsyncOutboxRepository, OutboxRepository
10
+ from nlbone.core.domain.models import Outbox
11
+ from nlbone.core.ports import UnitOfWork
12
+
13
+
14
+ async def outbox_stream(
15
+ repo: AsyncOutboxRepository,
16
+ *,
17
+ batch_size: int = 100,
18
+ idle_sleep: float = 1.0,
19
+ stop_event: Optional[asyncio.Event] = None,
20
+ ) -> AsyncIterator[Outbox]:
21
+ """
22
+ Yields Outbox one-by-one. If none available, waits (idle_sleep) and tries again.
23
+ Designed to run forever until stop_event is set.
24
+ """
25
+ while True:
26
+ if stop_event and stop_event.is_set():
27
+ return
28
+ batch: list[Outbox] = await repo.claim_batch(limit=batch_size)
29
+ if not batch:
30
+ await asyncio.sleep(idle_sleep)
31
+ continue
32
+ for msg in batch:
33
+ yield msg
34
+
35
+
36
+ @asynccontextmanager
37
+ async def process_message(
38
+ repo: AsyncOutboxRepository,
39
+ msg: Outbox,
40
+ *,
41
+ backoff: timedelta = timedelta(seconds=30),
42
+ ):
43
+ """
44
+ Usage:
45
+ async with process_message(repo, msg):
46
+ ... do work ...
47
+ On success -> mark_published
48
+ On exception -> mark_failed with backoff
49
+ """
50
+ try:
51
+ yield msg
52
+ except Exception as e: # noqa: BLE001
53
+ await repo.mark_failed(msg.id, str(e), backoff=backoff)
54
+ raise
55
+ else:
56
+ await repo.mark_published([msg.id])
57
+
58
+
59
+ async def process_batch(
60
+ repo: AsyncOutboxRepository,
61
+ messages: Iterable[Outbox],
62
+ *,
63
+ backoff: timedelta = timedelta(seconds=30),
64
+ concurrency: int = 1,
65
+ handler=None,
66
+ ):
67
+ """
68
+ Optional helper: run a handler concurrently on a batch.
69
+ handler: async callable(msg) -> None/… ; ack/nack handled via context manager.
70
+ """
71
+ sem = asyncio.Semaphore(concurrency)
72
+
73
+ async def _run(m: Outbox):
74
+ async with sem:
75
+ async with process_message(repo, m, backoff=backoff):
76
+ if handler:
77
+ await handler(m)
78
+
79
+ await asyncio.gather(*(_run(m) for m in messages))
80
+
81
+
82
+ def outbox_stream_sync(
83
+ repo: OutboxRepository,
84
+ *,
85
+ topics : list[str] = None,
86
+ batch_size: int = 100,
87
+ idle_sleep: float = 1.0,
88
+ stop_flag: Optional[callable] = None,
89
+ ) -> Iterator[Outbox]:
90
+ while True:
91
+ if stop_flag and stop_flag():
92
+ return
93
+ batch = repo.claim_batch(limit=batch_size, topics=topics)
94
+ if not batch:
95
+ time.sleep(idle_sleep)
96
+ continue
97
+ for msg in batch:
98
+ yield msg
99
+
100
+
101
+ @contextmanager
102
+ def process_message_sync(uow: UnitOfWork, msg: Outbox, *, backoff: timedelta = timedelta(seconds=30)):
103
+ try:
104
+ yield msg
105
+ except Exception as e:
106
+ uow.rollback()
107
+ msg.mark_failed(str(e), backoff=backoff)
108
+ uow.commit()
109
+ raise
110
+ else:
111
+ msg.mark_published()
112
+ uow.commit()
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
5
+
6
+ from sqlalchemy import (
7
+ delete,
8
+ select,
9
+ update,
10
+ )
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+ from sqlalchemy.orm import Session
13
+
14
+ from nlbone.core.domain.models import Outbox, OutboxStatus
15
+ from nlbone.core.ports.outbox import AsyncOutboxRepository, OutboxRepository
16
+
17
+
18
+ class SQLAlchemyOutboxRepository(OutboxRepository):
19
+ def __init__(self, session: Session) -> None:
20
+ self.session = session
21
+
22
+ def _now(self) -> datetime:
23
+ return datetime.now(timezone.utc)
24
+
25
+ def enqueue(self, topic: str, payload: Dict[str, Any], *, headers=None, key=None, available_at=None) -> Outbox:
26
+ row = Outbox(
27
+ topic=topic,
28
+ payload=payload,
29
+ headers=headers or {},
30
+ key=key,
31
+ available_at=available_at or self._now(),
32
+ )
33
+ self.session.add(row)
34
+ # Do not commit here; caller's transaction should commit
35
+ self.session.flush()
36
+ return row
37
+
38
+ def enqueue_many(
39
+ self, items: Iterable[Tuple[str, Dict[str, Any]]], *, headers=None, available_at=None
40
+ ) -> List[Outbox]:
41
+ rows = [
42
+ Outbox(topic=t, payload=p, headers=headers or {}, available_at=available_at or self._now())
43
+ for t, p in items
44
+ ]
45
+ self.session.add_all(rows)
46
+ self.session.flush()
47
+ return [r for r in rows]
48
+
49
+ def claim_batch(self, *, topics: list[str] = None, limit: int = 100, now: Optional[datetime] = None) -> List[
50
+ Outbox]:
51
+ now = now or self._now()
52
+ # Select candidates eligible to process
53
+ stmt = (
54
+ select(Outbox)
55
+ .where(
56
+ Outbox.topic.in_(topics),
57
+ Outbox.status.in_([OutboxStatus.PENDING, OutboxStatus.FAILED]),
58
+ Outbox.available_at <= now,
59
+ (Outbox.next_attempt_at.is_(None)) | (Outbox.next_attempt_at <= now),
60
+ )
61
+ .order_by(Outbox.id)
62
+ .limit(limit)
63
+ .with_for_update(skip_locked=True)
64
+ )
65
+ rows = self.session.execute(stmt).scalars().all()
66
+ if not rows:
67
+ return []
68
+ # Mark as PROCESSING and increment attempts
69
+ ids = [r.id for r in rows]
70
+ self.session.execute(
71
+ update(Outbox)
72
+ .where(Outbox.id.in_(ids))
73
+ .values(status=OutboxStatus.PROCESSING, attempts=Outbox.attempts + 1)
74
+ )
75
+ self.session.flush()
76
+ return [r for r in rows]
77
+
78
+ def mark_published(self, ids: Iterable[int]) -> None:
79
+ ids = list(ids)
80
+ if not ids:
81
+ return
82
+ self.session.execute(
83
+ update(Outbox)
84
+ .where(Outbox.id.in_(ids))
85
+ .values(status=OutboxStatus.PUBLISHED, last_error=None, next_attempt_at=None)
86
+ )
87
+
88
+ def mark_failed(self, id: int, error: str, *, backoff: timedelta = timedelta(seconds=30)) -> None:
89
+ self.session.execute(
90
+ update(Outbox)
91
+ .where(Outbox.id == id)
92
+ .values(
93
+ status=OutboxStatus.FAILED,
94
+ last_error=error,
95
+ next_attempt_at=self._now() + backoff,
96
+ )
97
+ )
98
+
99
+ def delete_older_than(self, *, before: datetime, status: Optional[OutboxStatus] = None) -> int:
100
+ conds = [Outbox.created_at < before]
101
+ if status is not None:
102
+ conds.append(Outbox.status == status)
103
+ stmt = delete(Outbox).where(*conds)
104
+ res = self.session.execute(stmt)
105
+ return res.rowcount or 0
106
+
107
+
108
+ class SQLAlchemyAsyncOutboxRepository(AsyncOutboxRepository):
109
+ def __init__(self, session: AsyncSession) -> None:
110
+ self.session = session
111
+
112
+ def _now(self) -> datetime:
113
+ return datetime.now(timezone.utc)
114
+
115
+ async def enqueue(
116
+ self, topic: str, payload: Dict[str, Any], *, headers=None, key=None, available_at=None
117
+ ) -> Outbox:
118
+ row = Outbox(
119
+ topic=topic,
120
+ payload=payload,
121
+ headers=headers or {},
122
+ key=key,
123
+ available_at=available_at or self._now(),
124
+ )
125
+ self.session.add(row)
126
+ await self.session.flush()
127
+ return row
128
+
129
+ async def enqueue_many(
130
+ self, items: Iterable[Tuple[str, Dict[str, Any]]], *, headers=None, available_at=None
131
+ ) -> List[Outbox]:
132
+ rows = [
133
+ Outbox(topic=t, payload=p, headers=headers or {}, available_at=available_at or self._now())
134
+ for t, p in items
135
+ ]
136
+ self.session.add_all(rows)
137
+ await self.session.flush()
138
+ return [r for r in rows]
139
+
140
+ async def claim_batch(self, *, limit: int = 100, now: Optional[datetime] = None) -> List[Outbox]:
141
+ now = now or self._now()
142
+ stmt = (
143
+ select(Outbox)
144
+ .where(
145
+ Outbox.status.in_([OutboxStatus.PENDING, OutboxStatus.FAILED]),
146
+ Outbox.available_at <= now,
147
+ (Outbox.next_attempt_at.is_(None)) | (Outbox.next_attempt_at <= now),
148
+ )
149
+ .order_by(Outbox.id)
150
+ .limit(limit)
151
+ .with_for_update(skip_locked=True)
152
+ )
153
+ rows = (await self.session.execute(stmt)).scalars().all()
154
+ if not rows:
155
+ return []
156
+ ids = [r.id for r in rows]
157
+ await self.session.execute(
158
+ update(Outbox)
159
+ .where(Outbox.id.in_(ids))
160
+ .values(status=OutboxStatus.PROCESSING, attempts=Outbox.attempts + 1)
161
+ )
162
+ await self.session.flush()
163
+ return [r.to_domain() for r in rows]
164
+
165
+ async def mark_published(self, ids: Iterable[int]) -> None:
166
+ ids = list(ids)
167
+ if not ids:
168
+ return
169
+ await self.session.execute(
170
+ update(Outbox)
171
+ .where(Outbox.id.in_(ids))
172
+ .values(status=OutboxStatus.PUBLISHED, last_error=None, next_attempt_at=None)
173
+ )
174
+
175
+ async def mark_failed(self, id: int, error: str, *, backoff: timedelta = timedelta(seconds=30)) -> None:
176
+ await self.session.execute(
177
+ update(Outbox)
178
+ .where(Outbox.id == id)
179
+ .values(
180
+ status=OutboxStatus.FAILED,
181
+ last_error=error,
182
+ next_attempt_at=self._now() + backoff,
183
+ )
184
+ )
185
+
186
+ async def delete_older_than(self, *, before: datetime, status: Optional[OutboxStatus] = None) -> int:
187
+ conds = [Outbox.created_at < before]
188
+ if status is not None:
189
+ conds.append(Outbox.status == status)
190
+ res = await self.session.execute(delete(Outbox).where(*conds))
191
+ return res.rowcount or 0
@@ -0,0 +1,39 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from nlbone.adapters.messaging.rabbitmq import RabbitMQEventBus
6
+ from nlbone.config.settings import get_settings
7
+
8
+
9
+ class CreateTicketIn(BaseModel):
10
+ assignee_id: str
11
+ category_id: int
12
+ channel: str
13
+ direction: str
14
+ entity_id: str
15
+ entity_type: str
16
+ message: str
17
+ priority: str
18
+ product_id: int
19
+ status: str
20
+ title: str
21
+ user_id: int
22
+
23
+
24
+ class TicketingClient:
25
+ def __init__(self):
26
+ settings = get_settings()
27
+ self._bus = RabbitMQEventBus(settings.RABBITMQ_URL)
28
+ self._exchange = settings.RABBITMQ_TICKETING_EXCHANGE
29
+ self._rk_create_v1 = settings.RABBITMQ_TICKETING_ROUTING_KEY_CREATE_V1
30
+
31
+ async def create_ticket(self, payload: CreateTicketIn, created_by_id: int, *,
32
+ override_exchange: Optional[str] = None,
33
+ override_routing_key: Optional[str] = None) -> None:
34
+ exchange = override_exchange or self._exchange
35
+ routing_key = override_routing_key or self._rk_create_v1
36
+ payload = payload.model_dump()
37
+ payload.update({'created_by_id': created_by_id})
38
+ print(payload)
39
+ await self._bus.publish(exchange=exchange, routing_key=routing_key, payload=payload)
nlbone/config/settings.py CHANGED
@@ -26,11 +26,11 @@ def _guess_env_file() -> str | None:
26
26
  raise Exception("Failed to guess env file path!") from e
27
27
 
28
28
 
29
- def is_production_env() -> bool:
29
+ def read_from_env_fild() -> bool:
30
30
  raw = os.getenv("NLBONE_ENV") or os.getenv("ENV") or os.getenv("ENVIRONMENT")
31
31
  if not raw:
32
32
  return False
33
- return raw.strip().lower() in {"prod", "production"}
33
+ return raw.strip().lower() not in {"prod", "production", "stage", "staging"}
34
34
 
35
35
 
36
36
  class Settings(BaseSettings):
@@ -96,6 +96,15 @@ class Settings(BaseSettings):
96
96
  # ---------------------------
97
97
  PRICING_SERVICE_URL: AnyHttpUrl = Field(default="https://pricing.numberland.ir/v1")
98
98
 
99
+ # ---------------------------
100
+ # Crypto
101
+ # ---------------------------
102
+ FERNET_KEY: str = Field(default="")
103
+
104
+ RABBITMQ_URL: str = Field(default="", description="amqp(s)://user:pass@host:5672/vhost")
105
+ RABBITMQ_TICKETING_EXCHANGE: str = "crm_stage.ticket"
106
+ RABBITMQ_TICKETING_ROUTING_KEY_CREATE_V1: str = "crm_stage.ticket.create.v1"
107
+
99
108
  model_config = SettingsConfigDict(
100
109
  env_prefix="",
101
110
  env_file=None,
@@ -105,9 +114,9 @@ class Settings(BaseSettings):
105
114
 
106
115
  @classmethod
107
116
  def load(cls, env_file: str | None = None) -> "Settings":
108
- if is_production_env():
109
- return cls()
110
- return cls(_env_file=env_file or _guess_env_file())
117
+ if read_from_env_fild():
118
+ return cls(_env_file=env_file or _guess_env_file())
119
+ return cls()
111
120
 
112
121
 
113
122
  @lru_cache(maxsize=4)
nlbone/container.py CHANGED
@@ -10,13 +10,10 @@ from nlbone.adapters.auth.token_provider import ClientTokenProvider
10
10
  from nlbone.adapters.cache.async_redis import AsyncRedisCache
11
11
  from nlbone.adapters.cache.memory import InMemoryCache
12
12
  from nlbone.adapters.cache.redis import RedisCache
13
- from nlbone.adapters.db.postgres import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
14
13
  from nlbone.adapters.db.postgres.engine import get_async_session_factory, get_sync_session_factory
15
14
  from nlbone.adapters.http_clients import PricingService
16
15
  from nlbone.adapters.http_clients.uploadchi import UploadchiClient
17
16
  from nlbone.adapters.http_clients.uploadchi.uploadchi_async import UploadchiAsyncClient
18
- from nlbone.adapters.messaging import InMemoryEventBus
19
- from nlbone.core.ports import EventBusPort
20
17
  from nlbone.core.ports.cache import AsyncCachePort, CachePort
21
18
  from nlbone.core.ports.files import AsyncFileServicePort, FileServicePort
22
19
 
@@ -27,12 +24,8 @@ class Container(containers.DeclarativeContainer):
27
24
  sync_session_factory = providers.Singleton(get_sync_session_factory)
28
25
  async_session_factory = providers.Singleton(get_async_session_factory)
29
26
 
30
- # --- UoW ---
31
- uow = providers.Factory(SqlAlchemyUnitOfWork, session_factory=sync_session_factory)
32
- async_uow = providers.Factory(AsyncSqlAlchemyUnitOfWork, session_factory=async_session_factory)
33
-
34
27
  # --- Event bus ---
35
- event_bus: providers.Singleton[EventBusPort] = providers.Singleton(InMemoryEventBus)
28
+ # event_bus: providers.Singleton[EventBusPort] = providers.Singleton(InMemoryEventBus)
36
29
 
37
30
  # --- Services ---
38
31
  auth: providers.Singleton[KeycloakAuthService] = providers.Singleton(KeycloakAuthService)
@@ -0,0 +1,169 @@
1
+ import traceback
2
+ from typing import Any, Callable, Coroutine, List, Optional, Protocol
3
+
4
+ from nlbone.core.application.registry import AsyncHandlerRegistry, HandlerRegistry
5
+ from nlbone.core.domain.base import Command, DomainEvent, Message
6
+ from nlbone.core.ports import AsyncUnitOfWork, UnitOfWork
7
+ from nlbone.core.ports.event_bus import AsyncOutboxPublisher, OutboxPublisher
8
+ from nlbone.interfaces.api.middleware.access_log import logger
9
+
10
+
11
+ class SyncMiddleware(Protocol):
12
+ def __call__(self, message: Message, next_: Callable[[Message], None]) -> None: # noqa: D401
13
+ ...
14
+
15
+
16
+ class AsyncMiddleware(Protocol):
17
+ async def __call__(self, message: Message, next_: Callable[[Message], Coroutine[Any, Any, None]]) -> None: ...
18
+
19
+
20
+ class SyncMessageBus:
21
+ def __init__(
22
+ self,
23
+ uow: UnitOfWork,
24
+ registry: HandlerRegistry,
25
+ middlewares: Optional[List[SyncMiddleware]] = None,
26
+ outbox: Optional[OutboxPublisher] = None,
27
+ ) -> None:
28
+ self.uow = uow
29
+ self.registry = registry
30
+ self.middlewares = middlewares or []
31
+ self.outbox = outbox
32
+ self._queue: List[Message] = []
33
+
34
+ def handle(self, message: Message) -> None:
35
+ self._queue.append(message)
36
+ while self._queue:
37
+ msg = self._queue.pop(0)
38
+ self._dispatch_with_pipeline(msg)
39
+
40
+ # pipeline that wraps dispatch
41
+ def _dispatch_with_pipeline(self, message: Message) -> None:
42
+ def terminal(m: Message) -> None:
43
+ self._dispatch(m)
44
+
45
+ # build chain right-to-left
46
+ next_callable = terminal
47
+ for mw in reversed(self.middlewares):
48
+ current_mw = mw
49
+
50
+ def make_next(nxt: Callable[[Message], None]): # closure helper
51
+ def _mw_call(m: Message) -> None:
52
+ return current_mw(m, nxt)
53
+
54
+ return _mw_call
55
+
56
+ next_callable = make_next(next_callable)
57
+ next_callable(message)
58
+
59
+ def _dispatch(self, message: Message) -> None:
60
+ if isinstance(message, DomainEvent):
61
+ self._handle_event(message)
62
+ elif isinstance(message, Command):
63
+ self._handle_command(message)
64
+ else:
65
+ raise TypeError(f"Unknown message type: {type(message)!r}")
66
+
67
+ def _handle_event(self, event: DomainEvent) -> None:
68
+ handlers = self.registry.for_event(type(event))
69
+ for handler in handlers:
70
+ try:
71
+ logger.debug("handling event %s with %s", event, handler)
72
+ produced = handler(event)
73
+ if produced:
74
+ self._queue.extend(produced)
75
+ self._queue.extend(self.uow.collect_new_events())
76
+ except Exception: # noqa: BLE001
77
+ logger.exception("Exception handling event %s\n%s", event, traceback.format_exc())
78
+ continue
79
+
80
+ def _handle_command(self, command: Command) -> None:
81
+ handler = self.registry.for_command(type(command))
82
+ try:
83
+ logger.debug("handling command %s with %s", command, handler)
84
+ produced = handler(command)
85
+ if produced:
86
+ self._queue.extend(produced)
87
+ # commit (and gather/emit domain events)
88
+ self._queue.extend(self.uow.collect_new_events())
89
+ if self.outbox:
90
+ self.outbox.publish(self._queue) # best-effort; in real systems use a DB-backed outbox
91
+ except Exception: # noqa: BLE001
92
+ logger.exception("Exception handling command %s\n%s", command, traceback.format_exc())
93
+ raise
94
+
95
+
96
+ # ==========================
97
+ # MessageBus (async)
98
+ # ==========================
99
+ class AsyncMessageBus:
100
+ def __init__(
101
+ self,
102
+ uow: AsyncUnitOfWork,
103
+ registry: AsyncHandlerRegistry,
104
+ middlewares: Optional[List[AsyncMiddleware]] = None,
105
+ outbox: Optional[AsyncOutboxPublisher] = None,
106
+ ) -> None:
107
+ self.uow = uow
108
+ self.registry = registry
109
+ self.middlewares = middlewares or []
110
+ self.outbox = outbox
111
+ self._queue: List[Message] = []
112
+
113
+ async def handle(self, message: Message) -> None:
114
+ self._queue.append(message)
115
+ while self._queue:
116
+ msg = self._queue.pop(0)
117
+ await self._dispatch_with_pipeline(msg)
118
+
119
+ async def _dispatch_with_pipeline(self, message: Message) -> None:
120
+ async def terminal(m: Message) -> None:
121
+ await self._dispatch(m)
122
+
123
+ next_callable = terminal
124
+ for mw in reversed(self.middlewares):
125
+ current_mw = mw
126
+
127
+ def make_next(nxt: Callable[[Message], Coroutine[Any, Any, None]]):
128
+ async def _mw_call(m: Message) -> None:
129
+ return await current_mw(m, nxt)
130
+
131
+ return _mw_call
132
+
133
+ next_callable = make_next(next_callable)
134
+ await next_callable(message)
135
+
136
+ async def _dispatch(self, message: Message) -> None:
137
+ if isinstance(message, DomainEvent):
138
+ await self._handle_event(message)
139
+ elif isinstance(message, Command):
140
+ await self._handle_command(message)
141
+ else:
142
+ raise TypeError(f"Unknown message type: {type(message)!r}")
143
+
144
+ async def _handle_event(self, event: DomainEvent) -> None:
145
+ handlers = self.registry.for_event(type(event))
146
+ for handler in handlers:
147
+ try:
148
+ logger.debug("handling event %s with %s", event, handler)
149
+ produced = await handler(event)
150
+ if produced:
151
+ self._queue.extend(produced)
152
+ self._queue.extend(await self.uow.collect_new_events())
153
+ except Exception: # noqa: BLE001
154
+ logger.exception("Exception handling event %s\n%s", event, traceback.format_exc())
155
+ continue
156
+
157
+ async def _handle_command(self, command: Command) -> None:
158
+ handler = self.registry.for_command(type(command))
159
+ try:
160
+ logger.debug("handling command %s with %s", command, handler)
161
+ produced = await handler(command)
162
+ if produced:
163
+ self._queue.extend(produced)
164
+ self._queue.extend(await self.uow.collect_new_events())
165
+ if self.outbox:
166
+ await self.outbox.publish(self._queue)
167
+ except Exception: # noqa: BLE001
168
+ logger.exception("Exception handling command %s\n%s", command, traceback.format_exc())
169
+ raise