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,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
@@ -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
- @dataclass(frozen=True)
15
- class DomainEvent:
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, *args, **kwargs) -> None:
43
- self._domain_events: List[DomainEvent] = []
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._domain_events.append(event)
65
+ self._events.append(event)
66
+
67
+ def clear_events(self) -> None:
68
+ self._events.clear()
69
+
47
70
 
48
- def pull_events(self) -> List[DomainEvent]:
49
- events = list(self._domain_events)
50
- self._domain_events.clear()
51
- return events
71
+ class BaseEnum(Enum):
72
+ pass
@@ -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 DateTime, Index, String, Text
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__)
@@ -1,5 +1,3 @@
1
1
  from .auth import AuthService
2
- from .event_bus import EventBusPort
3
2
  from .files import AsyncFileServicePort, FileServicePort
4
- from .repo import AsyncRepository, Repository
5
3
  from .uow import AsyncUnitOfWork, UnitOfWork
@@ -1,10 +1,27 @@
1
- from __future__ import annotations
1
+ from typing import Any, Awaitable, Callable, Iterable, Protocol, Type, runtime_checkable
2
2
 
3
- from typing import Callable, Iterable, Protocol
3
+ from nlbone.core.domain.base import DomainEvent, Message
4
4
 
5
- from nlbone.core.domain.base import DomainEvent
5
+ EventHandler = Callable[[DomainEvent], Any] | Callable[[DomainEvent], Awaitable[Any]]
6
6
 
7
7
 
8
- class EventBusPort(Protocol):
9
- def publish(self, events: Iterable[DomainEvent]) -> None: ...
10
- def subscribe(self, event_name: str, handler: Callable[[DomainEvent], None]) -> None: ...
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)