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.
- {nlbone-0.4.1 → nlbone-0.4.2}/PKG-INFO +5 -1
- {nlbone-0.4.1 → nlbone-0.4.2}/pyproject.toml +9 -2
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/auth/keycloak.py +6 -2
- nlbone-0.4.2/src/nlbone/adapters/db/__init__.py +4 -0
- nlbone-0.4.2/src/nlbone/adapters/db/postgres/audit.py +148 -0
- {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/schema.py +2 -2
- nlbone-0.4.2/src/nlbone/adapters/db/redis/client.py +22 -0
- nlbone-0.4.2/src/nlbone/adapters/percolation/__init__.py +1 -0
- nlbone-0.4.2/src/nlbone/adapters/percolation/connection.py +12 -0
- nlbone-0.4.2/src/nlbone/config/logging.py +119 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/config/settings.py +9 -2
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/container.py +2 -2
- nlbone-0.4.2/src/nlbone/core/application/base_worker.py +36 -0
- nlbone-0.4.2/src/nlbone/core/domain/models.py +38 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/db.py +1 -1
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/uow.py +1 -1
- nlbone-0.4.2/src/nlbone/interfaces/cli/init_db.py +28 -0
- nlbone-0.4.2/src/nlbone/interfaces/cli/main.py +29 -0
- nlbone-0.4.2/src/nlbone/utils/redactor.py +32 -0
- nlbone-0.4.2/src/nlbone/utils/time.py +44 -0
- nlbone-0.4.1/src/nlbone/adapters/db/__init__.py +0 -3
- nlbone-0.4.1/src/nlbone/adapters/db/postgres.py +0 -0
- nlbone-0.4.1/src/nlbone/config/logging.py +0 -155
- nlbone-0.4.1/src/nlbone/core/domain/models.py +0 -0
- nlbone-0.4.1/src/nlbone/interfaces/cli/init_db.py +0 -20
- nlbone-0.4.1/src/nlbone/interfaces/cli/main.py +0 -0
- nlbone-0.4.1/src/nlbone/utils/time.py +0 -5
- {nlbone-0.4.1 → nlbone-0.4.2}/.gitignore +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/LICENSE +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/README.md +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/auth/__init__.py +0 -0
- {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/__init__.py +0 -0
- {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/base.py +0 -0
- {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/engine.py +0 -0
- {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/query_builder.py +0 -0
- {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/repository.py +0 -0
- {nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy → nlbone-0.4.2/src/nlbone/adapters/db/postgres}/uow.py +0 -0
- {nlbone-0.4.1/src/nlbone/adapters/http_clients → nlbone-0.4.2/src/nlbone/adapters/db/redis}/__init__.py +0 -0
- {nlbone-0.4.1/src/nlbone/config → nlbone-0.4.2/src/nlbone/adapters/http_clients}/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/http_clients/email_gateway.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/http_clients/uploadchi.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/http_clients/uploadchi_async.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/messaging/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/messaging/event_bus.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/adapters/messaging/redis.py +0 -0
- {nlbone-0.4.1/src/nlbone/core → nlbone-0.4.2/src/nlbone/config}/__init__.py +0 -0
- {nlbone-0.4.1/src/nlbone/core/application → nlbone-0.4.2/src/nlbone/core}/__init__.py +0 -0
- {nlbone-0.4.1/src/nlbone/core/application/services → nlbone-0.4.2/src/nlbone/core/application}/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/application/events.py +0 -0
- {nlbone-0.4.1/src/nlbone/core/domain → nlbone-0.4.2/src/nlbone/core/application/services}/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/application/services.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/application/use_case.py +0 -0
- {nlbone-0.4.1/src/nlbone/interfaces → nlbone-0.4.2/src/nlbone/core/domain}/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/domain/base.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/domain/events.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/auth.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/event_bus.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/files.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/messaging.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/repo.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/core/ports/uow.py +0 -0
- {nlbone-0.4.1/src/nlbone/interfaces/api → nlbone-0.4.2/src/nlbone/interfaces}/__init__.py +0 -0
- {nlbone-0.4.1/src/nlbone/interfaces/cli → nlbone-0.4.2/src/nlbone/interfaces/api}/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/async_auth.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/dependencies/auth.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/exception_handlers.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/exceptions.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/middleware/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/middleware/access_log.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/middleware/add_request_context.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/middleware/authentication.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/pagination/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/pagination/offset_base.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/routers.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/api/schemas.py +0 -0
- {nlbone-0.4.1/src/nlbone/interfaces/jobs → nlbone-0.4.2/src/nlbone/interfaces/cli}/__init__.py +0 -0
- {nlbone-0.4.1/src/nlbone/utils → nlbone-0.4.2/src/nlbone/interfaces/jobs}/__init__.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/interfaces/jobs/sync_tokens.py +0 -0
- {nlbone-0.4.1 → nlbone-0.4.2}/src/nlbone/types.py +0 -0
- /nlbone-0.4.1/src/nlbone/adapters/db/memory.py → /nlbone-0.4.2/src/nlbone/utils/__init__.py +0 -0
- {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.
|
|
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.
|
|
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,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.
|
|
5
|
-
from nlbone.adapters.db.
|
|
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
|
|
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
|
|
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.
|
|
9
|
-
from nlbone.adapters.db.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nlbone-0.4.1/src/nlbone/config → nlbone-0.4.2/src/nlbone/adapters/http_clients}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nlbone-0.4.1/src/nlbone/interfaces/cli → nlbone-0.4.2/src/nlbone/interfaces/api}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nlbone-0.4.1/src/nlbone/interfaces/jobs → nlbone-0.4.2/src/nlbone/interfaces/cli}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|