nlbone 0.4.1__py3-none-any.whl → 0.4.3__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 (35) hide show
  1. nlbone/adapters/auth/keycloak.py +6 -2
  2. nlbone/adapters/auth/token_provider.py +40 -0
  3. nlbone/adapters/db/__init__.py +4 -3
  4. nlbone/adapters/db/postgres/audit.py +150 -0
  5. nlbone/adapters/db/{sqlalchemy → postgres}/schema.py +2 -2
  6. nlbone/adapters/db/redis/client.py +22 -0
  7. nlbone/adapters/http_clients/uploadchi.py +32 -17
  8. nlbone/adapters/http_clients/uploadchi_async.py +31 -16
  9. nlbone/adapters/percolation/__init__.py +1 -0
  10. nlbone/adapters/percolation/connection.py +12 -0
  11. nlbone/config/logging.py +78 -114
  12. nlbone/config/settings.py +9 -2
  13. nlbone/container.py +8 -4
  14. nlbone/core/application/base_worker.py +36 -0
  15. nlbone/core/domain/models.py +38 -0
  16. nlbone/core/ports/files.py +10 -8
  17. nlbone/interfaces/api/dependencies/db.py +1 -1
  18. nlbone/interfaces/api/dependencies/uow.py +1 -1
  19. nlbone/interfaces/cli/init_db.py +21 -13
  20. nlbone/interfaces/cli/main.py +29 -0
  21. nlbone/utils/redactor.py +32 -0
  22. nlbone/utils/time.py +40 -1
  23. {nlbone-0.4.1.dist-info → nlbone-0.4.3.dist-info}/METADATA +10 -2
  24. {nlbone-0.4.1.dist-info → nlbone-0.4.3.dist-info}/RECORD +34 -27
  25. nlbone-0.4.3.dist-info/entry_points.txt +2 -0
  26. nlbone/adapters/db/postgres.py +0 -0
  27. /nlbone/adapters/db/{sqlalchemy → postgres}/__init__.py +0 -0
  28. /nlbone/adapters/db/{sqlalchemy → postgres}/base.py +0 -0
  29. /nlbone/adapters/db/{sqlalchemy → postgres}/engine.py +0 -0
  30. /nlbone/adapters/db/{sqlalchemy → postgres}/query_builder.py +0 -0
  31. /nlbone/adapters/db/{sqlalchemy → postgres}/repository.py +0 -0
  32. /nlbone/adapters/db/{sqlalchemy → postgres}/uow.py +0 -0
  33. /nlbone/adapters/db/{memory.py → redis/__init__.py} +0 -0
  34. {nlbone-0.4.1.dist-info → nlbone-0.4.3.dist-info}/WHEEL +0 -0
  35. {nlbone-0.4.1.dist-info → nlbone-0.4.3.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,7 @@
1
1
  from keycloak import KeycloakOpenID
2
2
  from keycloak.exceptions import KeycloakAuthenticationError
3
3
 
4
- from nlbone.config.settings import Settings, get_settings
4
+ from nlbone.config.settings import Settings, get_settings, is_production_env
5
5
  from nlbone.core.ports.auth import AuthService
6
6
 
7
7
 
@@ -14,8 +14,12 @@ class KeycloakAuthService(AuthService):
14
14
  realm_name=s.KEYCLOAK_REALM_NAME,
15
15
  client_secret_key=s.KEYCLOAK_CLIENT_SECRET.get_secret_value().strip(),
16
16
  )
17
+ self.bypass = not is_production_env()
17
18
 
18
19
  def has_access(self, token, permissions):
20
+ if self.bypass:
21
+ return True
22
+
19
23
  try:
20
24
  result = self.keycloak_openid.has_uma_access(token, permissions=permissions)
21
25
  return result.is_authorized
@@ -71,4 +75,4 @@ class KeycloakAuthService(AuthService):
71
75
  def client_has_access(self, token: str, permissions: list[str], allowed_clients: set[str] | None = None) -> bool:
72
76
  if not self.is_client_token(token, allowed_clients):
73
77
  return False
74
- return self.has_access(token, permissions)
78
+ return self.has_access(token, permissions)
@@ -0,0 +1,40 @@
1
+ import threading
2
+ import time
3
+ from typing import Optional, Dict, Any
4
+
5
+ from nlbone.adapters.auth.keycloak import KeycloakAuthService
6
+
7
+
8
+ class ClientTokenProvider:
9
+ """Caches Keycloak client-credentials token and refreshes before expiry."""
10
+
11
+ def __init__(self, auth: KeycloakAuthService, *, skew_seconds: int = 30) -> None:
12
+ self._auth = auth
13
+ self._skew = skew_seconds
14
+ self._lock = threading.Lock()
15
+ self._token: Optional[str] = None # access_token
16
+ self._expires_at: float = 0.0 # epoch seconds
17
+
18
+ def _needs_refresh(self) -> bool:
19
+ return not self._token or time.time() >= (self._expires_at - self._skew)
20
+
21
+ def get_access_token(self) -> str:
22
+ """Return a valid access token; refresh if needed."""
23
+ if not self._needs_refresh():
24
+ return self._token
25
+
26
+ with self._lock:
27
+ if not self._needs_refresh():
28
+ return self._token
29
+
30
+ data: Dict[str, Any] = self._auth.get_client_token()
31
+ access_token = data.get("access_token")
32
+ if not access_token:
33
+ raise RuntimeError("Keycloak: missing access_token")
34
+ expires_in = int(data.get("expires_in", 60))
35
+ self._token = access_token
36
+ self._expires_at = time.time() + max(1, expires_in)
37
+ return self._token
38
+
39
+ def get_auth_header(self) -> str:
40
+ return f"Bearer {self.get_access_token()}"
@@ -1,3 +1,4 @@
1
- from .sqlalchemy import apply_pagination, get_paginated_response
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
+ from .postgres import apply_pagination, get_paginated_response
2
+ from .postgres.base import Base
3
+ from .postgres.engine import async_ping, async_session, init_async_engine, init_sync_engine, sync_ping, sync_session
4
+ import nlbone.adapters.db.postgres.audit
@@ -0,0 +1,150 @@
1
+ import uuid
2
+ from datetime import date, datetime
3
+ from typing import Any
4
+ from sqlalchemy import event, inspect as sa_inspect
5
+ from sqlalchemy.orm import Session as SASession
6
+ from enum import Enum as _Enum
7
+ from decimal import Decimal
8
+
9
+ from nlbone.core.domain.models import AuditLog
10
+ from nlbone.utils.context import current_context_dict
11
+
12
+ DEFAULT_EXCLUDE = {"updated_at", "created_at"}
13
+ DEFAULT_ENABLED = False
14
+ DEFAULT_OPS = {"INSERT", "UPDATE", "DELETE"}
15
+
16
+
17
+ def _get_ops_for(obj) -> set[str]:
18
+ ops = getattr(obj, "__audit_ops__", None)
19
+ if ops is None:
20
+ return set(DEFAULT_OPS)
21
+ return {str(op).upper() for op in ops}
22
+
23
+
24
+ def _is_audit_disabled(obj) -> bool:
25
+ if not DEFAULT_ENABLED:
26
+ return True
27
+ if getattr(obj, "__audit_disable__", False):
28
+ return True
29
+ if hasattr(obj, "__audit_enable__") and not getattr(obj, "__audit_enable__"):
30
+ return True
31
+ return False
32
+
33
+
34
+ def _is_op_enabled(obj, op: str) -> bool:
35
+ if _is_audit_disabled(obj):
36
+ return False
37
+ return op.upper() in _get_ops_for(obj)
38
+
39
+
40
+ def _ser(val):
41
+ if isinstance(val, (date, datetime)):
42
+ return val.isoformat()
43
+ # UUID
44
+ if isinstance(val, uuid.UUID):
45
+ return str(val)
46
+ # Enum
47
+ if isinstance(val, _Enum):
48
+ return val.value
49
+ if isinstance(val, Decimal):
50
+ return str(val)
51
+ if isinstance(val, set):
52
+ return list(val)
53
+ return val
54
+
55
+
56
+ def _entity_name(obj: Any) -> str:
57
+ return getattr(getattr(obj, "__table__", None), "name", None) or getattr(obj, "__tablename__",
58
+ None) or obj.__class__.__name__
59
+
60
+
61
+ def _entity_id(obj: Any) -> str:
62
+ insp = sa_inspect(obj)
63
+ if insp.identity and len(insp.identity) == 1:
64
+ return _ser(insp.identity[0])
65
+ for pk in insp.mapper.primary_key:
66
+ v = getattr(obj, pk.key)
67
+ if v is not None:
68
+ return _ser(v)
69
+ return _ser(getattr(obj, "id", "?"))
70
+
71
+
72
+ def _changes_for_update(obj: any) -> dict[str, dict[str, any]]:
73
+ changes = {}
74
+ insp = sa_inspect(obj)
75
+ exclude = set(getattr(obj, "__audit_exclude__", set())) | DEFAULT_EXCLUDE
76
+
77
+ for col in insp.mapper.column_attrs:
78
+ key = col.key
79
+ if key in exclude:
80
+ continue
81
+
82
+ try:
83
+ state = insp.attrs[key]
84
+ except KeyError:
85
+ continue
86
+
87
+ hist = state.history # History object
88
+ if hist.has_changes():
89
+ old = hist.deleted[0] if hist.deleted else None
90
+ new = hist.added[0] if hist.added else None
91
+ if old != new:
92
+ changes[key] = {"old": _ser(old), "new": _ser(new)}
93
+ return changes
94
+ @event.listens_for(SASession, "before_flush")
95
+ def before_flush(session: SASession, flush_context, instances):
96
+ entries = session.info.setdefault("_audit_entries", [])
97
+
98
+ # INSERT
99
+ for obj in session.new:
100
+ if isinstance(obj, AuditLog) or not _is_op_enabled(obj, "INSERT"):
101
+ continue
102
+ insp = sa_inspect(obj)
103
+ exclude = set(getattr(obj, "__audit_exclude__", set())) | DEFAULT_EXCLUDE
104
+ row = {}
105
+ for col_attr in insp.mapper.column_attrs:
106
+ key = col_attr.key
107
+ if key in exclude:
108
+ continue
109
+ row[key] = _ser(getattr(obj, key, None))
110
+ entries.append({
111
+ "obj": obj,
112
+ "op": "INSERT",
113
+ "changes": {k: {"old": None, "new": v} for k, v in row.items()}
114
+ })
115
+
116
+ # UPDATE
117
+ for obj in session.dirty:
118
+ if isinstance(obj, AuditLog) or not _is_op_enabled(obj, "UPDATE"):
119
+ continue
120
+ if session.is_modified(obj, include_collections=False):
121
+ ch = _changes_for_update(obj)
122
+ if ch:
123
+ entries.append({"obj": obj, "op": "UPDATE", "changes": ch})
124
+
125
+ # DELETE
126
+ for obj in session.deleted:
127
+ if isinstance(obj, AuditLog) or not _is_op_enabled(obj, "DELETE"):
128
+ continue
129
+ entries.append({"obj": obj, "op": "DELETE", "changes": None})
130
+
131
+
132
+ @event.listens_for(SASession, "after_flush_postexec")
133
+ def after_flush_postexec(session: SASession, flush_context):
134
+ entries = session.info.pop("_audit_entries", [])
135
+ if not entries:
136
+ return
137
+ ctx = current_context_dict()
138
+ for e in entries:
139
+ obj = e["obj"]
140
+ al = AuditLog(
141
+ entity=_entity_name(obj),
142
+ entity_id=str(_entity_id(obj)),
143
+ operation=e["op"],
144
+ changes=e.get("changes"),
145
+ actor_id=ctx.get("user_id"),
146
+ request_id=ctx.get("request_id"),
147
+ ip=ctx.get("ip"),
148
+ user_agent=ctx.get("user_agent"),
149
+ )
150
+ session.add(al)
@@ -1,8 +1,8 @@
1
1
  import importlib
2
2
  from typing import Sequence
3
3
 
4
- from nlbone.adapters.db.sqlalchemy.base import Base
5
- from nlbone.adapters.db.sqlalchemy.engine import init_async_engine, init_sync_engine
4
+ from nlbone.adapters.db.postgres.base import Base
5
+ from nlbone.adapters.db.postgres.engine import init_async_engine, init_sync_engine
6
6
 
7
7
  DEFAULT_MODEL_MODULES: Sequence[str] = ()
8
8
 
@@ -0,0 +1,22 @@
1
+ import redis
2
+
3
+ from nlbone.config.settings import get_settings
4
+
5
+
6
+ class RedisClient:
7
+ _client: redis.Redis | None = None
8
+
9
+ @classmethod
10
+ def get_client(cls) -> redis.Redis:
11
+ if cls._client is None:
12
+ cls._client = redis.from_url(
13
+ get_settings().REDIS_URL,
14
+ decode_responses=True
15
+ )
16
+ return cls._client
17
+
18
+ @classmethod
19
+ def close(cls):
20
+ if cls._client is not None:
21
+ cls._client.close()
22
+ cls._client = None
@@ -7,6 +7,7 @@ from urllib.parse import urlparse, urlunparse
7
7
  import httpx
8
8
  import requests
9
9
 
10
+ from nlbone.adapters.auth.token_provider import ClientTokenProvider
10
11
  from nlbone.config.settings import get_settings
11
12
  from nlbone.core.ports.files import FileServicePort
12
13
 
@@ -30,7 +31,7 @@ def _auth_headers(token: str | None) -> dict[str, str]:
30
31
 
31
32
 
32
33
  def _build_list_query(
33
- limit: int, offset: int, filters: dict[str, Any] | None, sort: list[tuple[str, str]] | None
34
+ limit: int, offset: int, filters: dict[str, Any] | None, sort: list[tuple[str, str]] | None
34
35
  ) -> dict[str, Any]:
35
36
  q: dict[str, Any] = {"limit": limit, "offset": offset}
36
37
  if filters:
@@ -58,21 +59,23 @@ def _normalize_https_base(url: str) -> str:
58
59
 
59
60
  class UploadchiClient(FileServicePort):
60
61
  def __init__(
61
- self,
62
- base_url: Optional[str] = None,
63
- timeout_seconds: Optional[float] = None,
64
- client: httpx.Client | None = None,
62
+ self,
63
+ token_provider: ClientTokenProvider | None = None,
64
+ base_url: Optional[str] = None,
65
+ timeout_seconds: Optional[float] = None,
66
+ client: httpx.Client | None = None,
65
67
  ) -> None:
66
68
  s = get_settings()
67
69
  self._base_url = _normalize_https_base(base_url or str(s.UPLOADCHI_BASE_URL))
68
70
  self._timeout = timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS)
69
71
  self._client = client or requests.session()
72
+ self._token_provider = token_provider
70
73
 
71
74
  def close(self) -> None:
72
75
  self._client.close()
73
76
 
74
77
  def upload_file(
75
- self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
78
+ self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
76
79
  ) -> dict:
77
80
  tok = _resolve_token(token)
78
81
  files = {"file": (filename, file_bytes)}
@@ -82,23 +85,35 @@ class UploadchiClient(FileServicePort):
82
85
  raise UploadchiError(r.status_code, r.text)
83
86
  return r.json()
84
87
 
85
- def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None:
88
+ def commit_file(self, file_id: str, token: str | None = None) -> None:
89
+ if not token and not self._token_provider:
90
+ raise UploadchiError(detail="token_provider is not provided", status=400)
86
91
  tok = _resolve_token(token)
87
92
  r = self._client.post(
88
93
  f"{self._base_url}/{file_id}/commit",
89
94
  headers=_auth_headers(tok),
90
- params={"client_id": client_id} if client_id else None,
95
+ )
96
+ if r.status_code not in (204, 200):
97
+ raise UploadchiError(r.status_code, r.text)
98
+
99
+ def rollback(self, file_id: str, token: str | None = None) -> None:
100
+ if not token and not self._token_provider:
101
+ raise UploadchiError(detail="token_provider is not provided", status=400)
102
+ tok = _resolve_token(token)
103
+ r = self._client.post(
104
+ f"{self._base_url}/{file_id}/rollback",
105
+ headers=_auth_headers(tok),
91
106
  )
92
107
  if r.status_code not in (204, 200):
93
108
  raise UploadchiError(r.status_code, r.text)
94
109
 
95
110
  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,
111
+ self,
112
+ limit: int = 10,
113
+ offset: int = 0,
114
+ filters: dict[str, Any] | None = None,
115
+ sort: list[tuple[str, str]] | None = None,
116
+ token: str | None = None,
102
117
  ) -> dict:
103
118
  tok = _resolve_token(token)
104
119
  q = _build_list_query(limit, offset, filters, sort)
@@ -107,14 +122,14 @@ class UploadchiClient(FileServicePort):
107
122
  raise UploadchiError(r.status_code, r.text)
108
123
  return r.json()
109
124
 
110
- def get_file(self, file_id: int, token: str | None = None) -> dict:
125
+ def get_file(self, file_id: str, token: str | None = None) -> dict:
111
126
  tok = _resolve_token(token)
112
127
  r = self._client.get(f"{self._base_url}/{file_id}", headers=_auth_headers(tok))
113
128
  if r.status_code >= 400:
114
129
  raise UploadchiError(r.status_code, r.text)
115
130
  return r.json()
116
131
 
117
- def download_file(self, file_id: int, token: str | None = None) -> tuple[bytes, str, str]:
132
+ def download_file(self, file_id: str, token: str | None = None) -> tuple[bytes, str, str]:
118
133
  tok = _resolve_token(token)
119
134
  r = self._client.get(f"{self._base_url}/{file_id}/download", headers=_auth_headers(tok))
120
135
  if r.status_code >= 400:
@@ -123,7 +138,7 @@ class UploadchiClient(FileServicePort):
123
138
  media_type = r.headers.get("content-type", "application/octet-stream")
124
139
  return r.content, filename, media_type
125
140
 
126
- def delete_file(self, file_id: int, token: str | None = None) -> None:
141
+ def delete_file(self, file_id: str, token: str | None = None) -> None:
127
142
  tok = _resolve_token(token)
128
143
  r = self._client.delete(f"{self._base_url}/{file_id}", headers=_auth_headers(tok))
129
144
  if r.status_code not in (204, 200):
@@ -8,14 +8,16 @@ from nlbone.config.settings import get_settings
8
8
  from nlbone.core.ports.files import AsyncFileServicePort
9
9
 
10
10
  from .uploadchi import UploadchiError, _auth_headers, _build_list_query, _filename_from_cd, _resolve_token
11
+ from ..auth.token_provider import ClientTokenProvider
11
12
 
12
13
 
13
14
  class UploadchiAsyncClient(AsyncFileServicePort):
14
15
  def __init__(
15
- self,
16
- base_url: Optional[str] = None,
17
- timeout_seconds: Optional[float] = None,
18
- client: httpx.AsyncClient | None = None,
16
+ self,
17
+ token_provider: ClientTokenProvider | None = None,
18
+ base_url: Optional[str] = None,
19
+ timeout_seconds: Optional[float] = None,
20
+ client: httpx.AsyncClient | None = None,
19
21
  ) -> None:
20
22
  s = get_settings()
21
23
  self._base_url = base_url or str(s.UPLOADCHI_BASE_URL)
@@ -23,12 +25,13 @@ class UploadchiAsyncClient(AsyncFileServicePort):
23
25
  self._client = client or httpx.AsyncClient(
24
26
  base_url=self._base_url, timeout=self._timeout, follow_redirects=True
25
27
  )
28
+ self._token_provider = token_provider
26
29
 
27
30
  async def aclose(self) -> None:
28
31
  await self._client.aclose()
29
32
 
30
33
  async def upload_file(
31
- self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
34
+ self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
32
35
  ) -> dict:
33
36
  tok = _resolve_token(token)
34
37
  files = {"file": (filename, file_bytes)}
@@ -38,21 +41,33 @@ class UploadchiAsyncClient(AsyncFileServicePort):
38
41
  raise UploadchiError(r.status_code, await r.aread())
39
42
  return r.json()
40
43
 
41
- async def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None:
44
+ async def commit_file(self, file_id: str, token: str | None = None) -> None:
45
+ if not token and not self._token_provider:
46
+ raise UploadchiError(detail="token_provider is not provided", status=400)
42
47
  tok = _resolve_token(token)
43
48
  r = await self._client.post(
44
- f"/{file_id}/commit", headers=_auth_headers(tok), params={"client_id": client_id} if client_id else None
49
+ f"/{file_id}/commit", headers=_auth_headers(tok)
50
+ )
51
+ if r.status_code not in (204, 200):
52
+ raise UploadchiError(r.status_code, await r.aread())
53
+
54
+ async def rollback(self, file_id: str, token: str | None = None) -> None:
55
+ if not token and not self._token_provider:
56
+ raise UploadchiError(detail="token_provider is not provided", status=400)
57
+ tok = _resolve_token(token)
58
+ r = await self._client.post(
59
+ f"/{file_id}/rollback", headers=_auth_headers(tok)
45
60
  )
46
61
  if r.status_code not in (204, 200):
47
62
  raise UploadchiError(r.status_code, await r.aread())
48
63
 
49
64
  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,
65
+ self,
66
+ limit: int = 10,
67
+ offset: int = 0,
68
+ filters: dict[str, Any] | None = None,
69
+ sort: list[tuple[str, str]] | None = None,
70
+ token: str | None = None,
56
71
  ) -> dict:
57
72
  tok = _resolve_token(token)
58
73
  q = _build_list_query(limit, offset, filters, sort)
@@ -61,14 +76,14 @@ class UploadchiAsyncClient(AsyncFileServicePort):
61
76
  raise UploadchiError(r.status_code, await r.aread())
62
77
  return r.json()
63
78
 
64
- async def get_file(self, file_id: int, token: str | None = None) -> dict:
79
+ async def get_file(self, file_id: str, token: str | None = None) -> dict:
65
80
  tok = _resolve_token(token)
66
81
  r = await self._client.get(f"/{file_id}", headers=_auth_headers(tok))
67
82
  if r.status_code >= 400:
68
83
  raise UploadchiError(r.status_code, await r.aread())
69
84
  return r.json()
70
85
 
71
- async def download_file(self, file_id: int, token: str | None = None) -> tuple[AsyncIterator[bytes], str, str]:
86
+ async def download_file(self, file_id: str, token: str | None = None) -> tuple[AsyncIterator[bytes], str, str]:
72
87
  tok = _resolve_token(token)
73
88
  r = await self._client.get(f"/{file_id}/download", headers=_auth_headers(tok), stream=True)
74
89
  if r.status_code >= 400:
@@ -86,7 +101,7 @@ class UploadchiAsyncClient(AsyncFileServicePort):
86
101
 
87
102
  return _aiter(), filename, media_type
88
103
 
89
- async def delete_file(self, file_id: int, token: str | None = None) -> None:
104
+ async def delete_file(self, file_id: str, token: str | None = None) -> None:
90
105
  tok = _resolve_token(token)
91
106
  r = await self._client.delete(f"/{file_id}", headers=_auth_headers(tok))
92
107
  if r.status_code not in (204, 200):
@@ -0,0 +1 @@
1
+ from .connection import get_es_client
@@ -0,0 +1,12 @@
1
+ from elasticsearch import Elasticsearch
2
+
3
+ from nlbone.config.settings import get_settings
4
+
5
+ setting = get_settings()
6
+
7
+ def get_es_client():
8
+ es = Elasticsearch(
9
+ setting.ELASTIC_PERCOLATE_URL,
10
+ basic_auth=(setting.ELASTIC_PERCOLATE_USER, setting.ELASTIC_PERCOLATE_PASS.get_secret_value().strip())
11
+ )
12
+ return es
nlbone/config/logging.py CHANGED
@@ -1,40 +1,33 @@
1
- from __future__ import annotations
2
-
3
- import contextvars
4
1
  import json
5
2
  import logging
6
3
  import sys
7
4
  from datetime import datetime, timezone
8
- from typing import Any, MutableMapping, Optional
5
+ from logging.config import dictConfig
6
+ from typing import Any, MutableMapping
9
7
 
10
8
  from nlbone.config.settings import get_settings
9
+ from nlbone.utils.context import current_context_dict
10
+ from nlbone.utils.redactor import PiiRedactor
11
11
 
12
- # Context variable for request/correlation id
13
- _request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("request_id", default=None)
14
-
15
-
16
- def set_request_id(request_id: Optional[str]) -> None:
17
- """
18
- Set request id in context (e.g., per incoming HTTP request).
19
- Example:
20
- from nlbone.config.logging import set_request_id
21
- set_request_id("abc-123")
22
- """
23
- _request_id_var.set(request_id)
24
-
25
-
26
- class RequestIdFilter(logging.Filter):
27
- """Injects request_id from contextvars into record."""
12
+ settings = get_settings()
28
13
 
14
+ # ---------- Filters ----------
15
+ class ContextFilter(logging.Filter):
29
16
  def filter(self, record: logging.LogRecord) -> bool:
30
- rid = _request_id_var.get()
31
- # attach as record attribute; formatters can use %(request_id)s
32
- record.request_id = rid or "-"
17
+ ctx = current_context_dict()
18
+ record.request_id = ctx.get("request_id")
19
+ record.user_id = ctx.get("user_id")
20
+ record.ip = ctx.get("ip")
21
+ record.user_agent = ctx.get("user_agent")
33
22
  return True
34
23
 
35
-
24
+ # ---------- Formatter ----------
36
25
  class JsonFormatter(logging.Formatter):
37
- """Minimal JSON formatter with ISO8601 timestamps."""
26
+ RESERVED = {
27
+ "args","asctime","created","exc_info","exc_text","filename","funcName","levelname","levelno",
28
+ "lineno","module","msecs","message","msg","name","pathname","process","processName",
29
+ "relativeCreated","stack_info","thread","threadName",
30
+ }
38
31
 
39
32
  def format(self, record: logging.LogRecord) -> str:
40
33
  payload: MutableMapping[str, Any] = {
@@ -42,99 +35,77 @@ class JsonFormatter(logging.Formatter):
42
35
  "level": record.levelname,
43
36
  "logger": record.name,
44
37
  "msg": record.getMessage(),
45
- "request_id": getattr(record, "request_id", "-"),
38
+ "request_id": getattr(record, "request_id", None),
39
+ "user_id": getattr(record, "user_id", None),
40
+ "ip": getattr(record, "ip", None),
41
+ "user_agent": getattr(record, "user_agent", None),
46
42
  }
47
43
 
48
- # Add extras (fields set via logger.bind-like approach: logger.info("x", extra={"k": "v"}))
49
- # Python's logging puts extras into record.__dict__ directly.
50
- for key, value in record.__dict__.items():
51
- if key in {
52
- "args",
53
- "asctime",
54
- "created",
55
- "exc_info",
56
- "exc_text",
57
- "filename",
58
- "funcName",
59
- "levelname",
60
- "levelno",
61
- "lineno",
62
- "module",
63
- "msecs",
64
- "message",
65
- "msg",
66
- "name",
67
- "pathname",
68
- "process",
69
- "processName",
70
- "relativeCreated",
71
- "stack_info",
72
- "thread",
73
- "threadName",
74
- }:
75
- continue
76
- # Avoid overriding our top-level keys
77
- if key in payload:
44
+ for k, v in record.__dict__.items():
45
+ if k in self.RESERVED or k in payload:
78
46
  continue
79
- payload[key] = value
47
+ payload[k] = v
80
48
 
81
- # Attach exception info if present
82
49
  if record.exc_info:
83
- payload["exc_type"] = record.exc_info[0].__name__ if record.exc_info[0] else None
50
+ etype = record.exc_info[0].__name__ if record.exc_info[0] else None
51
+ payload["exc_type"] = etype
84
52
  payload["exc"] = self.formatException(record.exc_info)
85
53
 
86
54
  return json.dumps(payload, ensure_ascii=False)
87
55
 
56
+ class PlainFormatter(logging.Formatter):
57
+ def __init__(self):
58
+ super().__init__(
59
+ fmt="%(asctime)s | %(levelname)s | %(name)s | "
60
+ "req=%(request_id)s user=%(user_id)s ip=%(ip)s | %(message)s",
61
+ datefmt="%Y-%m-%dT%H:%M:%S%z",
62
+ )
63
+
64
+ # ---------- Setup ----------
65
+ def setup_logging(*, log_json: bool=settings.LOG_JSON, log_level: str =settings.LOG_LEVEL,
66
+ log_file: str | None = None, silence_uvicorn_access: bool = True):
67
+ handlers = {
68
+ "console": {
69
+ "class": "logging.StreamHandler",
70
+ "stream": sys.stdout,
71
+ "filters": ["ctx", "pii"],
72
+ "formatter": "json" if log_json else "plain",
73
+ }
74
+ }
75
+ if log_file:
76
+ handlers["file"] = {
77
+ "class": "logging.handlers.RotatingFileHandler",
78
+ "filename": log_file,
79
+ "maxBytes": 10 * 1024 * 1024,
80
+ "backupCount": 5,
81
+ "filters": ["ctx", "pii"],
82
+ "formatter": "json" if log_json else "plain",
83
+ }
88
84
 
89
- def _build_stream_handler(json_enabled: bool, level: int) -> logging.Handler:
90
- handler = logging.StreamHandler(stream=sys.stdout)
91
- handler.setLevel(level)
92
- handler.addFilter(RequestIdFilter())
93
- if json_enabled:
94
- handler.setFormatter(JsonFormatter())
95
- else:
96
- # human-friendly text format
97
- fmt = "%(asctime)s | %(levelname)s | %(name)s | rid=%(request_id)s | %(message)s"
98
- datefmt = "%Y-%m-%dT%H:%M:%S%z"
99
- handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
100
- return handler
101
-
102
-
103
- def setup_logging(
104
- level: Optional[int] = None,
105
- json_enabled: Optional[bool] = None,
106
- silence_uvicorn_access: bool = True,
107
- ) -> None:
108
- """
109
- Configure root logging once at app start.
110
- Idempotent: safe to call multiple times.
111
-
112
- Example:
113
- from nlbone.config.logging import setup_logging
114
- setup_logging()
115
- """
116
- settings = get_settings()
117
- lvl = level if level is not None else getattr(logging, settings.LOG_LEVEL, logging.INFO)
118
- json_logs = settings.LOG_JSON if json_enabled is None else json_enabled
119
-
120
- # Clear existing handlers
121
- root = logging.getLogger()
122
- for h in list(root.handlers):
123
- root.removeHandler(h)
124
-
125
- root.setLevel(lvl)
126
- root.addHandler(_build_stream_handler(json_logs, lvl))
127
-
128
- # Common noisy loggers (optional tweaks)
129
- for noisy in ("asyncio", "httpx"):
130
- logging.getLogger(noisy).setLevel(logging.WARNING)
85
+ dictConfig({
86
+ "version": 1,
87
+ "disable_existing_loggers": False,
88
+ "filters": {
89
+ "ctx": {"()": ContextFilter},
90
+ "pii": {"()": PiiRedactor},
91
+ },
92
+ "formatters": {
93
+ "json": {"()": JsonFormatter},
94
+ "plain": {"()": PlainFormatter},
95
+ },
96
+ "handlers": handlers,
97
+ "root": {
98
+ "level": log_level,
99
+ "handlers": list(handlers.keys()),
100
+ },
101
+ })
102
+
103
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
104
+ logging.getLogger("httpx").setLevel(logging.WARNING)
131
105
 
132
- # Uvicorn / FastAPI compatibility (if used later)
133
- # - application logs go through root
134
- # - format access logs separately or silence them
135
106
  uvicorn_error = logging.getLogger("uvicorn.error")
136
107
  uvicorn_error.handlers = []
137
- uvicorn_error.propagate = True # bubble up to root
108
+ uvicorn_error.propagate = True
138
109
 
139
110
  uvicorn_access = logging.getLogger("uvicorn.access")
140
111
  if silence_uvicorn_access:
@@ -144,12 +115,5 @@ def setup_logging(
144
115
  uvicorn_access.handlers = []
145
116
  uvicorn_access.propagate = True
146
117
 
147
-
148
- def get_logger(name: str) -> logging.Logger:
149
- """
150
- Helper to get a configured logger.
151
- Example:
152
- logger = get_logger(__name__)
153
- logger.info("hello", extra={"user_id": "42"})
154
- """
155
- return logging.getLogger(name)
118
+ def get_logger(name: str | None = None) -> logging.Logger:
119
+ return logging.getLogger(name or "app")
nlbone/config/settings.py CHANGED
@@ -23,7 +23,7 @@ def _guess_env_file() -> str | None:
23
23
  return str(f)
24
24
 
25
25
 
26
- def _is_production_env() -> bool:
26
+ def is_production_env() -> bool:
27
27
  raw = os.getenv("NLBONE_ENV") or os.getenv("ENV") or os.getenv("ENVIRONMENT")
28
28
  if not raw:
29
29
  return False
@@ -77,6 +77,13 @@ class Settings(BaseSettings):
77
77
  UPLOADCHI_BASE_URL: AnyHttpUrl = Field(default="https://uploadchi.numberland.ir/v1/files")
78
78
  UPLOADCHI_TOKEN: SecretStr = Field(default="")
79
79
 
80
+ # ---------------------------
81
+ # PERCOLATE
82
+ # ---------------------------
83
+ ELASTIC_PERCOLATE_URL: str = Field(default="http://localhost:9200")
84
+ ELASTIC_PERCOLATE_USER: str = Field(default="")
85
+ ELASTIC_PERCOLATE_PASS: SecretStr = Field(default="")
86
+
80
87
  model_config = SettingsConfigDict(
81
88
  env_prefix="",
82
89
  env_file=None,
@@ -86,7 +93,7 @@ class Settings(BaseSettings):
86
93
 
87
94
  @classmethod
88
95
  def load(cls, env_file: str | None = None) -> "Settings":
89
- if _is_production_env():
96
+ if is_production_env():
90
97
  return cls()
91
98
  return cls(_env_file=env_file or _guess_env_file())
92
99
 
nlbone/container.py CHANGED
@@ -5,8 +5,9 @@ from typing import Any, Mapping, Optional
5
5
  from dependency_injector import containers, providers
6
6
 
7
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
+ from nlbone.adapters.auth.token_provider import ClientTokenProvider
9
+ from nlbone.adapters.db.postgres import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
10
+ from nlbone.adapters.db.postgres.engine import get_async_session_factory, get_sync_session_factory
10
11
  from nlbone.adapters.http_clients.uploadchi import UploadchiClient
11
12
  from nlbone.adapters.http_clients.uploadchi_async import UploadchiAsyncClient
12
13
  from nlbone.adapters.messaging import InMemoryEventBus
@@ -29,8 +30,11 @@ class Container(containers.DeclarativeContainer):
29
30
 
30
31
  # --- Services ---
31
32
  auth: providers.Singleton[KeycloakAuthService] = providers.Singleton(KeycloakAuthService, settings=config)
32
- file_service: providers.Singleton[FileServicePort] = providers.Singleton(UploadchiClient)
33
- afiles_service: providers.Singleton[AsyncFileServicePort] = providers.Singleton(UploadchiAsyncClient)
33
+ token_provider = providers.Singleton(ClientTokenProvider, auth=auth, skew_seconds=30)
34
+ file_service: providers.Singleton[FileServicePort] = providers.Singleton(UploadchiClient,
35
+ token_provider=token_provider)
36
+ afiles_service: providers.Singleton[AsyncFileServicePort] = providers.Singleton(UploadchiAsyncClient,
37
+ token_provider=token_provider)
34
38
 
35
39
 
36
40
  def create_container(settings: Optional[Any] = None) -> Container:
@@ -0,0 +1,36 @@
1
+ import asyncio
2
+ from abc import ABC, abstractmethod
3
+ from typing import Any
4
+
5
+ from nlbone.utils.time import TimeUtility
6
+
7
+
8
+ class BaseWorker(ABC):
9
+ def __init__(self, name, interval):
10
+ self.name = name
11
+ self.interval = interval
12
+
13
+ async def run(self, *args, **kwargs):
14
+ while True:
15
+ try:
16
+ print(f"[>>] {self.name} is running. Current time: {TimeUtility.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
17
+
18
+ await self.process(*args, **kwargs)
19
+
20
+ print(
21
+ f"[>>] {self.name} is sleeping. Current time: {TimeUtility.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
22
+ )
23
+ await asyncio.sleep(self.interval)
24
+
25
+ except asyncio.CancelledError:
26
+ print(f"[!!] {self.name} task was cancelled. Shutting down gracefully.\n")
27
+ break
28
+
29
+ except Exception as e:
30
+ print(f"[!!]An error occurred in {self.name}:\n{str(e)}\n")
31
+ print(f"[!!] Retrying in {self.interval / 2} seconds...\n")
32
+ await asyncio.sleep(self.interval / 2)
33
+
34
+ @abstractmethod
35
+ async def process(self, *args, **kwargs) -> Any:
36
+ pass
@@ -0,0 +1,38 @@
1
+ import uuid
2
+ from datetime import datetime
3
+ from sqlalchemy import String, DateTime, Index, Text
4
+ from sqlalchemy import JSON as SA_JSON
5
+ from sqlalchemy.orm import Mapped, mapped_column
6
+ from sqlalchemy.sql import func
7
+
8
+ from nlbone.adapters.db import Base
9
+
10
+ try:
11
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
12
+ JSONType = JSONB
13
+ UUIDType = UUID(as_uuid=True)
14
+ except Exception:
15
+ JSONType = SA_JSON
16
+ UUIDType = String(36)
17
+
18
+
19
+ class AuditLog(Base):
20
+ __tablename__ = "audit_logs"
21
+
22
+ id: Mapped[uuid.UUID] = mapped_column(UUIDType, primary_key=True, default=uuid.uuid4)
23
+ entity: Mapped[str] = mapped_column(String(100), nullable=False)
24
+ entity_id: Mapped[str] = mapped_column(String(64), nullable=False)
25
+ operation: Mapped[str] = mapped_column(String(10), nullable=False) # INSERT/UPDATE/DELETE
26
+ changes: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
27
+
28
+ actor_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
29
+ request_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
30
+ ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
31
+ user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
32
+
33
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
34
+
35
+ __table_args__ = (
36
+ Index("ix_audit_entity_entityid", "entity", "entity_id"),
37
+ Index("ix_audit_created_at", "created_at"),
38
+ )
@@ -8,7 +8,8 @@ class FileServicePort(Protocol):
8
8
  def upload_file(
9
9
  self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
10
10
  ) -> dict: ...
11
- def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None: ...
11
+ def commit_file(self, file_id: str, token: str | None = None) -> None: ...
12
+ def rollback(self, file_id: str, token: str | None = None) -> None: ...
12
13
  def list_files(
13
14
  self,
14
15
  limit: int = 10,
@@ -17,9 +18,9 @@ class FileServicePort(Protocol):
17
18
  sort: list[tuple[str, str]] | None = None,
18
19
  token: str | None = None,
19
20
  ) -> dict: ...
20
- def get_file(self, file_id: int, token: str | None = None) -> dict: ...
21
- def download_file(self, file_id: int, token: str | None = None) -> tuple[bytes, str, str]: ...
22
- def delete_file(self, file_id: int, token: str | None = None) -> None: ...
21
+ def get_file(self, file_id: str, token: str | None = None) -> dict: ...
22
+ def download_file(self, file_id: str, token: str | None = None) -> tuple[bytes, str, str]: ...
23
+ def delete_file(self, file_id: str, token: str | None = None) -> None: ...
23
24
 
24
25
 
25
26
  @runtime_checkable
@@ -27,7 +28,8 @@ class AsyncFileServicePort(Protocol):
27
28
  async def upload_file(
28
29
  self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
29
30
  ) -> dict: ...
30
- async def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None: ...
31
+ async def commit_file(self, file_id: str, token: str | None = None) -> None: ...
32
+ async def rollback(self, file_id: str, token: str | None = None) -> None: ...
31
33
  async def list_files(
32
34
  self,
33
35
  limit: int = 10,
@@ -36,6 +38,6 @@ class AsyncFileServicePort(Protocol):
36
38
  sort: list[tuple[str, str]] | None = None,
37
39
  token: str | None = None,
38
40
  ) -> dict: ...
39
- async def get_file(self, file_id: int, token: str | None = None) -> dict: ...
40
- async def download_file(self, file_id: int, token: str | None = None) -> tuple[AsyncIterator[bytes], str, str]: ...
41
- async def delete_file(self, file_id: int, token: str | None = None) -> None: ...
41
+ async def get_file(self, file_id: str, token: str | None = None) -> dict: ...
42
+ async def download_file(self, file_id: str, token: str | None = None) -> tuple[AsyncIterator[bytes], str, str]: ...
43
+ async def delete_file(self, file_id: str, token: str | None = None) -> None: ...
@@ -5,7 +5,7 @@ from typing import AsyncGenerator, Generator
5
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
6
  from sqlalchemy.orm import Session
7
7
 
8
- from nlbone.adapters.db.sqlalchemy.engine import async_session, sync_session
8
+ from nlbone.adapters.db.postgres.engine import async_session, sync_session
9
9
 
10
10
 
11
11
  async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
@@ -5,7 +5,7 @@ from typing import AsyncIterator
5
5
 
6
6
  from fastapi import Request
7
7
 
8
- from nlbone.adapters.db.sqlalchemy import AsyncSqlAlchemyUnitOfWork
8
+ from nlbone.adapters.db.postgres import AsyncSqlAlchemyUnitOfWork
9
9
  from nlbone.core.ports.uow import AsyncUnitOfWork, UnitOfWork
10
10
 
11
11
 
@@ -1,20 +1,28 @@
1
- import argparse
1
+ import typer
2
2
 
3
- import anyio
3
+ from nlbone.adapters.db import init_sync_engine, Base, sync_ping
4
4
 
5
- from nlbone.adapters.db.sqlalchemy.schema import init_db_async, init_db_sync
5
+ init_db_command = typer.Typer(help="Database utilities")
6
6
 
7
7
 
8
- def main() -> None:
9
- parser = argparse.ArgumentParser(description="Initialize database schema (create_all).")
10
- parser.add_argument("--async", dest="use_async", action="store_true", help="Use AsyncEngine")
11
- args = parser.parse_args()
8
+ @init_db_command.command("init")
9
+ def init_db(drop: bool = typer.Option(False, "--drop", help="Drop all tables before create")):
10
+ """Create (and optionally drop) DB schema."""
11
+ engine = init_sync_engine()
12
+ if drop:
13
+ Base.metadata.drop_all(bind=engine)
14
+ Base.metadata.create_all(bind=engine)
15
+ typer.echo("✅ DB schema initialized.")
12
16
 
13
- if args.use_async:
14
- anyio.run(init_db_async)
15
- else:
16
- init_db_sync()
17
17
 
18
+ @init_db_command.command("ping")
19
+ def ping():
20
+ """Health check."""
21
+ sync_ping()
22
+ typer.echo("✅ DB connection OK")
18
23
 
19
- if __name__ == "__main__":
20
- main()
24
+
25
+ @init_db_command.command("migrate")
26
+ def migrate():
27
+ """Placeholder for migration trigger (Alembic, etc.)."""
28
+ typer.echo("ℹ️ Hook your migration tool here.")
@@ -0,0 +1,29 @@
1
+ import typer
2
+ from typing import Optional
3
+
4
+ from nlbone.adapters.db import init_sync_engine
5
+ from nlbone.config.settings import get_settings
6
+ from nlbone.interfaces.cli.init_db import init_db_command
7
+
8
+ app = typer.Typer(help="NLBone CLI")
9
+
10
+ app.add_typer(init_db_command, name="db")
11
+
12
+ @app.callback()
13
+ def common(
14
+ env_file: Optional[str] = typer.Option(
15
+ None, "--env-file", "-e",
16
+ help="Path to .env file. In prod omit this."
17
+ ),
18
+ debug: bool = typer.Option(False, "--debug", help="Enable debug logging"),
19
+ ):
20
+ settings = get_settings(env_file=env_file)
21
+ if debug:
22
+ pass
23
+ init_sync_engine(echo=settings.DEBUG)
24
+
25
+ def main():
26
+ app()
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,32 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ from typing import Any
5
+
6
+ SENSITIVE_KEYS = {"password", "token", "access_token", "refresh_token", "secret", "card_number", "cvv", "pan"}
7
+
8
+ class PiiRedactor(logging.Filter):
9
+ def _redact_in_obj(self, obj: Any):
10
+ if isinstance(obj, dict):
11
+ return {k: ("***" if k.lower() in SENSITIVE_KEYS else self._redact_in_obj(v)) for k, v in obj.items()}
12
+ if isinstance(obj, (list, tuple)):
13
+ return [self._redact_in_obj(v) for v in obj]
14
+ if isinstance(obj, str):
15
+ obj = re.sub(r"\b(\d{6})\d{6}(\d{4})\b", r"\1******\2", obj)
16
+ return obj
17
+
18
+ def filter(self, record: logging.LogRecord) -> bool:
19
+ try:
20
+ if isinstance(record.args, dict):
21
+ record.args = self._redact_in_obj(record.args)
22
+ if isinstance(record.msg, dict):
23
+ record.msg = self._redact_in_obj(record.msg)
24
+ elif isinstance(record.msg, str):
25
+ try:
26
+ data = json.loads(record.msg)
27
+ record.msg = json.dumps(self._redact_in_obj(data), ensure_ascii=False)
28
+ except Exception:
29
+ record.msg = self._redact_in_obj(record.msg)
30
+ except Exception:
31
+ pass
32
+ return True
nlbone/utils/time.py CHANGED
@@ -1,5 +1,44 @@
1
- from datetime import datetime, timezone
1
+ from datetime import datetime, timedelta, timezone
2
+
3
+ from dateutil import parser
2
4
 
3
5
 
4
6
  def now() -> datetime:
5
7
  return datetime.now(timezone.utc)
8
+
9
+
10
+ class TimeUtility:
11
+ @classmethod
12
+ def now(cls) -> datetime:
13
+ return datetime.now(timezone.utc)
14
+
15
+ @classmethod
16
+ def minutes_left_from_now(cls, ts: str | datetime) -> int:
17
+ dt = parser.parse(ts) if isinstance(ts, str) else ts
18
+ if dt.tzinfo is None:
19
+ dt = dt.replace(tzinfo=timezone.utc)
20
+ now = datetime.now(timezone.utc)
21
+ delta_sec = (dt - now).total_seconds()
22
+ return int(delta_sec // 60)
23
+
24
+ @classmethod
25
+ def get_datetime(cls, ts: str | datetime) -> datetime:
26
+ dt = parser.parse(ts) if isinstance(ts, str) else ts
27
+ if dt.tzinfo is None:
28
+ dt = dt.replace(tzinfo=timezone.utc)
29
+ return dt
30
+
31
+ @classmethod
32
+ def get_past_datetime(
33
+ cls, days=0, seconds=0, microseconds=0, milliseconds=0, minutes=0, hours=0, weeks=0
34
+ ) -> datetime:
35
+ delta = timedelta(
36
+ days=days,
37
+ seconds=seconds,
38
+ microseconds=microseconds,
39
+ milliseconds=milliseconds,
40
+ minutes=minutes,
41
+ hours=hours,
42
+ weeks=weeks,
43
+ )
44
+ return cls.now() - delta
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nlbone
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Backbone package for interfaces and infrastructure in Python projects
5
5
  Author-email: Amir Hosein Kahkbazzadeh <a.khakbazzadeh@gmail.com>
6
6
  License: MIT
@@ -8,14 +8,18 @@ License-File: LICENSE
8
8
  Requires-Python: >=3.10
9
9
  Requires-Dist: anyio>=4.0
10
10
  Requires-Dist: dependency-injector>=4.48.1
11
+ Requires-Dist: elasticsearch==8.14.0
11
12
  Requires-Dist: fastapi>=0.116
12
13
  Requires-Dist: httpx>=0.27
13
14
  Requires-Dist: psycopg>=3.2.9
14
15
  Requires-Dist: pydantic-settings>=2.0
15
16
  Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: python-dateutil~=2.9.0.post0
16
18
  Requires-Dist: python-keycloak==5.8.1
19
+ Requires-Dist: redis~=6.4.0
17
20
  Requires-Dist: sqlalchemy>=2.0
18
21
  Requires-Dist: starlette>=0.47
22
+ Requires-Dist: typer>=0.17.4
19
23
  Requires-Dist: uvicorn>=0.35
20
24
  Description-Content-Type: text/markdown
21
25
 
@@ -75,4 +79,8 @@ async def main():
75
79
 
76
80
 
77
81
  anyio.run(main)
78
- ```
82
+ ```
83
+
84
+ ## 📦 Used In
85
+ - **Explore**
86
+ - **Pricing**
@@ -1,31 +1,36 @@
1
1
  nlbone/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- nlbone/container.py,sha256=TrjMmUNXfwW4R4dRftK1GOpYu3wqfhixtnwuZkxVWvI,2104
2
+ nlbone/container.py,sha256=wmamsFU0Be6DlEqhLW30J2w4zVCwNlcZDd7HxkzWil0,2481
3
3
  nlbone/types.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  nlbone/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  nlbone/adapters/auth/__init__.py,sha256=hkDHvsFhw_UiOHG9ZSMqjiAhK4wumEforitveSZswVw,42
6
- nlbone/adapters/auth/keycloak.py,sha256=adZ9r-IPybSzWEQCRlPcxeDjPf8rKVf1xq3zgpMTqrY,2616
7
- nlbone/adapters/db/__init__.py,sha256=A8O5Vk2o71HumV7pQBXOszWSzOfLVn5FqbHOAUCWugk,218
8
- nlbone/adapters/db/memory.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- nlbone/adapters/db/postgres.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- nlbone/adapters/db/sqlalchemy/__init__.py,sha256=6JYJH0xZs3aR-zuyMpRhsdzFugmqz8nprwTQLprqhZc,313
11
- nlbone/adapters/db/sqlalchemy/base.py,sha256=kha9xmklzhuQAK8QEkNBn-mAHq8dUKbOM-3abaBpWmQ,71
12
- nlbone/adapters/db/sqlalchemy/engine.py,sha256=UCegauVB1gvo42ThytYnn5VIcQBwR-5xhcXYFApRFNk,3448
13
- nlbone/adapters/db/sqlalchemy/query_builder.py,sha256=U5pqpCfJKuMIxIEHyodoHuPgE8jf53slC1ScKZR5xa4,8653
14
- nlbone/adapters/db/sqlalchemy/repository.py,sha256=J_DBE73JhHPYCk90c5-O7lQtZbxDgqjjN9OcWy4Omvs,1660
15
- nlbone/adapters/db/sqlalchemy/schema.py,sha256=7ELexoAnbSjEJ3JYDNRSKOuraS51DupZ6H8QIkuGcsM,1043
16
- nlbone/adapters/db/sqlalchemy/uow.py,sha256=nRxNpY-WoWHpym-XeZ8VHm0MYvtB9wuopOeNdV_ebk8,2088
6
+ nlbone/adapters/auth/keycloak.py,sha256=dfAxODiARfR8y3FKoWNo9fjfb6QyWd_Qr7AbJ0E78AM,2729
7
+ nlbone/adapters/auth/token_provider.py,sha256=NhqjqTUsoZO4gbK-cybs0OkKydFN7CPTxAiypEw081o,1433
8
+ nlbone/adapters/db/__init__.py,sha256=saW-wN4E0NZ2_ldi-nrm5AgsH7EULNSa62lYMwfy1oo,252
9
+ nlbone/adapters/db/postgres/__init__.py,sha256=6JYJH0xZs3aR-zuyMpRhsdzFugmqz8nprwTQLprqhZc,313
10
+ nlbone/adapters/db/postgres/audit.py,sha256=zFzL-pXmfjcp5YLx6vBYczprsJjEPxSYKhQNR3WjKL0,4675
11
+ nlbone/adapters/db/postgres/base.py,sha256=kha9xmklzhuQAK8QEkNBn-mAHq8dUKbOM-3abaBpWmQ,71
12
+ nlbone/adapters/db/postgres/engine.py,sha256=UCegauVB1gvo42ThytYnn5VIcQBwR-5xhcXYFApRFNk,3448
13
+ nlbone/adapters/db/postgres/query_builder.py,sha256=U5pqpCfJKuMIxIEHyodoHuPgE8jf53slC1ScKZR5xa4,8653
14
+ nlbone/adapters/db/postgres/repository.py,sha256=J_DBE73JhHPYCk90c5-O7lQtZbxDgqjjN9OcWy4Omvs,1660
15
+ nlbone/adapters/db/postgres/schema.py,sha256=NlE7Rr8uXypsw4oWkdZhZwcIBHQEPIpoHLxcUo98i6s,1039
16
+ nlbone/adapters/db/postgres/uow.py,sha256=nRxNpY-WoWHpym-XeZ8VHm0MYvtB9wuopOeNdV_ebk8,2088
17
+ nlbone/adapters/db/redis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ nlbone/adapters/db/redis/client.py,sha256=XAKcmU0lpPvWPMS0fChVQ3iSJfHV1g4bMOCgJaj2bCI,512
17
19
  nlbone/adapters/http_clients/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
20
  nlbone/adapters/http_clients/email_gateway.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
- nlbone/adapters/http_clients/uploadchi.py,sha256=BGdGC-p8EmXYn29da577Kas2CzZVDAb9XJ9xx2LJioY,4713
20
- nlbone/adapters/http_clients/uploadchi_async.py,sha256=hBx0jzYYZAX1DCkImZ98zdUob8D9PQH7jykDXIjwG9I,3866
21
+ nlbone/adapters/http_clients/uploadchi.py,sha256=iMLbXUXqi60gQMEI7wfaqWG0G7p9fQN7otT9c8npnLQ,5470
22
+ nlbone/adapters/http_clients/uploadchi_async.py,sha256=wmb2XIUNkowh48GJwttNZ_STgsFQdGKKRZc_luYNCu8,4609
21
23
  nlbone/adapters/messaging/__init__.py,sha256=UDAwu3s-JQmOZjWz2Nu0SgHhnkbeOhKDH_zLD75oWMY,40
22
24
  nlbone/adapters/messaging/event_bus.py,sha256=w-NPwDiPMLFPU_enRQCtfQXOALsXfg31u57R8sG_-1U,781
23
25
  nlbone/adapters/messaging/redis.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
+ nlbone/adapters/percolation/__init__.py,sha256=viq5WZqcSLlRBF5JwuyTD_IZaNWfpKzGJIECgKOWgOw,37
27
+ nlbone/adapters/percolation/connection.py,sha256=xZ-OtQVbyQYH83TUizS0UWI85Iic-AhUjiuyzO0e46s,331
24
28
  nlbone/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
- nlbone/config/logging.py,sha256=T7Wm61Bu6QqhmnGKdWRQBqnVYKOwaFguiNs2CbJwYfE,5010
26
- nlbone/config/settings.py,sha256=7DoouDUrWN_fZmo1NngoFFNRMY6kNVq4VGpocnYVeQ4,3208
29
+ nlbone/config/logging.py,sha256=rGQz9W5ZgUFXBK74TFmTuwx_WMJhD8zPN39zfKVxwnI,4115
30
+ nlbone/config/settings.py,sha256=xxdZQDQJ7wSEGODKljtQWcfITXbSHoqAXOlQ9vhNSe4,3474
27
31
  nlbone/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
32
  nlbone/core/application/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
+ nlbone/core/application/base_worker.py,sha256=uHqglsd33jXl_0kmkFlB4KQ5NdI1wArcOeQmdcifPQc,1192
29
34
  nlbone/core/application/events.py,sha256=eQGLE0aZHuWJsy9J-qRse4CMXOtweH9-2rQ7AIPRMEQ,614
30
35
  nlbone/core/application/services.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
36
  nlbone/core/application/use_case.py,sha256=3GMQZ3CFK5cbLoBNBgohPft6GBq2j9_wr8iKRq_osQA,247
@@ -33,11 +38,11 @@ nlbone/core/application/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRk
33
38
  nlbone/core/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
39
  nlbone/core/domain/base.py,sha256=5oUfbpaI8juJ28Api8J9IXOSm55VI2bp4QNhA0U8h2Y,1251
35
40
  nlbone/core/domain/events.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
- nlbone/core/domain/models.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
+ nlbone/core/domain/models.py,sha256=e2ig7PMBBpmc8pdHLNMnXhucMXr9OUq-G7bKGTq9Qj0,1458
37
42
  nlbone/core/ports/__init__.py,sha256=gx-Ubj7h-1vvnu56sNnRqmer7HHfW3rX2WLl-0AX5U0,214
38
43
  nlbone/core/ports/auth.py,sha256=Gh0yQsxx2OD6pDH2_p-khsA-bVoypP1juuqMoSfjZUo,493
39
44
  nlbone/core/ports/event_bus.py,sha256=_Om1GOOT-F325oV6_LJXtLdx4vu5i7KrpTDD3qPJXU0,325
40
- nlbone/core/ports/files.py,sha256=1k-Vm0ld89EnFK2wybSXIJm5gQNpeuO92PD7d4VMh8s,1737
45
+ nlbone/core/ports/files.py,sha256=7Ov2ITYRpPwwDTZGCeNVISg8e3A9l08jbOgpTImgfK8,1863
41
46
  nlbone/core/ports/messaging.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
47
  nlbone/core/ports/repo.py,sha256=zOw8CTMAu5DKKy2wZpT3_6JWWjaJCDt7q4dOiJYrCOQ,651
43
48
  nlbone/core/ports/uow.py,sha256=SmBdRf0NvSdIjQ3Le1QGz8kNGBk7jgNHtNguvXRwmgs,557
@@ -50,8 +55,8 @@ nlbone/interfaces/api/schemas.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hS
50
55
  nlbone/interfaces/api/dependencies/__init__.py,sha256=rnYRrFVZCfICQrp_PVFlzNg3BeC57yM08wn2DbOHCfk,359
51
56
  nlbone/interfaces/api/dependencies/async_auth.py,sha256=bfxgBXhp29WqevjTG4jrdPNR-75APm4jKyHdOOtxnp4,1825
52
57
  nlbone/interfaces/api/dependencies/auth.py,sha256=VcWnEipQr4dqhzGbP0G9a5nJdplnpFG3wm_NlDCb8z4,1765
53
- nlbone/interfaces/api/dependencies/db.py,sha256=DGX_hGVrPRnRIuhMnirJZvKNyj1OLC_hQSIrA9NVnNw,468
54
- nlbone/interfaces/api/dependencies/uow.py,sha256=crV9mu4LlmJ0JI1uzNYkZX2VJUjQ6hWvxyXqleZL_ak,1183
58
+ nlbone/interfaces/api/dependencies/db.py,sha256=-UD39J_86UU7ZJs2ZncpdND0yhAG0NeeeALrgSDuuFw,466
59
+ nlbone/interfaces/api/dependencies/uow.py,sha256=QfLEvLYLNWZJQN1k-0q0hBVtUld3D75P4j39q_RjcnE,1181
55
60
  nlbone/interfaces/api/middleware/__init__.py,sha256=zbX2vaEAfxRMIYwO2MVY_2O6bqG5H9o7HqGpX14U3Is,158
56
61
  nlbone/interfaces/api/middleware/access_log.py,sha256=vIkxxxfy2HcjqqKb8XCfGCcSrivAC8u6ie75FMq5x-U,1032
57
62
  nlbone/interfaces/api/middleware/add_request_context.py,sha256=av-qs0biOYuF9R6RJOo2eYsFqDL9WRYWcjVakFhbt-w,1834
@@ -59,14 +64,16 @@ nlbone/interfaces/api/middleware/authentication.py,sha256=ze7vCm492QsX9nPL6A-PqZ
59
64
  nlbone/interfaces/api/pagination/__init__.py,sha256=sWKKQFa2Z-1SlprQOqImOa2c9exq4wueKpUL_9QM7wc,417
60
65
  nlbone/interfaces/api/pagination/offset_base.py,sha256=B6rHxzDsxQbm-d2snM6tjgnhWyZw7zvs7fcehV0gpa0,3621
61
66
  nlbone/interfaces/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
- nlbone/interfaces/cli/init_db.py,sha256=EehZEsMeyR6vA4WvM6sAZ-IRhyiJZdrWBaYiptkJVdc,482
63
- nlbone/interfaces/cli/main.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
67
+ nlbone/interfaces/cli/init_db.py,sha256=49TmA-LwUsqGVttjU-w2EzZ-vzQCULNlhKP_0sfl1_Y,790
68
+ nlbone/interfaces/cli/main.py,sha256=65XXNmH0dX9Lib_yW5iQXo7wp_GRFwx9xXDYgy2LJtY,704
64
69
  nlbone/interfaces/jobs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
70
  nlbone/interfaces/jobs/sync_tokens.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
66
71
  nlbone/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
67
72
  nlbone/utils/context.py,sha256=MmclJ24BG2uvSTg1IK7J-Da9BhVFDQ5ag4Ggs2FF1_w,1600
68
- nlbone/utils/time.py,sha256=0v7TpTglIo1BfwErceXbnFOu6XExNqTQELDXzxLdnAg,103
69
- nlbone-0.4.1.dist-info/METADATA,sha256=byHqPsOWgE3I5pn0hJjszvG2KSCZFVfAxk7ZuM_4rAE,1979
70
- nlbone-0.4.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
71
- nlbone-0.4.1.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
72
- nlbone-0.4.1.dist-info/RECORD,,
73
+ nlbone/utils/redactor.py,sha256=JbbPs2Qtnz0zHN85BGPYQNWwBigXMSzmMEmmZZOTs_U,1277
74
+ nlbone/utils/time.py,sha256=6e0A4_hG1rYDCrWoOklEGVJstBf8j9XSSTT7VNV2K9Y,1272
75
+ nlbone-0.4.3.dist-info/METADATA,sha256=dKKof0RuBfc3ONT-R1n5qD_oJXaE4BIhqcezwx8NH8Q,2163
76
+ nlbone-0.4.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
77
+ nlbone-0.4.3.dist-info/entry_points.txt,sha256=CpIL45t5nbhl1dGQPhfIIDfqqak3teK0SxPGBBr7YCk,59
78
+ nlbone-0.4.3.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
79
+ nlbone-0.4.3.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nlbone = nlbone.interfaces.cli.main:main
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes