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.
- nlbone/adapters/db/postgres/__init__.py +1 -1
- nlbone/adapters/db/postgres/base.py +2 -1
- nlbone/adapters/db/postgres/query_builder.py +1 -1
- nlbone/adapters/db/postgres/repository.py +254 -29
- nlbone/adapters/db/postgres/uow.py +36 -1
- nlbone/adapters/messaging/__init__.py +1 -1
- nlbone/adapters/messaging/event_bus.py +97 -17
- nlbone/adapters/messaging/rabbitmq.py +45 -0
- nlbone/adapters/outbox/__init__.py +1 -0
- nlbone/adapters/outbox/outbox_consumer.py +112 -0
- nlbone/adapters/outbox/outbox_repo.py +191 -0
- nlbone/adapters/ticketing/client.py +39 -0
- nlbone/config/settings.py +14 -5
- nlbone/container.py +1 -8
- nlbone/core/application/bus.py +169 -0
- nlbone/core/application/di.py +128 -0
- nlbone/core/application/registry.py +129 -0
- nlbone/core/domain/base.py +30 -9
- nlbone/core/domain/models.py +46 -3
- nlbone/core/ports/__init__.py +0 -2
- nlbone/core/ports/event_bus.py +23 -6
- nlbone/core/ports/outbox.py +73 -0
- nlbone/core/ports/repository.py +116 -0
- nlbone/core/ports/uow.py +20 -1
- nlbone/interfaces/api/additional_filed/field_registry.py +2 -0
- nlbone/interfaces/cli/crypto.py +22 -0
- nlbone/interfaces/cli/init_db.py +39 -2
- nlbone/interfaces/cli/main.py +4 -0
- nlbone/interfaces/cli/ticket.py +29 -0
- nlbone/interfaces/jobs/dispatch_outbox.py +3 -2
- nlbone/utils/crypto.py +32 -0
- nlbone/utils/normalize_mobile.py +33 -0
- {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/METADATA +4 -2
- {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/RECORD +38 -31
- nlbone/adapters/repositories/outbox_repo.py +0 -20
- nlbone/core/application/command_bus.py +0 -25
- nlbone/core/application/events.py +0 -20
- nlbone/core/application/services.py +0 -0
- nlbone/core/domain/events.py +0 -0
- nlbone/core/ports/messaging.py +0 -0
- nlbone/core/ports/repo.py +0 -19
- /nlbone/adapters/{messaging/redis.py → ticketing/__init__.py} +0 -0
- {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/WHEEL +0 -0
- {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/entry_points.txt +0 -0
- {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Callable, Dict, Optional, Type, get_args, get_origin, get_type_hints
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TypeContainer:
|
|
9
|
+
"""Tiny type-based DI: register_instance(T, obj) / register_factory(T, () -> obj)."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._instances: Dict[Type[Any], Any] = {}
|
|
13
|
+
self._factories: Dict[Type[Any], Callable[[], Any]] = {}
|
|
14
|
+
|
|
15
|
+
def register_instance(self, t: Type[Any], instance: Any) -> None:
|
|
16
|
+
self._instances[t] = instance
|
|
17
|
+
|
|
18
|
+
def register_factory(self, t: Type[Any], factory: Callable[[], Any]) -> None:
|
|
19
|
+
self._factories[t] = factory
|
|
20
|
+
|
|
21
|
+
def _providers(self):
|
|
22
|
+
for t, v in self._instances.items():
|
|
23
|
+
yield t, (lambda v=v: v)
|
|
24
|
+
for t, f in self._factories.items():
|
|
25
|
+
yield t, f
|
|
26
|
+
|
|
27
|
+
def _unwrap(self, ann: Any) -> tuple[list[Type[Any]], bool]:
|
|
28
|
+
if ann is inspect._empty:
|
|
29
|
+
return [], True
|
|
30
|
+
origin = get_origin(ann)
|
|
31
|
+
args = list(get_args(ann))
|
|
32
|
+
allow_none = False
|
|
33
|
+
if origin in (Optional, getattr(__import__("typing"), "Union")):
|
|
34
|
+
if type(None) in args:
|
|
35
|
+
allow_none = True
|
|
36
|
+
args = [a for a in args if a is not type(None)]
|
|
37
|
+
return [a for a in args if isinstance(a, type)], allow_none
|
|
38
|
+
if isinstance(ann, type):
|
|
39
|
+
return [ann], False
|
|
40
|
+
return [], True
|
|
41
|
+
|
|
42
|
+
def resolve(self, ann: Any) -> Any:
|
|
43
|
+
types, allow_none = self._unwrap(ann)
|
|
44
|
+
if not types:
|
|
45
|
+
if allow_none:
|
|
46
|
+
return None
|
|
47
|
+
raise LookupError(f"Cannot resolve {ann!r}")
|
|
48
|
+
for T in types:
|
|
49
|
+
# exact
|
|
50
|
+
for pt, make in self._providers():
|
|
51
|
+
if pt is T:
|
|
52
|
+
return make()
|
|
53
|
+
# supertype/provider match
|
|
54
|
+
best = None
|
|
55
|
+
for pt, make in self._providers():
|
|
56
|
+
try:
|
|
57
|
+
if issubclass(T, pt):
|
|
58
|
+
dist = _mro_distance(T, pt)
|
|
59
|
+
if best is None or dist < best[0]:
|
|
60
|
+
best = (dist, make)
|
|
61
|
+
except TypeError:
|
|
62
|
+
pass
|
|
63
|
+
if best:
|
|
64
|
+
return best[1]()
|
|
65
|
+
if allow_none:
|
|
66
|
+
return None
|
|
67
|
+
raise LookupError(f"No provider for {types}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _mro_distance(sub: Type[Any], sup: Type[Any]) -> int:
|
|
71
|
+
try:
|
|
72
|
+
return sub.mro().index(sup)
|
|
73
|
+
except ValueError:
|
|
74
|
+
return 10**6
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _type_hints(obj):
|
|
78
|
+
mod = sys.modules.get(getattr(obj, "__module__", "__name__"))
|
|
79
|
+
return get_type_hints(obj, globalns=vars(mod) if mod else None)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_kwargs(
|
|
83
|
+
sig: inspect.Signature,
|
|
84
|
+
hints: Dict[str, Any],
|
|
85
|
+
container: TypeContainer,
|
|
86
|
+
*,
|
|
87
|
+
skip_params: int = 0,
|
|
88
|
+
) -> Dict[str, Any]:
|
|
89
|
+
kwargs: Dict[str, Any] = {}
|
|
90
|
+
for name, param in list(sig.parameters.items())[skip_params:]:
|
|
91
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
92
|
+
continue
|
|
93
|
+
ann = hints.get(name, param.annotation)
|
|
94
|
+
if param.default is not inspect._empty:
|
|
95
|
+
kwargs[name] = param.default
|
|
96
|
+
else:
|
|
97
|
+
kwargs[name] = container.resolve(ann)
|
|
98
|
+
return kwargs
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bind_callable(fn: Callable[..., Any], c: TypeContainer) -> Callable[..., Any]:
|
|
102
|
+
sig = inspect.signature(fn)
|
|
103
|
+
hints = _type_hints(fn)
|
|
104
|
+
|
|
105
|
+
def wrapper(message: Any):
|
|
106
|
+
kwargs = _build_kwargs(sig, hints, c, skip_params=1)
|
|
107
|
+
return fn(message, **kwargs)
|
|
108
|
+
|
|
109
|
+
return wrapper
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def bind_handler(handler: Any, c: TypeContainer) -> Callable[..., Any]:
|
|
113
|
+
if inspect.isclass(handler):
|
|
114
|
+
init = handler.__init__
|
|
115
|
+
sig = inspect.signature(init)
|
|
116
|
+
hints = _type_hints(init)
|
|
117
|
+
|
|
118
|
+
kwargs = _build_kwargs(sig, hints, c, skip_params=1)
|
|
119
|
+
|
|
120
|
+
instance = handler(**kwargs) # type: ignore[arg-type]
|
|
121
|
+
if not callable(instance):
|
|
122
|
+
raise TypeError(f"{handler!r} must implement __call__")
|
|
123
|
+
return instance
|
|
124
|
+
|
|
125
|
+
if callable(handler):
|
|
126
|
+
return bind_callable(handler, c)
|
|
127
|
+
|
|
128
|
+
raise TypeError(f"Unsupported handler: {handler!r}")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from typing import Any, Callable, Coroutine, Dict, Iterable, List, Optional, Type, TypeVar
|
|
2
|
+
|
|
3
|
+
from nlbone.core.application.di import TypeContainer, bind_handler
|
|
4
|
+
from nlbone.core.domain.base import Command, DomainEvent, Message
|
|
5
|
+
|
|
6
|
+
TMsg = TypeVar("TMsg", bound=Message)
|
|
7
|
+
SyncHandler = Callable[[TMsg], Optional[Iterable[Message]]]
|
|
8
|
+
AsyncHandler = Callable[[TMsg], Coroutine[Any, Any, Optional[Iterable[Message]]]]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HandlerRegistry:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._event_handlers: Dict[Type[DomainEvent], List[SyncHandler[Any]]] = {}
|
|
14
|
+
self._command_handlers: Dict[Type[Command], SyncHandler[Any]] = {}
|
|
15
|
+
|
|
16
|
+
def register_event(self, event_type: Type[DomainEvent], handler: SyncHandler[Any]) -> None:
|
|
17
|
+
self._event_handlers.setdefault(event_type, []).append(handler)
|
|
18
|
+
|
|
19
|
+
def register_command(self, cmd_type: Type[Command], handler: SyncHandler[Any]) -> None:
|
|
20
|
+
if cmd_type in self._command_handlers:
|
|
21
|
+
raise ValueError(f"Command handler already registered for {cmd_type!r}")
|
|
22
|
+
self._command_handlers[cmd_type] = handler
|
|
23
|
+
|
|
24
|
+
def for_event(self, event_type: Type[DomainEvent]) -> List[SyncHandler[Any]]:
|
|
25
|
+
return self._event_handlers.get(event_type, [])
|
|
26
|
+
|
|
27
|
+
def for_command(self, cmd_type: Type[Command]) -> SyncHandler[Any]:
|
|
28
|
+
try:
|
|
29
|
+
return self._command_handlers[cmd_type]
|
|
30
|
+
except KeyError as e:
|
|
31
|
+
raise KeyError(f"No handler for command {cmd_type.__name__}") from e
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AsyncHandlerRegistry:
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._event_handlers: Dict[Type[DomainEvent], List[AsyncHandler[Any]]] = {}
|
|
37
|
+
self._command_handlers: Dict[Type[Command], AsyncHandler[Any]] = {}
|
|
38
|
+
|
|
39
|
+
def register_event(self, event_type: Type[DomainEvent], handler: AsyncHandler[Any]) -> None:
|
|
40
|
+
self._event_handlers.setdefault(event_type, []).append(handler)
|
|
41
|
+
|
|
42
|
+
def register_command(self, cmd_type: Type[Command], handler: AsyncHandler[Any]) -> None:
|
|
43
|
+
if cmd_type in self._command_handlers:
|
|
44
|
+
raise ValueError(f"Command handler already registered for {cmd_type!r}")
|
|
45
|
+
self._command_handlers[cmd_type] = handler
|
|
46
|
+
|
|
47
|
+
def for_event(self, event_type: Type[DomainEvent]) -> List[AsyncHandler[Any]]:
|
|
48
|
+
return self._event_handlers.get(event_type, [])
|
|
49
|
+
|
|
50
|
+
def for_command(self, cmd_type: Type[Command]) -> AsyncHandler[Any]:
|
|
51
|
+
try:
|
|
52
|
+
return self._command_handlers[cmd_type]
|
|
53
|
+
except KeyError as e:
|
|
54
|
+
raise KeyError(f"No handler for command {cmd_type.__name__}") from e
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def handles_event(event_type: Type[DomainEvent], registry: HandlerRegistry):
|
|
58
|
+
def deco(fn: SyncHandler[Any]) -> SyncHandler[Any]:
|
|
59
|
+
registry.register_event(event_type, fn)
|
|
60
|
+
return fn
|
|
61
|
+
|
|
62
|
+
return deco
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def handles_command(cmd_type: Type[Command], registry: HandlerRegistry):
|
|
66
|
+
def deco(fn: SyncHandler[Any]) -> SyncHandler[Any]:
|
|
67
|
+
registry.register_command(cmd_type, fn)
|
|
68
|
+
return fn
|
|
69
|
+
|
|
70
|
+
return deco
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def handles_event_async(event_type: Type[DomainEvent], registry: AsyncHandlerRegistry):
|
|
74
|
+
def deco(fn: AsyncHandler[Any]) -> AsyncHandler[Any]:
|
|
75
|
+
registry.register_event(event_type, fn)
|
|
76
|
+
return fn
|
|
77
|
+
|
|
78
|
+
return deco
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def handles_command_async(cmd_type: Type[Command], registry: AsyncHandlerRegistry):
|
|
82
|
+
def deco(fn: AsyncHandler[Any]) -> AsyncHandler[Any]:
|
|
83
|
+
registry.register_command(cmd_type, fn)
|
|
84
|
+
return fn
|
|
85
|
+
|
|
86
|
+
return deco
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def make_sync_decorators(registry: HandlerRegistry, container: TypeContainer):
|
|
90
|
+
def handles_command(cmd_type):
|
|
91
|
+
def deco(h):
|
|
92
|
+
instance_ref = {"inst": None}
|
|
93
|
+
|
|
94
|
+
def lazy_handler(message):
|
|
95
|
+
if instance_ref["inst"] is None:
|
|
96
|
+
instance_ref["inst"] = bind_handler(h, container)
|
|
97
|
+
return instance_ref["inst"](message)
|
|
98
|
+
|
|
99
|
+
registry.register_command(cmd_type, lazy_handler)
|
|
100
|
+
return h
|
|
101
|
+
|
|
102
|
+
return deco
|
|
103
|
+
|
|
104
|
+
def handles_event(evt_type: Type[Any]):
|
|
105
|
+
def deco(h: Any):
|
|
106
|
+
registry.register_event(evt_type, bind_handler(h, container))
|
|
107
|
+
return h
|
|
108
|
+
|
|
109
|
+
return deco
|
|
110
|
+
|
|
111
|
+
return handles_command, handles_event
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def make_async_decorators(registry: AsyncHandlerRegistry, container: TypeContainer):
|
|
115
|
+
def handles_command_async(cmd_type: Type[Any]):
|
|
116
|
+
def deco(h: Any):
|
|
117
|
+
registry.register_command(cmd_type, bind_handler(h, container))
|
|
118
|
+
return h
|
|
119
|
+
|
|
120
|
+
return deco
|
|
121
|
+
|
|
122
|
+
def handles_event_async(evt_type: Type[Any]):
|
|
123
|
+
def deco(h: Any):
|
|
124
|
+
registry.register_event(evt_type, bind_handler(h, container))
|
|
125
|
+
return h
|
|
126
|
+
|
|
127
|
+
return deco
|
|
128
|
+
|
|
129
|
+
return handles_command_async, handles_event_async
|
nlbone/core/domain/base.py
CHANGED
|
@@ -2,7 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
|
+
from enum import Enum
|
|
5
6
|
from typing import Any, Generic, List, TypeVar
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
6
10
|
|
|
7
11
|
TId = TypeVar("TId")
|
|
8
12
|
|
|
@@ -11,17 +15,28 @@ class DomainError(Exception):
|
|
|
11
15
|
"""Base domain exception."""
|
|
12
16
|
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
class
|
|
18
|
+
|
|
19
|
+
class Message(BaseModel):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DomainEvent(Message):
|
|
16
25
|
"""Immutable domain event."""
|
|
17
26
|
|
|
18
27
|
occurred_at: datetime = datetime.now(timezone.utc)
|
|
28
|
+
event_id: str = uuid4()
|
|
19
29
|
|
|
20
30
|
@property
|
|
21
31
|
def name(self):
|
|
22
32
|
return self.__class__.__name__
|
|
23
33
|
|
|
24
34
|
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Command:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
25
40
|
class ValueObject:
|
|
26
41
|
"""Base for value objects (immutable in practice)."""
|
|
27
42
|
|
|
@@ -39,13 +54,19 @@ class Entity(Generic[TId]):
|
|
|
39
54
|
class AggregateRoot(Entity[TId]):
|
|
40
55
|
"""Aggregate root with domain event collection."""
|
|
41
56
|
|
|
42
|
-
def __init__(self
|
|
43
|
-
self.
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._events: List[DomainEvent] = []
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def events(self) -> List[DomainEvent]:
|
|
62
|
+
return self._events
|
|
44
63
|
|
|
45
64
|
def _raise(self, event: DomainEvent) -> None:
|
|
46
|
-
self.
|
|
65
|
+
self._events.append(event)
|
|
66
|
+
|
|
67
|
+
def clear_events(self) -> None:
|
|
68
|
+
self._events.clear()
|
|
69
|
+
|
|
47
70
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
self._domain_events.clear()
|
|
51
|
-
return events
|
|
71
|
+
class BaseEnum(Enum):
|
|
72
|
+
pass
|
nlbone/core/domain/models.py
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import enum
|
|
1
2
|
import uuid
|
|
2
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, timezone, timedelta
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
3
5
|
|
|
6
|
+
from sqlalchemy import JSON, DateTime, Index, Integer, String, Text
|
|
4
7
|
from sqlalchemy import JSON as SA_JSON
|
|
5
|
-
from sqlalchemy import
|
|
8
|
+
from sqlalchemy import Enum as SA_Enum
|
|
6
9
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
7
10
|
from sqlalchemy.sql import func
|
|
8
11
|
|
|
9
|
-
from nlbone.adapters.db import Base
|
|
12
|
+
from nlbone.adapters.db.postgres.base import Base
|
|
13
|
+
from nlbone.utils.time import now
|
|
10
14
|
|
|
11
15
|
try:
|
|
12
16
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
@@ -38,3 +42,42 @@ class AuditLog(Base):
|
|
|
38
42
|
Index("ix_audit_entity_entityid", "entity", "entity_id"),
|
|
39
43
|
Index("ix_audit_created_at", "created_at"),
|
|
40
44
|
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OutboxStatus(str, enum.Enum):
|
|
48
|
+
PENDING = "pending"
|
|
49
|
+
PROCESSING = "processing"
|
|
50
|
+
PUBLISHED = "published"
|
|
51
|
+
FAILED = "failed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Outbox(Base):
|
|
55
|
+
__tablename__ = "outbox"
|
|
56
|
+
|
|
57
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
58
|
+
topic: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
|
59
|
+
payload: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False)
|
|
60
|
+
headers: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
61
|
+
key: Mapped[Optional[str]] = mapped_column(String(200), nullable=True, index=True)
|
|
62
|
+
|
|
63
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
|
64
|
+
available_at: Mapped[datetime] = mapped_column(
|
|
65
|
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
status: Mapped[OutboxStatus] = mapped_column(SA_Enum(OutboxStatus), default=OutboxStatus.PENDING, index=True)
|
|
69
|
+
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
|
70
|
+
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
71
|
+
next_attempt_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
|
72
|
+
|
|
73
|
+
def mark_failed(self, error: str, *, backoff: timedelta = timedelta(seconds=30)):
|
|
74
|
+
self.status = OutboxStatus.FAILED
|
|
75
|
+
self.last_error = error
|
|
76
|
+
self.next_attempt_at = now() + backoff
|
|
77
|
+
|
|
78
|
+
def mark_published(self):
|
|
79
|
+
self.status = OutboxStatus.PUBLISHED
|
|
80
|
+
self.next_attempt_at = None
|
|
81
|
+
|
|
82
|
+
def to_outbox_row(evt) -> Outbox:
|
|
83
|
+
return Outbox(topic=evt.topic, payload=evt.__dict__)
|
nlbone/core/ports/__init__.py
CHANGED
nlbone/core/ports/event_bus.py
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Any, Awaitable, Callable, Iterable, Protocol, Type, runtime_checkable
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from nlbone.core.domain.base import DomainEvent, Message
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
EventHandler = Callable[[DomainEvent], Any] | Callable[[DomainEvent], Awaitable[Any]]
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
9
|
-
def
|
|
10
|
-
|
|
8
|
+
class EventBus(Protocol):
|
|
9
|
+
def subscribe(self, event_type: Type[DomainEvent] | str, handler: EventHandler) -> None: ...
|
|
10
|
+
|
|
11
|
+
def publish(self, event: DomainEvent) -> None: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EventPublisher(Protocol):
|
|
15
|
+
def publish(self, event: DomainEvent) -> None: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class OutboxPublisher(Protocol):
|
|
20
|
+
"""Optional: publish integration messages reliably after commit."""
|
|
21
|
+
|
|
22
|
+
def publish(self, messages: Iterable[Message]) -> None: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class AsyncOutboxPublisher(Protocol):
|
|
27
|
+
async def publish(self, messages: Iterable[Message]) -> None: ...
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Any, Dict, Iterable, List, Optional, Protocol, Tuple
|
|
5
|
+
|
|
6
|
+
from nlbone.core.domain.models import Outbox, OutboxStatus
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OutboxRepository(Protocol):
|
|
10
|
+
def enqueue(
|
|
11
|
+
self,
|
|
12
|
+
topic: str,
|
|
13
|
+
payload: Dict[str, Any],
|
|
14
|
+
*,
|
|
15
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
16
|
+
key: Optional[str] = None,
|
|
17
|
+
available_at: Optional[datetime] = None,
|
|
18
|
+
) -> Outbox: ...
|
|
19
|
+
|
|
20
|
+
def enqueue_many(
|
|
21
|
+
self,
|
|
22
|
+
items: Iterable[Tuple[str, Dict[str, Any]]],
|
|
23
|
+
*,
|
|
24
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
25
|
+
available_at: Optional[datetime] = None,
|
|
26
|
+
) -> List[Outbox]: ...
|
|
27
|
+
|
|
28
|
+
def claim_batch(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
topics: list[str] = None,
|
|
32
|
+
limit: int = 100,
|
|
33
|
+
now: Optional[datetime] = None,
|
|
34
|
+
) -> List[Outbox]: ...
|
|
35
|
+
|
|
36
|
+
def mark_published(self, ids: Iterable[int]) -> None: ...
|
|
37
|
+
|
|
38
|
+
def mark_failed(self, id: int, error: str, *, backoff: timedelta = timedelta(seconds=30)) -> None: ...
|
|
39
|
+
|
|
40
|
+
def delete_older_than(self, *, before: datetime, status: Optional[OutboxStatus] = None) -> int: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AsyncOutboxRepository(Protocol):
|
|
44
|
+
async def enqueue(
|
|
45
|
+
self,
|
|
46
|
+
topic: str,
|
|
47
|
+
payload: Dict[str, Any],
|
|
48
|
+
*,
|
|
49
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
50
|
+
key: Optional[str] = None,
|
|
51
|
+
available_at: Optional[datetime] = None,
|
|
52
|
+
) -> Outbox: ...
|
|
53
|
+
|
|
54
|
+
async def enqueue_many(
|
|
55
|
+
self,
|
|
56
|
+
items: Iterable[Tuple[str, Dict[str, Any]]],
|
|
57
|
+
*,
|
|
58
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
59
|
+
available_at: Optional[datetime] = None,
|
|
60
|
+
) -> List[Outbox]: ...
|
|
61
|
+
|
|
62
|
+
async def claim_batch(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
limit: int = 100,
|
|
66
|
+
now: Optional[datetime] = None,
|
|
67
|
+
) -> List[Outbox]: ...
|
|
68
|
+
|
|
69
|
+
async def mark_published(self, ids: Iterable[int]) -> None: ...
|
|
70
|
+
|
|
71
|
+
async def mark_failed(self, id: int, error: str, *, backoff: timedelta = timedelta(seconds=30)) -> None: ...
|
|
72
|
+
|
|
73
|
+
async def delete_older_than(self, *, before: datetime, status: Optional[OutboxStatus] = None) -> int: ...
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import (
|
|
3
|
+
Any,
|
|
4
|
+
Callable,
|
|
5
|
+
ClassVar,
|
|
6
|
+
Generic,
|
|
7
|
+
Iterable,
|
|
8
|
+
List,
|
|
9
|
+
Optional,
|
|
10
|
+
Type,
|
|
11
|
+
TypeVar,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from nlbone.core.domain.base import AggregateRoot
|
|
15
|
+
from nlbone.interfaces.api.exceptions import NotFoundException
|
|
16
|
+
|
|
17
|
+
ID = TypeVar("ID")
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Repository(Generic[T, ID], ABC):
|
|
22
|
+
model: ClassVar[Type[Any]]
|
|
23
|
+
seen: set[AggregateRoot] = set()
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def get(self, id: ID) -> Optional[T]: ...
|
|
27
|
+
|
|
28
|
+
def get_or_raise(self, id: ID) -> T:
|
|
29
|
+
...
|
|
30
|
+
entity = self.get(id)
|
|
31
|
+
if entity is None:
|
|
32
|
+
raise NotFoundException(f"Entity with id={id!r} not found")
|
|
33
|
+
return entity
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def list(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
offset: int = 0,
|
|
40
|
+
limit: Optional[int] = None,
|
|
41
|
+
where: Optional[Callable[[T], bool]] = None,
|
|
42
|
+
order_by: Optional[Callable[[T], object]] = None,
|
|
43
|
+
reverse: bool = False,
|
|
44
|
+
) -> List[T]: ...
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def count(self, *, where: Optional[Callable[[T], bool]] = None) -> int: ...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def exists(self, id: ID) -> bool: ...
|
|
51
|
+
|
|
52
|
+
# --- Write ---
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def add(self, entity: T) -> T: ...
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def add_many(self, entities: Iterable[T]) -> List[T]: ...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def update(self, entity: T) -> T: ...
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def delete(self, id: ID) -> bool: ...
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def clear(self) -> None: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# -----------------------------
|
|
70
|
+
# Async Repository (Abstract)
|
|
71
|
+
# -----------------------------
|
|
72
|
+
class AsyncRepository(Generic[T, ID], ABC):
|
|
73
|
+
model: ClassVar[Type[Any]]
|
|
74
|
+
seen: set[AggregateRoot] = set()
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
async def get(self, id: ID) -> Optional[T]: ...
|
|
78
|
+
|
|
79
|
+
async def get_or_raise(self, id: ID) -> T:
|
|
80
|
+
entity = await self.get(id)
|
|
81
|
+
if entity is None:
|
|
82
|
+
raise NotFoundException(f"Entity with id={id!r} not found")
|
|
83
|
+
return entity
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
async def list(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
offset: int = 0,
|
|
90
|
+
limit: Optional[int] = None,
|
|
91
|
+
where: Optional[Callable[[T], bool]] = None,
|
|
92
|
+
order_by: Optional[Callable[[T], object]] = None,
|
|
93
|
+
reverse: bool = False,
|
|
94
|
+
) -> List[T]: ...
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
async def count(self, *, where: Optional[Callable[[T], bool]] = None) -> int: ...
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
async def exists(self, id: ID) -> bool: ...
|
|
101
|
+
|
|
102
|
+
# --- Write ---
|
|
103
|
+
@abstractmethod
|
|
104
|
+
async def add(self, entity: T) -> T: ...
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
async def add_many(self, entities: Iterable[T]) -> List[T]: ...
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
async def update(self, entity: T) -> T: ...
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
async def delete(self, id: ID) -> bool: ...
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
async def clear(self) -> None: ...
|
nlbone/core/ports/uow.py
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Protocol, runtime_checkable
|
|
3
|
+
from typing import AsyncIterator, Iterator, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from nlbone.core.domain.base import DomainEvent
|
|
6
|
+
from nlbone.core.ports.outbox import AsyncOutboxRepository, OutboxRepository
|
|
4
7
|
|
|
5
8
|
|
|
6
9
|
@runtime_checkable
|
|
7
10
|
class UnitOfWork(Protocol):
|
|
11
|
+
outbox: list
|
|
12
|
+
outbox_repo: OutboxRepository
|
|
13
|
+
|
|
8
14
|
def __enter__(self) -> "UnitOfWork": ...
|
|
15
|
+
|
|
9
16
|
def __exit__(self, exc_type, exc, tb) -> None: ...
|
|
17
|
+
|
|
10
18
|
def commit(self) -> None: ...
|
|
19
|
+
|
|
11
20
|
def rollback(self) -> None: ...
|
|
12
21
|
|
|
22
|
+
def collect_new_events(self) -> Iterator[DomainEvent]: ...
|
|
23
|
+
|
|
13
24
|
|
|
14
25
|
@runtime_checkable
|
|
15
26
|
class AsyncUnitOfWork(Protocol):
|
|
27
|
+
outbox: list
|
|
28
|
+
outbox_repo: AsyncOutboxRepository
|
|
29
|
+
|
|
16
30
|
async def __aenter__(self) -> "AsyncUnitOfWork": ...
|
|
31
|
+
|
|
17
32
|
async def __aexit__(self, exc_type, exc, tb) -> None: ...
|
|
33
|
+
|
|
18
34
|
async def commit(self) -> None: ...
|
|
35
|
+
|
|
19
36
|
async def rollback(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
async def collect_new_events(self) -> AsyncIterator[DomainEvent]: ...
|
|
@@ -11,6 +11,8 @@ Loader = Callable
|
|
|
11
11
|
def _schema_fields(schema: Type[BaseModel], by_alias: bool = True) -> Set[str]:
|
|
12
12
|
names = set()
|
|
13
13
|
for name, f in schema.model_fields.items():
|
|
14
|
+
if f.json_schema_extra and f.json_schema_extra.get("exclude_none"):
|
|
15
|
+
continue
|
|
14
16
|
names.add(f.alias or name if by_alias else name)
|
|
15
17
|
return names
|
|
16
18
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from nlbone.utils.crypto import decrypt_text, encrypt_text
|
|
4
|
+
|
|
5
|
+
crypto_command = typer.Typer(help="Encryption / Decryption utilities")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@crypto_command.command("encrypt")
|
|
9
|
+
def encrypt_cmd(value: str):
|
|
10
|
+
"""Encrypt a plain text string."""
|
|
11
|
+
encrypted = encrypt_text(value)
|
|
12
|
+
typer.secho(f"🔐 Encrypted:\n{encrypted}", fg=typer.colors.GREEN)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@crypto_command.command("decrypt")
|
|
16
|
+
def decrypt_cmd(value: str):
|
|
17
|
+
"""Decrypt an encrypted token string."""
|
|
18
|
+
try:
|
|
19
|
+
decrypted = decrypt_text(value)
|
|
20
|
+
typer.secho(f"🔓 Decrypted:\n{decrypted}", fg=typer.colors.CYAN)
|
|
21
|
+
except Exception as e:
|
|
22
|
+
typer.secho(f"❌ Failed to decrypt: {e}", fg=typer.colors.RED)
|