nlbone 0.4.1__tar.gz → 0.4.2__tar.gz

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 (85) hide show
  1. {nlbone-0.4.1 → nlbone-0.4.2}/PKG-INFO +5 -1
  2. {nlbone-0.4.1 → nlbone-0.4.2}/pyproject.toml +9 -2
  3. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/auth/keycloak.py +6 -2
  4. nlbone-0.4.2/src/nlbone/adapters/db/__init__.py +4 -0
  5. nlbone-0.4.2/src/nlbone/adapters/db/postgres/audit.py +148 -0
  6. {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/schema.py +2 -2
  7. nlbone-0.4.2/src/nlbone/adapters/db/redis/client.py +22 -0
  8. nlbone-0.4.2/src/nlbone/adapters/percolation/__init__.py +1 -0
  9. nlbone-0.4.2/src/nlbone/adapters/percolation/connection.py +12 -0
  10. nlbone-0.4.2/src/nlbone/config/logging.py +119 -0
  11. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/config/settings.py +9 -2
  12. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/container.py +2 -2
  13. nlbone-0.4.2/src/nlbone/core/application/base_worker.py +36 -0
  14. nlbone-0.4.2/src/nlbone/core/domain/models.py +38 -0
  15. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/db.py +1 -1
  16. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/uow.py +1 -1
  17. nlbone-0.4.2/src/nlbone/interfaces/cli/init_db.py +28 -0
  18. nlbone-0.4.2/src/nlbone/interfaces/cli/main.py +29 -0
  19. nlbone-0.4.2/src/nlbone/utils/redactor.py +32 -0
  20. nlbone-0.4.2/src/nlbone/utils/time.py +44 -0
  21. nlbone-0.4.1/src/nlbone/adapters/db/__init__.py +0 -3
  22. nlbone-0.4.1/src/nlbone/adapters/db/postgres.py +0 -0
  23. nlbone-0.4.1/src/nlbone/config/logging.py +0 -155
  24. nlbone-0.4.1/src/nlbone/core/domain/models.py +0 -0
  25. nlbone-0.4.1/src/nlbone/interfaces/cli/init_db.py +0 -20
  26. nlbone-0.4.1/src/nlbone/interfaces/cli/main.py +0 -0
  27. nlbone-0.4.1/src/nlbone/utils/time.py +0 -5
  28. {nlbone-0.4.1 → nlbone-0.4.2}/.gitignore +0 -0
  29. {nlbone-0.4.1 → nlbone-0.4.2}/LICENSE +0 -0
  30. {nlbone-0.4.1 → nlbone-0.4.2}/README.md +0 -0
  31. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/__init__.py +0 -0
  32. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/__init__.py +0 -0
  33. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/auth/__init__.py +0 -0
  34. {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/__init__.py +0 -0
  35. {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/base.py +0 -0
  36. {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/engine.py +0 -0
  37. {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/query_builder.py +0 -0
  38. {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/repository.py +0 -0
  39. {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/uow.py +0 -0
  40. {nlbone-0.4.1/src/nlbone/adapters/http_clients → nlbone-0.4.2/src/nlbone/adapters/db/redis}/__init__.py +0 -0
  41. {nlbone-0.4.1/src/nlbone/config → nlbone-0.4.2/src/nlbone/adapters/http_clients}/__init__.py +0 -0
  42. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/http_clients/email_gateway.py +0 -0
  43. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/http_clients/uploadchi.py +0 -0
  44. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/http_clients/uploadchi_async.py +0 -0
  45. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/messaging/__init__.py +0 -0
  46. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/messaging/event_bus.py +0 -0
  47. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/messaging/redis.py +0 -0
  48. {nlbone-0.4.1/src/nlbone/core → nlbone-0.4.2/src/nlbone/config}/__init__.py +0 -0
  49. {nlbone-0.4.1/src/nlbone/core/application → nlbone-0.4.2/src/nlbone/core}/__init__.py +0 -0
  50. {nlbone-0.4.1/src/nlbone/core/application/services → nlbone-0.4.2/src/nlbone/core/application}/__init__.py +0 -0
  51. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/application/events.py +0 -0
  52. {nlbone-0.4.1/src/nlbone/core/domain → nlbone-0.4.2/src/nlbone/core/application/services}/__init__.py +0 -0
  53. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/application/services.py +0 -0
  54. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/application/use_case.py +0 -0
  55. {nlbone-0.4.1/src/nlbone/interfaces → nlbone-0.4.2/src/nlbone/core/domain}/__init__.py +0 -0
  56. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/domain/base.py +0 -0
  57. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/domain/events.py +0 -0
  58. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/__init__.py +0 -0
  59. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/auth.py +0 -0
  60. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/event_bus.py +0 -0
  61. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/files.py +0 -0
  62. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/messaging.py +0 -0
  63. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/repo.py +0 -0
  64. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/uow.py +0 -0
  65. {nlbone-0.4.1/src/nlbone/interfaces/api → nlbone-0.4.2/src/nlbone/interfaces}/__init__.py +0 -0
  66. {nlbone-0.4.1/src/nlbone/interfaces/cli → nlbone-0.4.2/src/nlbone/interfaces/api}/__init__.py +0 -0
  67. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/__init__.py +0 -0
  68. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/async_auth.py +0 -0
  69. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/auth.py +0 -0
  70. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/exception_handlers.py +0 -0
  71. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/exceptions.py +0 -0
  72. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/middleware/__init__.py +0 -0
  73. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/middleware/access_log.py +0 -0
  74. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/middleware/add_request_context.py +0 -0
  75. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/middleware/authentication.py +0 -0
  76. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/pagination/__init__.py +0 -0
  77. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/pagination/offset_base.py +0 -0
  78. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/routers.py +0 -0
  79. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/schemas.py +0 -0
  80. {nlbone-0.4.1/src/nlbone/interfaces/jobs → nlbone-0.4.2/src/nlbone/interfaces/cli}/__init__.py +0 -0
  81. {nlbone-0.4.1/src/nlbone/utils → nlbone-0.4.2/src/nlbone/interfaces/jobs}/__init__.py +0 -0
  82. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/jobs/sync_tokens.py +0 -0
  83. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/types.py +0 -0
  84. /nlbone-0.4.1/src/nlbone/adapters/db/memory.py → /nlbone-0.4.2/src/nlbone/utils/__init__.py +0 -0
  85. {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/utils/context.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nlbone
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nlbone"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Backbone package for interfaces and infrastructure in Python projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -22,7 +22,11 @@ dependencies = [
22
22
  "uvicorn>=0.35",
23
23
  "sqlalchemy>=2.0",
24
24
  "psycopg>=3.2.9",
25
- "dependency-injector>=4.48.1"
25
+ "dependency-injector>=4.48.1",
26
+ "elasticsearch==8.14.0",
27
+ "redis~=6.4.0",
28
+ "python-dateutil~=2.9.0.post0",
29
+ "typer>=0.17.4"
26
30
  ]
27
31
 
28
32
  [tool.ruff]
@@ -56,3 +60,6 @@ dev = [
56
60
  "pytest>=8.4.2",
57
61
  "ruff>=0.12.12",
58
62
  ]
63
+
64
+ [project.scripts]
65
+ nlbone = "nlbone.interfaces.cli.main:main"
@@ -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,4 @@
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,148 @@
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 getattr(obj, "__audit_disable__", False):
26
+ return True
27
+ if hasattr(obj, "__audit_enable__") and not getattr(obj, "__audit_enable__"):
28
+ return True
29
+ return False
30
+
31
+
32
+ def _is_op_enabled(obj, op: str) -> bool:
33
+ if _is_audit_disabled(obj):
34
+ return False
35
+ return op.upper() in _get_ops_for(obj)
36
+
37
+
38
+ def _ser(val):
39
+ if isinstance(val, (date, datetime)):
40
+ return val.isoformat()
41
+ # UUID
42
+ if isinstance(val, uuid.UUID):
43
+ return str(val)
44
+ # Enum
45
+ if isinstance(val, _Enum):
46
+ return val.value
47
+ if isinstance(val, Decimal):
48
+ return str(val)
49
+ if isinstance(val, set):
50
+ return list(val)
51
+ return val
52
+
53
+
54
+ def _entity_name(obj: Any) -> str:
55
+ return getattr(getattr(obj, "__table__", None), "name", None) or getattr(obj, "__tablename__",
56
+ None) or obj.__class__.__name__
57
+
58
+
59
+ def _entity_id(obj: Any) -> str:
60
+ insp = sa_inspect(obj)
61
+ if insp.identity and len(insp.identity) == 1:
62
+ return _ser(insp.identity[0])
63
+ for pk in insp.mapper.primary_key:
64
+ v = getattr(obj, pk.key)
65
+ if v is not None:
66
+ return _ser(v)
67
+ return _ser(getattr(obj, "id", "?"))
68
+
69
+
70
+ def _changes_for_update(obj: any) -> dict[str, dict[str, any]]:
71
+ changes = {}
72
+ insp = sa_inspect(obj)
73
+ exclude = set(getattr(obj, "__audit_exclude__", set())) | DEFAULT_EXCLUDE
74
+
75
+ for col in insp.mapper.column_attrs:
76
+ key = col.key
77
+ if key in exclude:
78
+ continue
79
+
80
+ try:
81
+ state = insp.attrs[key]
82
+ except KeyError:
83
+ continue
84
+
85
+ hist = state.history # History object
86
+ if hist.has_changes():
87
+ old = hist.deleted[0] if hist.deleted else None
88
+ new = hist.added[0] if hist.added else None
89
+ if old != new:
90
+ changes[key] = {"old": _ser(old), "new": _ser(new)}
91
+ return changes
92
+ @event.listens_for(SASession, "before_flush")
93
+ def before_flush(session: SASession, flush_context, instances):
94
+ entries = session.info.setdefault("_audit_entries", [])
95
+
96
+ # INSERT
97
+ for obj in session.new:
98
+ if isinstance(obj, AuditLog) or not _is_op_enabled(obj, "INSERT"):
99
+ continue
100
+ insp = sa_inspect(obj)
101
+ exclude = set(getattr(obj, "__audit_exclude__", set())) | DEFAULT_EXCLUDE
102
+ row = {}
103
+ for col_attr in insp.mapper.column_attrs:
104
+ key = col_attr.key
105
+ if key in exclude:
106
+ continue
107
+ row[key] = _ser(getattr(obj, key, None))
108
+ entries.append({
109
+ "obj": obj,
110
+ "op": "INSERT",
111
+ "changes": {k: {"old": None, "new": v} for k, v in row.items()}
112
+ })
113
+
114
+ # UPDATE
115
+ for obj in session.dirty:
116
+ if isinstance(obj, AuditLog) or not _is_op_enabled(obj, "UPDATE"):
117
+ continue
118
+ if session.is_modified(obj, include_collections=False):
119
+ ch = _changes_for_update(obj)
120
+ if ch:
121
+ entries.append({"obj": obj, "op": "UPDATE", "changes": ch})
122
+
123
+ # DELETE
124
+ for obj in session.deleted:
125
+ if isinstance(obj, AuditLog) or not _is_op_enabled(obj, "DELETE"):
126
+ continue
127
+ entries.append({"obj": obj, "op": "DELETE", "changes": None})
128
+
129
+
130
+ @event.listens_for(SASession, "after_flush_postexec")
131
+ def after_flush_postexec(session: SASession, flush_context):
132
+ entries = session.info.pop("_audit_entries", [])
133
+ if not entries:
134
+ return
135
+ ctx = current_context_dict()
136
+ for e in entries:
137
+ obj = e["obj"]
138
+ al = AuditLog(
139
+ entity=_entity_name(obj),
140
+ entity_id=str(_entity_id(obj)),
141
+ operation=e["op"],
142
+ changes=e.get("changes"),
143
+ actor_id=ctx.get("user_id"),
144
+ request_id=ctx.get("request_id"),
145
+ ip=ctx.get("ip"),
146
+ user_agent=ctx.get("user_agent"),
147
+ )
148
+ 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
@@ -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
@@ -0,0 +1,119 @@
1
+ import json
2
+ import logging
3
+ import sys
4
+ from datetime import datetime, timezone
5
+ from logging.config import dictConfig
6
+ from typing import Any, MutableMapping
7
+
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
+
12
+ settings = get_settings()
13
+
14
+ # ---------- Filters ----------
15
+ class ContextFilter(logging.Filter):
16
+ def filter(self, record: logging.LogRecord) -> bool:
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")
22
+ return True
23
+
24
+ # ---------- Formatter ----------
25
+ class JsonFormatter(logging.Formatter):
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
+ }
31
+
32
+ def format(self, record: logging.LogRecord) -> str:
33
+ payload: MutableMapping[str, Any] = {
34
+ "ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
35
+ "level": record.levelname,
36
+ "logger": record.name,
37
+ "msg": record.getMessage(),
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),
42
+ }
43
+
44
+ for k, v in record.__dict__.items():
45
+ if k in self.RESERVED or k in payload:
46
+ continue
47
+ payload[k] = v
48
+
49
+ if record.exc_info:
50
+ etype = record.exc_info[0].__name__ if record.exc_info[0] else None
51
+ payload["exc_type"] = etype
52
+ payload["exc"] = self.formatException(record.exc_info)
53
+
54
+ return json.dumps(payload, ensure_ascii=False)
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
+ }
84
+
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)
105
+
106
+ uvicorn_error = logging.getLogger("uvicorn.error")
107
+ uvicorn_error.handlers = []
108
+ uvicorn_error.propagate = True
109
+
110
+ uvicorn_access = logging.getLogger("uvicorn.access")
111
+ if silence_uvicorn_access:
112
+ uvicorn_access.handlers = []
113
+ uvicorn_access.propagate = False
114
+ else:
115
+ uvicorn_access.handlers = []
116
+ uvicorn_access.propagate = True
117
+
118
+ def get_logger(name: str | None = None) -> logging.Logger:
119
+ return logging.getLogger(name or "app")
@@ -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
 
@@ -5,8 +5,8 @@ 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.db.postgres import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
9
+ from nlbone.adapters.db.postgres.engine import get_async_session_factory, get_sync_session_factory
10
10
  from nlbone.adapters.http_clients.uploadchi import UploadchiClient
11
11
  from nlbone.adapters.http_clients.uploadchi_async import UploadchiAsyncClient
12
12
  from nlbone.adapters.messaging import InMemoryEventBus
@@ -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
+ )
@@ -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
 
@@ -0,0 +1,28 @@
1
+ import typer
2
+
3
+ from nlbone.adapters.db import init_sync_engine, Base, sync_ping
4
+
5
+ init_db_command = typer.Typer(help="Database utilities")
6
+
7
+
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.")
16
+
17
+
18
+ @init_db_command.command("ping")
19
+ def ping():
20
+ """Health check."""
21
+ sync_ping()
22
+ typer.echo("✅ DB connection OK")
23
+
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
@@ -0,0 +1,44 @@
1
+ from datetime import datetime, timedelta, timezone
2
+
3
+ from dateutil import parser
4
+
5
+
6
+ def now() -> datetime:
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,3 +0,0 @@
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
File without changes
@@ -1,155 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import contextvars
4
- import json
5
- import logging
6
- import sys
7
- from datetime import datetime, timezone
8
- from typing import Any, MutableMapping, Optional
9
-
10
- from nlbone.config.settings import get_settings
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."""
28
-
29
- 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 "-"
33
- return True
34
-
35
-
36
- class JsonFormatter(logging.Formatter):
37
- """Minimal JSON formatter with ISO8601 timestamps."""
38
-
39
- def format(self, record: logging.LogRecord) -> str:
40
- payload: MutableMapping[str, Any] = {
41
- "ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
42
- "level": record.levelname,
43
- "logger": record.name,
44
- "msg": record.getMessage(),
45
- "request_id": getattr(record, "request_id", "-"),
46
- }
47
-
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:
78
- continue
79
- payload[key] = value
80
-
81
- # Attach exception info if present
82
- if record.exc_info:
83
- payload["exc_type"] = record.exc_info[0].__name__ if record.exc_info[0] else None
84
- payload["exc"] = self.formatException(record.exc_info)
85
-
86
- return json.dumps(payload, ensure_ascii=False)
87
-
88
-
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)
131
-
132
- # Uvicorn / FastAPI compatibility (if used later)
133
- # - application logs go through root
134
- # - format access logs separately or silence them
135
- uvicorn_error = logging.getLogger("uvicorn.error")
136
- uvicorn_error.handlers = []
137
- uvicorn_error.propagate = True # bubble up to root
138
-
139
- uvicorn_access = logging.getLogger("uvicorn.access")
140
- if silence_uvicorn_access:
141
- uvicorn_access.handlers = []
142
- uvicorn_access.propagate = False
143
- else:
144
- uvicorn_access.handlers = []
145
- uvicorn_access.propagate = True
146
-
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)
File without changes
@@ -1,20 +0,0 @@
1
- import argparse
2
-
3
- import anyio
4
-
5
- from nlbone.adapters.db.sqlalchemy.schema import init_db_async, init_db_sync
6
-
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()
12
-
13
- if args.use_async:
14
- anyio.run(init_db_async)
15
- else:
16
- init_db_sync()
17
-
18
-
19
- if __name__ == "__main__":
20
- main()
File without changes
@@ -1,5 +0,0 @@
1
- from datetime import datetime, timezone
2
-
3
-
4
- def now() -> datetime:
5
- return datetime.now(timezone.utc)
File without changes
File without changes
File without changes
File without changes
File without changes