nlbone 0.4.0__py3-none-any.whl → 0.4.1__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 (46) hide show
  1. nlbone/adapters/auth/__init__.py +1 -1
  2. nlbone/adapters/auth/keycloak.py +3 -2
  3. nlbone/adapters/db/__init__.py +2 -2
  4. nlbone/adapters/db/sqlalchemy/__init__.py +4 -4
  5. nlbone/adapters/db/sqlalchemy/base.py +0 -2
  6. nlbone/adapters/db/sqlalchemy/engine.py +4 -3
  7. nlbone/adapters/db/sqlalchemy/query_builder.py +27 -11
  8. nlbone/adapters/db/sqlalchemy/repository.py +4 -2
  9. nlbone/adapters/db/sqlalchemy/schema.py +5 -5
  10. nlbone/adapters/db/sqlalchemy/uow.py +3 -2
  11. nlbone/adapters/http_clients/uploadchi.py +27 -11
  12. nlbone/adapters/http_clients/uploadchi_async.py +29 -7
  13. nlbone/adapters/messaging/event_bus.py +3 -0
  14. nlbone/config/logging.py +3 -8
  15. nlbone/config/settings.py +11 -22
  16. nlbone/container.py +6 -5
  17. nlbone/core/application/events.py +5 -1
  18. nlbone/core/application/use_case.py +3 -1
  19. nlbone/core/domain/base.py +4 -0
  20. nlbone/core/ports/__init__.py +3 -3
  21. nlbone/core/ports/auth.py +1 -0
  22. nlbone/core/ports/event_bus.py +3 -0
  23. nlbone/core/ports/files.py +26 -5
  24. nlbone/core/ports/repo.py +5 -2
  25. nlbone/core/ports/uow.py +3 -0
  26. nlbone/interfaces/api/dependencies/__init__.py +11 -3
  27. nlbone/interfaces/api/dependencies/async_auth.py +61 -0
  28. nlbone/interfaces/api/dependencies/auth.py +5 -3
  29. nlbone/interfaces/api/dependencies/db.py +4 -2
  30. nlbone/interfaces/api/dependencies/uow.py +2 -1
  31. nlbone/interfaces/api/exception_handlers.py +17 -15
  32. nlbone/interfaces/api/exceptions.py +1 -2
  33. nlbone/interfaces/api/middleware/__init__.py +2 -2
  34. nlbone/interfaces/api/middleware/access_log.py +12 -8
  35. nlbone/interfaces/api/middleware/add_request_context.py +55 -52
  36. nlbone/interfaces/api/middleware/authentication.py +4 -1
  37. nlbone/interfaces/api/pagination/__init__.py +4 -5
  38. nlbone/interfaces/api/pagination/offset_base.py +0 -2
  39. nlbone/interfaces/cli/init_db.py +3 -0
  40. nlbone/utils/context.py +14 -4
  41. nlbone/utils/time.py +1 -1
  42. {nlbone-0.4.0.dist-info → nlbone-0.4.1.dist-info}/METADATA +1 -9
  43. nlbone-0.4.1.dist-info/RECORD +72 -0
  44. nlbone-0.4.0.dist-info/RECORD +0 -71
  45. {nlbone-0.4.0.dist-info → nlbone-0.4.1.dist-info}/WHEEL +0 -0
  46. {nlbone-0.4.0.dist-info → nlbone-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -1 +1 @@
1
- from .keycloak import KeycloakAuthService
1
+ from .keycloak import KeycloakAuthService
@@ -1,7 +1,8 @@
1
1
  from keycloak import KeycloakOpenID
2
2
  from keycloak.exceptions import KeycloakAuthenticationError
3
- from nlbone.core.ports.auth import AuthService
3
+
4
4
  from nlbone.config.settings import Settings, get_settings
5
+ from nlbone.core.ports.auth import AuthService
5
6
 
6
7
 
7
8
  class KeycloakAuthService(AuthService):
@@ -70,4 +71,4 @@ class KeycloakAuthService(AuthService):
70
71
  def client_has_access(self, token: str, permissions: list[str], allowed_clients: set[str] | None = None) -> bool:
71
72
  if not self.is_client_token(token, allowed_clients):
72
73
  return False
73
- return self.has_access(token, permissions)
74
+ return self.has_access(token, permissions)
@@ -1,3 +1,3 @@
1
- from .sqlalchemy.engine import init_async_engine, async_session, async_ping, init_sync_engine, sync_session, sync_ping
2
- from .sqlalchemy import get_paginated_response, apply_pagination
1
+ from .sqlalchemy import apply_pagination, get_paginated_response
3
2
  from .sqlalchemy.base import Base
3
+ from .sqlalchemy.engine import async_ping, async_session, init_async_engine, init_sync_engine, sync_ping, sync_session
@@ -1,4 +1,4 @@
1
- from .query_builder import get_paginated_response, apply_pagination
2
- from .engine import init_sync_engine, init_async_engine, sync_ping, sync_session, async_ping, async_session
3
- from .repository import SqlAlchemyRepository, AsyncSqlAlchemyRepository
4
- from .uow import SqlAlchemyUnitOfWork, AsyncSqlAlchemyUnitOfWork
1
+ from .engine import async_ping, async_session, init_async_engine, init_sync_engine, sync_ping, sync_session
2
+ from .query_builder import apply_pagination, get_paginated_response
3
+ from .repository import AsyncSqlAlchemyRepository, SqlAlchemyRepository
4
+ from .uow import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
@@ -1,5 +1,3 @@
1
1
  from sqlalchemy.orm import declarative_base
2
2
 
3
3
  Base = declarative_base()
4
-
5
-
@@ -1,16 +1,15 @@
1
1
  from contextlib import asynccontextmanager, contextmanager
2
- from typing import Generator, Optional, Any, AsyncGenerator
2
+ from typing import Any, AsyncGenerator, Generator, Optional
3
3
 
4
4
  from sqlalchemy import create_engine, text
5
5
  from sqlalchemy.engine import Engine
6
- from sqlalchemy.orm import Session, sessionmaker
7
-
8
6
  from sqlalchemy.ext.asyncio import (
9
7
  AsyncEngine,
10
8
  AsyncSession,
11
9
  async_sessionmaker,
12
10
  create_async_engine,
13
11
  )
12
+ from sqlalchemy.orm import Session, sessionmaker
14
13
 
15
14
  from nlbone.config.settings import get_settings
16
15
 
@@ -117,12 +116,14 @@ def sync_ping() -> None:
117
116
  with eng.connect() as conn:
118
117
  conn.execute(text("SELECT 1"))
119
118
 
119
+
120
120
  def get_async_session_factory() -> async_sessionmaker[AsyncSession]:
121
121
  if _async_session_factory is None:
122
122
  init_async_engine()
123
123
  assert _async_session_factory is not None
124
124
  return _async_session_factory
125
125
 
126
+
126
127
  def get_sync_session_factory() -> sessionmaker[Session]:
127
128
  if _sync_session_factory is None:
128
129
  init_sync_engine()
@@ -1,12 +1,22 @@
1
- from typing import Union, Callable, Any, Optional, Type, Sequence
1
+ from typing import Any, Callable, Optional, Sequence, Type, Union
2
2
 
3
3
  from sqlalchemy import asc, desc, or_
4
+ from sqlalchemy.dialects.postgresql import ENUM as PGEnum
5
+ from sqlalchemy.orm import Query, Session
4
6
  from sqlalchemy.orm.interfaces import LoaderOption
5
7
  from sqlalchemy.sql.sqltypes import (
6
- String, Text, Integer, BigInteger, SmallInteger, Numeric, Float, Boolean, Enum as SAEnum
8
+ BigInteger,
9
+ Boolean,
10
+ Float,
11
+ Integer,
12
+ Numeric,
13
+ SmallInteger,
14
+ String,
15
+ Text,
16
+ )
17
+ from sqlalchemy.sql.sqltypes import (
18
+ Enum as SAEnum,
7
19
  )
8
- from sqlalchemy.orm import Session, Query
9
- from sqlalchemy.dialects.postgresql import ENUM as PGEnum
10
20
 
11
21
  from nlbone.interfaces.api.exceptions import UnprocessableEntityException
12
22
  from nlbone.interfaces.api.pagination import PaginateRequest, PaginateResponse
@@ -122,12 +132,16 @@ def _apply_filters(pagination, entity, query):
122
132
  return float(v)
123
133
  # Booleans
124
134
  if isinstance(coltype, Boolean):
125
- if isinstance(v, bool): return v
126
- if isinstance(v, (int, float)): return bool(v)
135
+ if isinstance(v, bool):
136
+ return v
137
+ if isinstance(v, (int, float)):
138
+ return bool(v)
127
139
  if isinstance(v, str):
128
140
  vl = v.strip().lower()
129
- if vl in {"true", "1", "yes", "y", "t"}: return True
130
- if vl in {"false", "0", "no", "n", "f"}: return False
141
+ if vl in {"true", "1", "yes", "y", "t"}:
142
+ return True
143
+ if vl in {"false", "0", "no", "n", "f"}:
144
+ return False
131
145
  return None
132
146
  # fallback
133
147
  return v
@@ -215,7 +229,8 @@ def _serialize_item(item: Any, output_cls: OutputType) -> Any:
215
229
  try:
216
230
  obj = output_cls(item) # type: ignore[call-arg]
217
231
  try:
218
- from dataclasses import is_dataclass, asdict
232
+ from dataclasses import asdict, is_dataclass
233
+
219
234
  if is_dataclass(obj):
220
235
  return asdict(obj)
221
236
  except Exception:
@@ -252,5 +267,6 @@ def get_paginated_response(
252
267
  data = [output_cls.model_validate(r, from_attributes=True).model_dump() for r in rows]
253
268
  else:
254
269
  data = rows
255
- return PaginateResponse(total_count=total_count, data=data, limit=pagination.limit,
256
- offset=pagination.offset).to_dict()
270
+ return PaginateResponse(
271
+ total_count=total_count, data=data, limit=pagination.limit, offset=pagination.offset
272
+ ).to_dict()
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
- from typing import Generic, Iterable, Optional, Type, TypeVar, List
2
+
3
+ from typing import Generic, Iterable, List, Optional, Type, TypeVar
3
4
 
4
5
  from sqlalchemy import select
5
6
  from sqlalchemy.ext.asyncio import AsyncSession
6
7
  from sqlalchemy.orm import Session
7
- from nlbone.core.ports.repo import Repository, AsyncRepository
8
+
9
+ from nlbone.core.ports.repo import AsyncRepository, Repository
8
10
 
9
11
  T = TypeVar("T")
10
12
 
@@ -1,18 +1,17 @@
1
-
2
1
  import importlib
3
2
  from typing import Sequence
4
3
 
5
4
  from nlbone.adapters.db.sqlalchemy.base import Base
6
5
  from nlbone.adapters.db.sqlalchemy.engine import init_async_engine, init_sync_engine
7
6
 
7
+ DEFAULT_MODEL_MODULES: Sequence[str] = ()
8
8
 
9
- DEFAULT_MODEL_MODULES: Sequence[str] = (
10
- )
11
9
 
12
10
  def import_model_modules(modules: Sequence[str] | None = None) -> None:
13
- for m in (modules or DEFAULT_MODEL_MODULES):
11
+ for m in modules or DEFAULT_MODEL_MODULES:
14
12
  importlib.import_module(m)
15
13
 
14
+
16
15
  # --------- Async (SQLAlchemy 2.x) ----------
17
16
  async def init_db_async(model_modules: Sequence[str] | None = None) -> None:
18
17
  """Create tables using AsyncEngine (dev/test). Prefer Alembic in prod."""
@@ -21,9 +20,10 @@ async def init_db_async(model_modules: Sequence[str] | None = None) -> None:
21
20
  async with engine.begin() as conn:
22
21
  await conn.run_sync(Base.metadata.create_all)
23
22
 
23
+
24
24
  # --------- Sync ----------
25
25
  def init_db_sync(model_modules: Sequence[str] | None = None) -> None:
26
26
  """Create tables using Sync Engine (dev/test). Prefer Alembic in prod."""
27
27
  import_model_modules(model_modules)
28
28
  engine = init_sync_engine()
29
- Base.metadata.create_all(bind=engine)
29
+ Base.metadata.create_all(bind=engine)
@@ -2,10 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Optional
4
4
 
5
- from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
5
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
6
6
  from sqlalchemy.orm import Session, sessionmaker
7
- from nlbone.core.ports.uow import UnitOfWork
7
+
8
8
  from nlbone.core.ports.uow import AsyncUnitOfWork as AsyncUnitOfWorkPort
9
+ from nlbone.core.ports.uow import UnitOfWork
9
10
 
10
11
 
11
12
  class SqlAlchemyUnitOfWork(UnitOfWork):
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  import json
3
4
  from typing import Any, Optional
4
5
  from urllib.parse import urlparse, urlunparse
@@ -6,8 +7,8 @@ from urllib.parse import urlparse, urlunparse
6
7
  import httpx
7
8
  import requests
8
9
 
9
- from nlbone.core.ports.files import FileServicePort
10
10
  from nlbone.config.settings import get_settings
11
+ from nlbone.core.ports.files import FileServicePort
11
12
 
12
13
 
13
14
  class UploadchiError(RuntimeError):
@@ -28,8 +29,9 @@ def _auth_headers(token: str | None) -> dict[str, str]:
28
29
  return {"Authorization": f"Bearer {token}"} if token else {}
29
30
 
30
31
 
31
- def _build_list_query(limit: int, offset: int, filters: dict[str, Any] | None, sort: list[tuple[str, str]] | None) -> \
32
- dict[str, Any]:
32
+ def _build_list_query(
33
+ limit: int, offset: int, filters: dict[str, Any] | None, sort: list[tuple[str, str]] | None
34
+ ) -> dict[str, Any]:
33
35
  q: dict[str, Any] = {"limit": limit, "offset": offset}
34
36
  if filters:
35
37
  q["filters"] = json.dumps(filters)
@@ -55,8 +57,12 @@ def _normalize_https_base(url: str) -> str:
55
57
 
56
58
 
57
59
  class UploadchiClient(FileServicePort):
58
- def __init__(self, base_url: Optional[str] = None, timeout_seconds: Optional[float] = None,
59
- client: httpx.Client | None = None) -> None:
60
+ def __init__(
61
+ self,
62
+ base_url: Optional[str] = None,
63
+ timeout_seconds: Optional[float] = None,
64
+ client: httpx.Client | None = None,
65
+ ) -> None:
60
66
  s = get_settings()
61
67
  self._base_url = _normalize_https_base(base_url or str(s.UPLOADCHI_BASE_URL))
62
68
  self._timeout = timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS)
@@ -65,8 +71,9 @@ class UploadchiClient(FileServicePort):
65
71
  def close(self) -> None:
66
72
  self._client.close()
67
73
 
68
- def upload_file(self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None,
69
- token: str | None = None) -> dict:
74
+ def upload_file(
75
+ self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
76
+ ) -> dict:
70
77
  tok = _resolve_token(token)
71
78
  files = {"file": (filename, file_bytes)}
72
79
  data = (params or {}).copy()
@@ -77,13 +84,22 @@ class UploadchiClient(FileServicePort):
77
84
 
78
85
  def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None:
79
86
  tok = _resolve_token(token)
80
- r = self._client.post(f"{self._base_url}/{file_id}/commit", headers=_auth_headers(tok),
81
- params={"client_id": client_id} if client_id else None)
87
+ r = self._client.post(
88
+ f"{self._base_url}/{file_id}/commit",
89
+ headers=_auth_headers(tok),
90
+ params={"client_id": client_id} if client_id else None,
91
+ )
82
92
  if r.status_code not in (204, 200):
83
93
  raise UploadchiError(r.status_code, r.text)
84
94
 
85
- def list_files(self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None,
86
- sort: list[tuple[str, str]] | None = None, token: str | None = None) -> dict:
95
+ def list_files(
96
+ self,
97
+ limit: int = 10,
98
+ offset: int = 0,
99
+ filters: dict[str, Any] | None = None,
100
+ sort: list[tuple[str, str]] | None = None,
101
+ token: str | None = None,
102
+ ) -> dict:
87
103
  tok = _resolve_token(token)
88
104
  q = _build_list_query(limit, offset, filters, sort)
89
105
  r = self._client.get(self._base_url, params=q, headers=_auth_headers(tok))
@@ -1,22 +1,35 @@
1
1
  from __future__ import annotations
2
- from typing import Any, Optional, AsyncIterator
2
+
3
+ from typing import Any, AsyncIterator, Optional
4
+
3
5
  import httpx
4
6
 
5
- from nlbone.core.ports.files import AsyncFileServicePort
6
7
  from nlbone.config.settings import get_settings
8
+ from nlbone.core.ports.files import AsyncFileServicePort
9
+
7
10
  from .uploadchi import UploadchiError, _auth_headers, _build_list_query, _filename_from_cd, _resolve_token
8
11
 
12
+
9
13
  class UploadchiAsyncClient(AsyncFileServicePort):
10
- def __init__(self, base_url: Optional[str] = None, timeout_seconds: Optional[float] = None, client: httpx.AsyncClient | None = None) -> None:
14
+ def __init__(
15
+ self,
16
+ base_url: Optional[str] = None,
17
+ timeout_seconds: Optional[float] = None,
18
+ client: httpx.AsyncClient | None = None,
19
+ ) -> None:
11
20
  s = get_settings()
12
21
  self._base_url = base_url or str(s.UPLOADCHI_BASE_URL)
13
22
  self._timeout = timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS)
14
- self._client = client or httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout, follow_redirects=True)
23
+ self._client = client or httpx.AsyncClient(
24
+ base_url=self._base_url, timeout=self._timeout, follow_redirects=True
25
+ )
15
26
 
16
27
  async def aclose(self) -> None:
17
28
  await self._client.aclose()
18
29
 
19
- async def upload_file(self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None) -> dict:
30
+ async def upload_file(
31
+ self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
32
+ ) -> dict:
20
33
  tok = _resolve_token(token)
21
34
  files = {"file": (filename, file_bytes)}
22
35
  data = (params or {}).copy()
@@ -27,11 +40,20 @@ class UploadchiAsyncClient(AsyncFileServicePort):
27
40
 
28
41
  async def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None:
29
42
  tok = _resolve_token(token)
30
- r = await self._client.post(f"/{file_id}/commit", headers=_auth_headers(tok), params={"client_id": client_id} if client_id else None)
43
+ r = await self._client.post(
44
+ f"/{file_id}/commit", headers=_auth_headers(tok), params={"client_id": client_id} if client_id else None
45
+ )
31
46
  if r.status_code not in (204, 200):
32
47
  raise UploadchiError(r.status_code, await r.aread())
33
48
 
34
- async def list_files(self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None, sort: list[tuple[str, str]] | None = None, token: str | None = None) -> dict:
49
+ async def list_files(
50
+ self,
51
+ limit: int = 10,
52
+ offset: int = 0,
53
+ filters: dict[str, Any] | None = None,
54
+ sort: list[tuple[str, str]] | None = None,
55
+ token: str | None = None,
56
+ ) -> dict:
35
57
  tok = _resolve_token(token)
36
58
  q = _build_list_query(limit, offset, filters, sort)
37
59
  r = await self._client.get("", params=q, headers=_auth_headers(tok))
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
+
2
3
  from collections import defaultdict
3
4
  from typing import Callable, Dict, Iterable, List
5
+
4
6
  from nlbone.core.domain.base import DomainEvent
5
7
  from nlbone.core.ports.event_bus import EventBusPort
6
8
 
9
+
7
10
  class InMemoryEventBus(EventBusPort):
8
11
  def __init__(self) -> None:
9
12
  self._handlers: Dict[str, List[Callable[[DomainEvent], None]]] = defaultdict(list)
nlbone/config/logging.py CHANGED
@@ -1,19 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextvars
3
4
  import json
4
5
  import logging
5
6
  import sys
6
7
  from datetime import datetime, timezone
7
8
  from typing import Any, MutableMapping, Optional
8
9
 
9
- import contextvars
10
-
11
10
  from nlbone.config.settings import get_settings
12
11
 
13
12
  # Context variable for request/correlation id
14
- _request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
15
- "request_id", default=None
16
- )
13
+ _request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("request_id", default=None)
17
14
 
18
15
 
19
16
  def set_request_id(request_id: Optional[str]) -> None:
@@ -97,9 +94,7 @@ def _build_stream_handler(json_enabled: bool, level: int) -> logging.Handler:
97
94
  handler.setFormatter(JsonFormatter())
98
95
  else:
99
96
  # human-friendly text format
100
- fmt = (
101
- "%(asctime)s | %(levelname)s | %(name)s | rid=%(request_id)s | %(message)s"
102
- )
97
+ fmt = "%(asctime)s | %(levelname)s | %(name)s | rid=%(request_id)s | %(message)s"
103
98
  datefmt = "%Y-%m-%dT%H:%M:%S%z"
104
99
  handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
105
100
  return handler
nlbone/config/settings.py CHANGED
@@ -2,7 +2,8 @@ import os
2
2
  from functools import lru_cache
3
3
  from pathlib import Path
4
4
  from typing import Literal
5
- from pydantic import AnyHttpUrl, Field, SecretStr, AliasChoices
5
+
6
+ from pydantic import AnyHttpUrl, Field, SecretStr
6
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
7
8
 
8
9
 
@@ -34,9 +35,7 @@ class Settings(BaseSettings):
34
35
  # App
35
36
  # ---------------------------
36
37
  PORT: int = 8000
37
- ENV: Literal["local", "dev", "staging", "prod"] = Field(default="local",
38
- validation_alias=AliasChoices("NLBONE_ENV", "ENV",
39
- "ENVIRONMENT"))
38
+ ENV: Literal["local", "dev", "staging", "prod"] = Field(default="local")
40
39
  DEBUG: bool = Field(default=False)
41
40
  LOG_LEVEL: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = Field(default="INFO")
42
41
  LOG_JSON: bool = Field(default=True)
@@ -49,23 +48,15 @@ class Settings(BaseSettings):
49
48
  # ---------------------------
50
49
  # Keycloak / Auth
51
50
  # ---------------------------
52
- KEYCLOAK_SERVER_URL: AnyHttpUrl = Field(default="https://keycloak.local/auth",
53
- validation_alias=AliasChoices("NLBONE_KEYCLOAK_SERVER_URL",
54
- "KEYCLOAK_SERVER_URL"))
55
- KEYCLOAK_REALM_NAME: str = Field(default="numberland",
56
- validation_alias=AliasChoices("NLBONE_KEYCLOAK_REALM_NAME", "KEYCLOAK_REALM_NAME"))
57
- KEYCLOAK_CLIENT_ID: str = Field(default="nlbone",
58
- validation_alias=AliasChoices("NLBONE_KEYCLOAK_CLIENT_ID", "KEYCLOAK_CLIENT_ID"))
59
- KEYCLOAK_CLIENT_SECRET: SecretStr = Field(default=SecretStr("dev-secret"),
60
- validation_alias=AliasChoices("NLBONE_KEYCLOAK_CLIENT_SECRET",
61
- "KEYCLOAK_CLIENT_SECRET"))
51
+ KEYCLOAK_SERVER_URL: AnyHttpUrl = Field(default="https://keycloak.local/auth")
52
+ KEYCLOAK_REALM_NAME: str = Field(default="numberland")
53
+ KEYCLOAK_CLIENT_ID: str = Field(default="nlbone")
54
+ KEYCLOAK_CLIENT_SECRET: SecretStr = Field(default=SecretStr("dev-secret"))
62
55
 
63
56
  # ---------------------------
64
57
  # Database
65
58
  # ---------------------------
66
- POSTGRES_DB_DSN: str = Field(default="postgresql+asyncpg://user:pass@localhost:5432/nlbone",
67
- validation_alias=AliasChoices("NLBONE_POSTGRES_DB_DSN",
68
- "POSTGRES_DB_DSN", "DATABASE_URL", "DB_DSN"))
59
+ POSTGRES_DB_DSN: str = Field(default="postgresql+asyncpg://user:pass@localhost:5432/nlbone")
69
60
  DB_ECHO: bool = Field(default=False)
70
61
  DB_POOL_SIZE: int = Field(default=5)
71
62
  DB_MAX_OVERFLOW: int = Field(default=10)
@@ -74,6 +65,7 @@ class Settings(BaseSettings):
74
65
  # Messaging / Cache
75
66
  # ---------------------------
76
67
  REDIS_URL: str = Field(default="redis://localhost:6379/0")
68
+
77
69
  # --- Event bus / Outbox ---
78
70
  EVENT_BUS_BACKEND: Literal["inmemory"] = Field(default="inmemory")
79
71
  OUTBOX_ENABLED: bool = Field(default=False)
@@ -83,13 +75,10 @@ class Settings(BaseSettings):
83
75
  # UPLOADCHI
84
76
  # ---------------------------
85
77
  UPLOADCHI_BASE_URL: AnyHttpUrl = Field(default="https://uploadchi.numberland.ir/v1/files")
86
- UPLOADCHI_TOKEN: SecretStr | None = Field(
87
- default=None,
88
- validation_alias=AliasChoices("NLBONE_UPLOADCHI_TOKEN", "UPLOADCHI_TOKEN"),
89
- )
78
+ UPLOADCHI_TOKEN: SecretStr = Field(default="")
90
79
 
91
80
  model_config = SettingsConfigDict(
92
- env_prefix="NLBONE_",
81
+ env_prefix="",
93
82
  env_file=None,
94
83
  env_file_encoding="utf-8",
95
84
  extra="ignore",
nlbone/container.py CHANGED
@@ -1,16 +1,17 @@
1
1
  from __future__ import annotations
2
+
2
3
  from typing import Any, Mapping, Optional
3
4
 
4
5
  from dependency_injector import containers, providers
5
6
 
6
- from nlbone.adapters.db.sqlalchemy import SqlAlchemyUnitOfWork, AsyncUnitOfWork
7
- from nlbone.adapters.db.sqlalchemy.engine import get_sync_session_factory, get_async_session_factory
7
+ from nlbone.adapters.auth.keycloak import KeycloakAuthService
8
+ from nlbone.adapters.db.sqlalchemy import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
9
+ from nlbone.adapters.db.sqlalchemy.engine import get_async_session_factory, get_sync_session_factory
8
10
  from nlbone.adapters.http_clients.uploadchi import UploadchiClient
9
11
  from nlbone.adapters.http_clients.uploadchi_async import UploadchiAsyncClient
10
- from nlbone.adapters.auth.keycloak import KeycloakAuthService
11
12
  from nlbone.adapters.messaging import InMemoryEventBus
12
13
  from nlbone.core.ports import EventBusPort
13
- from nlbone.core.ports.files import FileServicePort, AsyncFileServicePort
14
+ from nlbone.core.ports.files import AsyncFileServicePort, FileServicePort
14
15
 
15
16
 
16
17
  class Container(containers.DeclarativeContainer):
@@ -21,7 +22,7 @@ class Container(containers.DeclarativeContainer):
21
22
 
22
23
  # --- UoW ---
23
24
  uow = providers.Factory(SqlAlchemyUnitOfWork, session_factory=sync_session_factory)
24
- async_uow = providers.Factory(AsyncUnitOfWork, session_factory=async_session_factory)
25
+ async_uow = providers.Factory(AsyncSqlAlchemyUnitOfWork, session_factory=async_session_factory)
25
26
 
26
27
  # --- Event bus ---
27
28
  event_bus: providers.Singleton[EventBusPort] = providers.Singleton(InMemoryEventBus)
@@ -1,7 +1,9 @@
1
1
  from typing import Iterable, Sequence
2
+
2
3
  from nlbone.core.domain.base import AggregateRoot, DomainEvent
3
4
  from nlbone.core.ports.event_bus import EventBusPort
4
5
 
6
+
5
7
  def collect_events(*aggregates: Iterable[AggregateRoot]) -> list[DomainEvent]:
6
8
  events: list[DomainEvent] = []
7
9
  for agg in aggregates:
@@ -12,5 +14,7 @@ def collect_events(*aggregates: Iterable[AggregateRoot]) -> list[DomainEvent]:
12
14
  events.extend(a.pull_events())
13
15
  return events
14
16
 
17
+
15
18
  def publish_events(bus: EventBusPort, events: Sequence[DomainEvent]) -> None:
16
- if events: bus.publish(events)
19
+ if events:
20
+ bus.publish(events)
@@ -1,10 +1,12 @@
1
1
  from typing import Protocol, TypeVar
2
2
 
3
+ InT = TypeVar("InT")
4
+ OutT = TypeVar("OutT")
3
5
 
4
- InT = TypeVar("InT"); OutT = TypeVar("OutT")
5
6
 
6
7
  class UseCase(Protocol):
7
8
  def __call__(self, command: InT) -> OutT: ...
8
9
 
10
+
9
11
  class AsyncUseCase(Protocol):
10
12
  async def __call__(self, command: InT) -> OutT: ...
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  from dataclasses import dataclass
3
4
  from datetime import datetime, timezone
4
5
  from typing import Any, Generic, List, TypeVar
@@ -13,6 +14,7 @@ class DomainError(Exception):
13
14
  @dataclass(frozen=True)
14
15
  class DomainEvent:
15
16
  """Immutable domain event."""
17
+
16
18
  occurred_at: datetime = datetime.now(timezone.utc)
17
19
 
18
20
  @property
@@ -22,6 +24,7 @@ class DomainEvent:
22
24
 
23
25
  class ValueObject:
24
26
  """Base for value objects (immutable in practice)."""
27
+
25
28
  def __eq__(self, other: Any) -> bool:
26
29
  return isinstance(other, self.__class__) and self.__dict__ == other.__dict__
27
30
 
@@ -35,6 +38,7 @@ class Entity(Generic[TId]):
35
38
 
36
39
  class AggregateRoot(Entity[TId]):
37
40
  """Aggregate root with domain event collection."""
41
+
38
42
  def __init__(self, *args, **kwargs) -> None:
39
43
  self._domain_events: List[DomainEvent] = []
40
44
 
@@ -1,5 +1,5 @@
1
1
  from .auth import AuthService
2
- from .repo import Repository, AsyncRepository
3
- from .files import FileServicePort, AsyncFileServicePort
4
- from .uow import UnitOfWork, AsyncUnitOfWork
5
2
  from .event_bus import EventBusPort
3
+ from .files import AsyncFileServicePort, FileServicePort
4
+ from .repo import AsyncRepository, Repository
5
+ from .uow import AsyncUnitOfWork, UnitOfWork
nlbone/core/ports/auth.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from typing import Protocol, runtime_checkable
2
2
 
3
+
3
4
  @runtime_checkable
4
5
  class AuthService(Protocol):
5
6
  def has_access(self, token: str, permissions: list[str]) -> bool: ...
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
+
2
3
  from typing import Callable, Iterable, Protocol
4
+
3
5
  from nlbone.core.domain.base import DomainEvent
4
6
 
7
+
5
8
  class EventBusPort(Protocol):
6
9
  def publish(self, events: Iterable[DomainEvent]) -> None: ...
7
10
  def subscribe(self, event_name: str, handler: Callable[[DomainEvent], None]) -> None: ...
@@ -1,20 +1,41 @@
1
1
  from __future__ import annotations
2
- from typing import Protocol, runtime_checkable, AsyncIterator, Any
2
+
3
+ from typing import Any, AsyncIterator, Protocol, runtime_checkable
4
+
3
5
 
4
6
  @runtime_checkable
5
7
  class FileServicePort(Protocol):
6
- def upload_file(self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None) -> dict: ...
8
+ def upload_file(
9
+ self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
10
+ ) -> dict: ...
7
11
  def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None: ...
8
- def list_files(self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None, sort: list[tuple[str, str]] | None = None, token: str | None = None) -> dict: ...
12
+ def list_files(
13
+ self,
14
+ limit: int = 10,
15
+ offset: int = 0,
16
+ filters: dict[str, Any] | None = None,
17
+ sort: list[tuple[str, str]] | None = None,
18
+ token: str | None = None,
19
+ ) -> dict: ...
9
20
  def get_file(self, file_id: int, token: str | None = None) -> dict: ...
10
21
  def download_file(self, file_id: int, token: str | None = None) -> tuple[bytes, str, str]: ...
11
22
  def delete_file(self, file_id: int, token: str | None = None) -> None: ...
12
23
 
24
+
13
25
  @runtime_checkable
14
26
  class AsyncFileServicePort(Protocol):
15
- async def upload_file(self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None) -> dict: ...
27
+ async def upload_file(
28
+ self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
29
+ ) -> dict: ...
16
30
  async def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None: ...
17
- async def list_files(self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None, sort: list[tuple[str, str]] | None = None, token: str | None = None) -> dict: ...
31
+ async def list_files(
32
+ self,
33
+ limit: int = 10,
34
+ offset: int = 0,
35
+ filters: dict[str, Any] | None = None,
36
+ sort: list[tuple[str, str]] | None = None,
37
+ token: str | None = None,
38
+ ) -> dict: ...
18
39
  async def get_file(self, file_id: int, token: str | None = None) -> dict: ...
19
40
  async def download_file(self, file_id: int, token: str | None = None) -> tuple[AsyncIterator[bytes], str, str]: ...
20
41
  async def delete_file(self, file_id: int, token: str | None = None) -> None: ...