nlbone 0.3.3__tar.gz → 0.4.1__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.3.3 → nlbone-0.4.1}/PKG-INFO +1 -9
- {nlbone-0.3.3 → nlbone-0.4.1}/pyproject.toml +21 -17
- nlbone-0.4.1/src/nlbone/adapters/auth/__init__.py +1 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/auth/keycloak.py +3 -2
- nlbone-0.4.1/src/nlbone/adapters/db/__init__.py +3 -0
- nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy/__init__.py +4 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/db/sqlalchemy/base.py +0 -2
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/db/sqlalchemy/engine.py +15 -3
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/db/sqlalchemy/query_builder.py +27 -11
- nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy/repository.py +54 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/db/sqlalchemy/schema.py +5 -5
- nlbone-0.4.1/src/nlbone/adapters/db/sqlalchemy/uow.py +71 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/http_clients/uploadchi.py +27 -11
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/http_clients/uploadchi_async.py +29 -7
- nlbone-0.4.1/src/nlbone/adapters/messaging/__init__.py +1 -0
- nlbone-0.4.1/src/nlbone/adapters/messaging/event_bus.py +23 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/config/logging.py +3 -8
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/config/settings.py +18 -22
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/container.py +18 -2
- nlbone-0.4.1/src/nlbone/core/application/events.py +20 -0
- nlbone-0.4.1/src/nlbone/core/application/use_case.py +12 -0
- nlbone-0.4.1/src/nlbone/core/domain/base.py +51 -0
- nlbone-0.4.1/src/nlbone/core/ports/__init__.py +5 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/core/ports/auth.py +1 -0
- nlbone-0.4.1/src/nlbone/core/ports/event_bus.py +10 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/core/ports/files.py +26 -5
- nlbone-0.4.1/src/nlbone/core/ports/repo.py +19 -0
- nlbone-0.4.1/src/nlbone/core/ports/uow.py +19 -0
- nlbone-0.4.1/src/nlbone/interfaces/api/dependencies/__init__.py +11 -0
- nlbone-0.4.1/src/nlbone/interfaces/api/dependencies/async_auth.py +61 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/dependencies/auth.py +5 -3
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/dependencies/db.py +4 -2
- nlbone-0.4.1/src/nlbone/interfaces/api/dependencies/uow.py +32 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/exception_handlers.py +17 -15
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/exceptions.py +1 -2
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/middleware/__init__.py +2 -2
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/middleware/access_log.py +12 -8
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/middleware/add_request_context.py +55 -52
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/middleware/authentication.py +4 -1
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/pagination/__init__.py +4 -5
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/pagination/offset_base.py +0 -2
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/cli/init_db.py +3 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/utils/context.py +14 -4
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/utils/time.py +1 -1
- nlbone-0.3.3/src/nlbone/adapters/auth/__init__.py +0 -1
- nlbone-0.3.3/src/nlbone/adapters/db/__init__.py +0 -3
- nlbone-0.3.3/src/nlbone/adapters/db/sqlalchemy/__init__.py +0 -1
- nlbone-0.3.3/src/nlbone/core/application/use_cases/register_user.py +0 -0
- nlbone-0.3.3/src/nlbone/core/ports/__init__.py +0 -1
- nlbone-0.3.3/src/nlbone/core/ports/repo.py +0 -0
- nlbone-0.3.3/src/nlbone/interfaces/api/dependencies/__init__.py +0 -2
- nlbone-0.3.3/src/nlbone/interfaces/jobs/__init__.py +0 -0
- nlbone-0.3.3/src/nlbone/utils/__init__.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/.gitignore +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/LICENSE +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/README.md +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/__init__.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/__init__.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/db/memory.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/db/postgres.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/http_clients/__init__.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/http_clients/email_gateway.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/adapters/messaging/redis.py +0 -0
- {nlbone-0.3.3/src/nlbone/adapters/messaging → nlbone-0.4.1/src/nlbone/config}/__init__.py +0 -0
- {nlbone-0.3.3/src/nlbone/config → nlbone-0.4.1/src/nlbone/core}/__init__.py +0 -0
- {nlbone-0.3.3/src/nlbone/core → nlbone-0.4.1/src/nlbone/core/application}/__init__.py +0 -0
- {nlbone-0.3.3/src/nlbone/core/application → nlbone-0.4.1/src/nlbone/core/application/services}/__init__.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/core/application/services.py +0 -0
- {nlbone-0.3.3/src/nlbone/core/application/services → nlbone-0.4.1/src/nlbone/core/domain}/__init__.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/core/domain/events.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/core/domain/models.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/core/ports/messaging.py +0 -0
- {nlbone-0.3.3/src/nlbone/core/application/use_cases → nlbone-0.4.1/src/nlbone/interfaces}/__init__.py +0 -0
- {nlbone-0.3.3/src/nlbone/core/domain → nlbone-0.4.1/src/nlbone/interfaces/api}/__init__.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/routers.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/api/schemas.py +0 -0
- {nlbone-0.3.3/src/nlbone/interfaces → nlbone-0.4.1/src/nlbone/interfaces/cli}/__init__.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/cli/main.py +0 -0
- {nlbone-0.3.3/src/nlbone/interfaces/api → nlbone-0.4.1/src/nlbone/interfaces/jobs}/__init__.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/interfaces/jobs/sync_tokens.py +0 -0
- {nlbone-0.3.3 → nlbone-0.4.1}/src/nlbone/types.py +0 -0
- {nlbone-0.3.3/src/nlbone/interfaces/cli → nlbone-0.4.1/src/nlbone/utils}/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nlbone
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -17,14 +17,6 @@ Requires-Dist: python-keycloak==5.8.1
|
|
|
17
17
|
Requires-Dist: sqlalchemy>=2.0
|
|
18
18
|
Requires-Dist: starlette>=0.47
|
|
19
19
|
Requires-Dist: uvicorn>=0.35
|
|
20
|
-
Provides-Extra: dev
|
|
21
|
-
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
22
|
-
Requires-Dist: pre-commit>=3.7; extra == 'dev'
|
|
23
|
-
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
24
|
-
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
-
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
26
|
-
Requires-Dist: tomli; extra == 'dev'
|
|
27
|
-
Requires-Dist: twine; extra == 'dev'
|
|
28
20
|
Description-Content-Type: text/markdown
|
|
29
21
|
|
|
30
22
|
# nlbone
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nlbone"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "Backbone package for interfaces and infrastructure in Python projects"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -25,20 +25,17 @@ dependencies = [
|
|
|
25
25
|
"dependency-injector>=4.48.1"
|
|
26
26
|
]
|
|
27
27
|
|
|
28
|
-
[project.optional-dependencies]
|
|
29
|
-
dev = [
|
|
30
|
-
"pytest>=8.0",
|
|
31
|
-
"pytest-asyncio>=0.23",
|
|
32
|
-
"ruff>=0.5",
|
|
33
|
-
"mypy>=1.10",
|
|
34
|
-
"pre-commit>=3.7",
|
|
35
|
-
"twine",
|
|
36
|
-
"tomli"
|
|
37
|
-
]
|
|
38
|
-
|
|
39
28
|
[tool.ruff]
|
|
40
|
-
line-length =
|
|
41
|
-
target-version = "
|
|
29
|
+
line-length = 120
|
|
30
|
+
target-version = "py312"
|
|
31
|
+
exclude = ["tests"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
select = ["E", "F", "I"]
|
|
35
|
+
preview = true
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint.per-file-ignores]
|
|
38
|
+
"__init__.py" = ["F401"]
|
|
42
39
|
|
|
43
40
|
[tool.pytest.ini_options]
|
|
44
41
|
asyncio_mode = "auto"
|
|
@@ -48,7 +45,14 @@ packages = ["src/nlbone"]
|
|
|
48
45
|
|
|
49
46
|
[tool.hatch.build.targets.sdist]
|
|
50
47
|
include = [
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
"src/nlbone",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[dependency-groups]
|
|
54
|
+
dev = [
|
|
55
|
+
"pre-commit>=4.3.0",
|
|
56
|
+
"pytest>=8.4.2",
|
|
57
|
+
"ruff>=0.12.12",
|
|
54
58
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .keycloak import KeycloakAuthService
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from keycloak import KeycloakOpenID
|
|
2
2
|
from keycloak.exceptions import KeycloakAuthenticationError
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
from nlbone.config.settings import Settings, get_settings
|
|
5
|
+
from nlbone.core.ports.auth import AuthService
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class KeycloakAuthService(AuthService):
|
|
@@ -70,4 +71,4 @@ class KeycloakAuthService(AuthService):
|
|
|
70
71
|
def client_has_access(self, token: str, permissions: list[str], allowed_clients: set[str] | None = None) -> bool:
|
|
71
72
|
if not self.is_client_token(token, allowed_clients):
|
|
72
73
|
return False
|
|
73
|
-
return self.has_access(token, permissions)
|
|
74
|
+
return self.has_access(token, permissions)
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
from .engine import async_ping, async_session, init_async_engine, init_sync_engine, sync_ping, sync_session
|
|
2
|
+
from .query_builder import apply_pagination, get_paginated_response
|
|
3
|
+
from .repository import AsyncSqlAlchemyRepository, SqlAlchemyRepository
|
|
4
|
+
from .uow import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
from contextlib import asynccontextmanager, contextmanager
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Any, AsyncGenerator, Generator, Optional
|
|
3
3
|
|
|
4
4
|
from sqlalchemy import create_engine, text
|
|
5
5
|
from sqlalchemy.engine import Engine
|
|
6
|
-
from sqlalchemy.orm import Session, sessionmaker
|
|
7
|
-
|
|
8
6
|
from sqlalchemy.ext.asyncio import (
|
|
9
7
|
AsyncEngine,
|
|
10
8
|
AsyncSession,
|
|
11
9
|
async_sessionmaker,
|
|
12
10
|
create_async_engine,
|
|
13
11
|
)
|
|
12
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
14
13
|
|
|
15
14
|
from nlbone.config.settings import get_settings
|
|
16
15
|
|
|
@@ -117,3 +116,16 @@ def sync_ping() -> None:
|
|
|
117
116
|
with eng.connect() as conn:
|
|
118
117
|
conn.execute(text("SELECT 1"))
|
|
119
118
|
|
|
119
|
+
|
|
120
|
+
def get_async_session_factory() -> async_sessionmaker[AsyncSession]:
|
|
121
|
+
if _async_session_factory is None:
|
|
122
|
+
init_async_engine()
|
|
123
|
+
assert _async_session_factory is not None
|
|
124
|
+
return _async_session_factory
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_sync_session_factory() -> sessionmaker[Session]:
|
|
128
|
+
if _sync_session_factory is None:
|
|
129
|
+
init_sync_engine()
|
|
130
|
+
assert _sync_session_factory is not None
|
|
131
|
+
return _sync_session_factory
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import Any, Callable, Optional, Sequence, Type, Union
|
|
2
2
|
|
|
3
3
|
from sqlalchemy import asc, desc, or_
|
|
4
|
+
from sqlalchemy.dialects.postgresql import ENUM as PGEnum
|
|
5
|
+
from sqlalchemy.orm import Query, Session
|
|
4
6
|
from sqlalchemy.orm.interfaces import LoaderOption
|
|
5
7
|
from sqlalchemy.sql.sqltypes import (
|
|
6
|
-
|
|
8
|
+
BigInteger,
|
|
9
|
+
Boolean,
|
|
10
|
+
Float,
|
|
11
|
+
Integer,
|
|
12
|
+
Numeric,
|
|
13
|
+
SmallInteger,
|
|
14
|
+
String,
|
|
15
|
+
Text,
|
|
16
|
+
)
|
|
17
|
+
from sqlalchemy.sql.sqltypes import (
|
|
18
|
+
Enum as SAEnum,
|
|
7
19
|
)
|
|
8
|
-
from sqlalchemy.orm import Session, Query
|
|
9
|
-
from sqlalchemy.dialects.postgresql import ENUM as PGEnum
|
|
10
20
|
|
|
11
21
|
from nlbone.interfaces.api.exceptions import UnprocessableEntityException
|
|
12
22
|
from nlbone.interfaces.api.pagination import PaginateRequest, PaginateResponse
|
|
@@ -122,12 +132,16 @@ def _apply_filters(pagination, entity, query):
|
|
|
122
132
|
return float(v)
|
|
123
133
|
# Booleans
|
|
124
134
|
if isinstance(coltype, Boolean):
|
|
125
|
-
if isinstance(v, bool):
|
|
126
|
-
|
|
135
|
+
if isinstance(v, bool):
|
|
136
|
+
return v
|
|
137
|
+
if isinstance(v, (int, float)):
|
|
138
|
+
return bool(v)
|
|
127
139
|
if isinstance(v, str):
|
|
128
140
|
vl = v.strip().lower()
|
|
129
|
-
if vl in {"true", "1", "yes", "y", "t"}:
|
|
130
|
-
|
|
141
|
+
if vl in {"true", "1", "yes", "y", "t"}:
|
|
142
|
+
return True
|
|
143
|
+
if vl in {"false", "0", "no", "n", "f"}:
|
|
144
|
+
return False
|
|
131
145
|
return None
|
|
132
146
|
# fallback
|
|
133
147
|
return v
|
|
@@ -215,7 +229,8 @@ def _serialize_item(item: Any, output_cls: OutputType) -> Any:
|
|
|
215
229
|
try:
|
|
216
230
|
obj = output_cls(item) # type: ignore[call-arg]
|
|
217
231
|
try:
|
|
218
|
-
from dataclasses import
|
|
232
|
+
from dataclasses import asdict, is_dataclass
|
|
233
|
+
|
|
219
234
|
if is_dataclass(obj):
|
|
220
235
|
return asdict(obj)
|
|
221
236
|
except Exception:
|
|
@@ -252,5 +267,6 @@ def get_paginated_response(
|
|
|
252
267
|
data = [output_cls.model_validate(r, from_attributes=True).model_dump() for r in rows]
|
|
253
268
|
else:
|
|
254
269
|
data = rows
|
|
255
|
-
return PaginateResponse(
|
|
256
|
-
|
|
270
|
+
return PaginateResponse(
|
|
271
|
+
total_count=total_count, data=data, limit=pagination.limit, offset=pagination.offset
|
|
272
|
+
).to_dict()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Generic, Iterable, List, Optional, Type, TypeVar
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from nlbone.core.ports.repo import AsyncRepository, Repository
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SqlAlchemyRepository(Repository[T], Generic[T]):
|
|
15
|
+
def __init__(self, session: Session, model: Type[T]) -> None:
|
|
16
|
+
self.session = session
|
|
17
|
+
self.model = model
|
|
18
|
+
|
|
19
|
+
def get(self, id) -> Optional[T]:
|
|
20
|
+
return self.session.get(self.model, id)
|
|
21
|
+
|
|
22
|
+
def add(self, obj: T) -> None:
|
|
23
|
+
self.session.add(obj)
|
|
24
|
+
|
|
25
|
+
def remove(self, obj: T) -> None:
|
|
26
|
+
self.session.delete(obj)
|
|
27
|
+
|
|
28
|
+
def list(self, *, limit: int | None = None, offset: int = 0) -> Iterable[T]:
|
|
29
|
+
q = self.session.query(self.model).offset(offset)
|
|
30
|
+
if limit is not None:
|
|
31
|
+
q = q.limit(limit)
|
|
32
|
+
return q.all()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncSqlAlchemyRepository(AsyncRepository, Generic[T]):
|
|
36
|
+
def __init__(self, session: AsyncSession, model: Type[T]) -> None:
|
|
37
|
+
self.session = session
|
|
38
|
+
self.model = model
|
|
39
|
+
|
|
40
|
+
async def get(self, id) -> Optional[T]:
|
|
41
|
+
return await self.session.get(self.model, id)
|
|
42
|
+
|
|
43
|
+
def add(self, obj: T) -> None:
|
|
44
|
+
self.session.add(obj)
|
|
45
|
+
|
|
46
|
+
async def remove(self, obj: T) -> None:
|
|
47
|
+
await self.session.delete(obj)
|
|
48
|
+
|
|
49
|
+
async def list(self, *, limit: int | None = None, offset: int = 0) -> List[T]:
|
|
50
|
+
stmt = select(self.model).offset(offset)
|
|
51
|
+
if limit is not None:
|
|
52
|
+
stmt = stmt.limit(limit)
|
|
53
|
+
res = await self.session.execute(stmt)
|
|
54
|
+
return list(res.scalars().all())
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
|
|
2
1
|
import importlib
|
|
3
2
|
from typing import Sequence
|
|
4
3
|
|
|
5
4
|
from nlbone.adapters.db.sqlalchemy.base import Base
|
|
6
5
|
from nlbone.adapters.db.sqlalchemy.engine import init_async_engine, init_sync_engine
|
|
7
6
|
|
|
7
|
+
DEFAULT_MODEL_MODULES: Sequence[str] = ()
|
|
8
8
|
|
|
9
|
-
DEFAULT_MODEL_MODULES: Sequence[str] = (
|
|
10
|
-
)
|
|
11
9
|
|
|
12
10
|
def import_model_modules(modules: Sequence[str] | None = None) -> None:
|
|
13
|
-
for m in
|
|
11
|
+
for m in modules or DEFAULT_MODEL_MODULES:
|
|
14
12
|
importlib.import_module(m)
|
|
15
13
|
|
|
14
|
+
|
|
16
15
|
# --------- Async (SQLAlchemy 2.x) ----------
|
|
17
16
|
async def init_db_async(model_modules: Sequence[str] | None = None) -> None:
|
|
18
17
|
"""Create tables using AsyncEngine (dev/test). Prefer Alembic in prod."""
|
|
@@ -21,9 +20,10 @@ async def init_db_async(model_modules: Sequence[str] | None = None) -> None:
|
|
|
21
20
|
async with engine.begin() as conn:
|
|
22
21
|
await conn.run_sync(Base.metadata.create_all)
|
|
23
22
|
|
|
23
|
+
|
|
24
24
|
# --------- Sync ----------
|
|
25
25
|
def init_db_sync(model_modules: Sequence[str] | None = None) -> None:
|
|
26
26
|
"""Create tables using Sync Engine (dev/test). Prefer Alembic in prod."""
|
|
27
27
|
import_model_modules(model_modules)
|
|
28
28
|
engine = init_sync_engine()
|
|
29
|
-
Base.metadata.create_all(bind=engine)
|
|
29
|
+
Base.metadata.create_all(bind=engine)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
6
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
7
|
+
|
|
8
|
+
from nlbone.core.ports.uow import AsyncUnitOfWork as AsyncUnitOfWorkPort
|
|
9
|
+
from nlbone.core.ports.uow import UnitOfWork
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SqlAlchemyUnitOfWork(UnitOfWork):
|
|
13
|
+
"""sync UoW for SQLAlchemy."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, session_factory: sessionmaker) -> None:
|
|
16
|
+
self._session_factory = session_factory
|
|
17
|
+
self.session: Session | None = None
|
|
18
|
+
|
|
19
|
+
def __enter__(self) -> "SqlAlchemyUnitOfWork":
|
|
20
|
+
self.session = self._session_factory()
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
24
|
+
try:
|
|
25
|
+
if exc_type is None:
|
|
26
|
+
self.commit()
|
|
27
|
+
else:
|
|
28
|
+
self.rollback()
|
|
29
|
+
finally:
|
|
30
|
+
if self.session is not None:
|
|
31
|
+
self.session.close()
|
|
32
|
+
self.session = None
|
|
33
|
+
|
|
34
|
+
def commit(self) -> None:
|
|
35
|
+
if self.session:
|
|
36
|
+
self.session.commit()
|
|
37
|
+
|
|
38
|
+
def rollback(self) -> None:
|
|
39
|
+
if self.session:
|
|
40
|
+
self.session.rollback()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AsyncSqlAlchemyUnitOfWork(AsyncUnitOfWorkPort):
|
|
44
|
+
"""Transactional boundary for async SQLAlchemy."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
|
47
|
+
self._sf = session_factory
|
|
48
|
+
self.session: Optional[AsyncSession] = None
|
|
49
|
+
|
|
50
|
+
async def __aenter__(self) -> "AsyncSqlAlchemyUnitOfWork":
|
|
51
|
+
self.session = self._sf()
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
55
|
+
try:
|
|
56
|
+
if exc_type is None:
|
|
57
|
+
await self.commit()
|
|
58
|
+
else:
|
|
59
|
+
await self.rollback()
|
|
60
|
+
finally:
|
|
61
|
+
if self.session is not None:
|
|
62
|
+
await self.session.close()
|
|
63
|
+
self.session = None
|
|
64
|
+
|
|
65
|
+
async def commit(self) -> None:
|
|
66
|
+
if self.session:
|
|
67
|
+
await self.session.commit()
|
|
68
|
+
|
|
69
|
+
async def rollback(self) -> None:
|
|
70
|
+
if self.session:
|
|
71
|
+
await self.session.rollback()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import json
|
|
3
4
|
from typing import Any, Optional
|
|
4
5
|
from urllib.parse import urlparse, urlunparse
|
|
@@ -6,8 +7,8 @@ from urllib.parse import urlparse, urlunparse
|
|
|
6
7
|
import httpx
|
|
7
8
|
import requests
|
|
8
9
|
|
|
9
|
-
from nlbone.core.ports.files import FileServicePort
|
|
10
10
|
from nlbone.config.settings import get_settings
|
|
11
|
+
from nlbone.core.ports.files import FileServicePort
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class UploadchiError(RuntimeError):
|
|
@@ -28,8 +29,9 @@ def _auth_headers(token: str | None) -> dict[str, str]:
|
|
|
28
29
|
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
def _build_list_query(
|
|
32
|
-
|
|
32
|
+
def _build_list_query(
|
|
33
|
+
limit: int, offset: int, filters: dict[str, Any] | None, sort: list[tuple[str, str]] | None
|
|
34
|
+
) -> dict[str, Any]:
|
|
33
35
|
q: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
34
36
|
if filters:
|
|
35
37
|
q["filters"] = json.dumps(filters)
|
|
@@ -55,8 +57,12 @@ def _normalize_https_base(url: str) -> str:
|
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
class UploadchiClient(FileServicePort):
|
|
58
|
-
def __init__(
|
|
59
|
-
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
base_url: Optional[str] = None,
|
|
63
|
+
timeout_seconds: Optional[float] = None,
|
|
64
|
+
client: httpx.Client | None = None,
|
|
65
|
+
) -> None:
|
|
60
66
|
s = get_settings()
|
|
61
67
|
self._base_url = _normalize_https_base(base_url or str(s.UPLOADCHI_BASE_URL))
|
|
62
68
|
self._timeout = timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS)
|
|
@@ -65,8 +71,9 @@ class UploadchiClient(FileServicePort):
|
|
|
65
71
|
def close(self) -> None:
|
|
66
72
|
self._client.close()
|
|
67
73
|
|
|
68
|
-
def upload_file(
|
|
69
|
-
|
|
74
|
+
def upload_file(
|
|
75
|
+
self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
76
|
+
) -> dict:
|
|
70
77
|
tok = _resolve_token(token)
|
|
71
78
|
files = {"file": (filename, file_bytes)}
|
|
72
79
|
data = (params or {}).copy()
|
|
@@ -77,13 +84,22 @@ class UploadchiClient(FileServicePort):
|
|
|
77
84
|
|
|
78
85
|
def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None:
|
|
79
86
|
tok = _resolve_token(token)
|
|
80
|
-
r = self._client.post(
|
|
81
|
-
|
|
87
|
+
r = self._client.post(
|
|
88
|
+
f"{self._base_url}/{file_id}/commit",
|
|
89
|
+
headers=_auth_headers(tok),
|
|
90
|
+
params={"client_id": client_id} if client_id else None,
|
|
91
|
+
)
|
|
82
92
|
if r.status_code not in (204, 200):
|
|
83
93
|
raise UploadchiError(r.status_code, r.text)
|
|
84
94
|
|
|
85
|
-
def list_files(
|
|
86
|
-
|
|
95
|
+
def list_files(
|
|
96
|
+
self,
|
|
97
|
+
limit: int = 10,
|
|
98
|
+
offset: int = 0,
|
|
99
|
+
filters: dict[str, Any] | None = None,
|
|
100
|
+
sort: list[tuple[str, str]] | None = None,
|
|
101
|
+
token: str | None = None,
|
|
102
|
+
) -> dict:
|
|
87
103
|
tok = _resolve_token(token)
|
|
88
104
|
q = _build_list_query(limit, offset, filters, sort)
|
|
89
105
|
r = self._client.get(self._base_url, params=q, headers=_auth_headers(tok))
|
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Optional
|
|
4
|
+
|
|
3
5
|
import httpx
|
|
4
6
|
|
|
5
|
-
from nlbone.core.ports.files import AsyncFileServicePort
|
|
6
7
|
from nlbone.config.settings import get_settings
|
|
8
|
+
from nlbone.core.ports.files import AsyncFileServicePort
|
|
9
|
+
|
|
7
10
|
from .uploadchi import UploadchiError, _auth_headers, _build_list_query, _filename_from_cd, _resolve_token
|
|
8
11
|
|
|
12
|
+
|
|
9
13
|
class UploadchiAsyncClient(AsyncFileServicePort):
|
|
10
|
-
def __init__(
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
base_url: Optional[str] = None,
|
|
17
|
+
timeout_seconds: Optional[float] = None,
|
|
18
|
+
client: httpx.AsyncClient | None = None,
|
|
19
|
+
) -> None:
|
|
11
20
|
s = get_settings()
|
|
12
21
|
self._base_url = base_url or str(s.UPLOADCHI_BASE_URL)
|
|
13
22
|
self._timeout = timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS)
|
|
14
|
-
self._client = client or httpx.AsyncClient(
|
|
23
|
+
self._client = client or httpx.AsyncClient(
|
|
24
|
+
base_url=self._base_url, timeout=self._timeout, follow_redirects=True
|
|
25
|
+
)
|
|
15
26
|
|
|
16
27
|
async def aclose(self) -> None:
|
|
17
28
|
await self._client.aclose()
|
|
18
29
|
|
|
19
|
-
async def upload_file(
|
|
30
|
+
async def upload_file(
|
|
31
|
+
self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
|
|
32
|
+
) -> dict:
|
|
20
33
|
tok = _resolve_token(token)
|
|
21
34
|
files = {"file": (filename, file_bytes)}
|
|
22
35
|
data = (params or {}).copy()
|
|
@@ -27,11 +40,20 @@ class UploadchiAsyncClient(AsyncFileServicePort):
|
|
|
27
40
|
|
|
28
41
|
async def commit_file(self, file_id: int, client_id: str, token: str | None = None) -> None:
|
|
29
42
|
tok = _resolve_token(token)
|
|
30
|
-
r = await self._client.post(
|
|
43
|
+
r = await self._client.post(
|
|
44
|
+
f"/{file_id}/commit", headers=_auth_headers(tok), params={"client_id": client_id} if client_id else None
|
|
45
|
+
)
|
|
31
46
|
if r.status_code not in (204, 200):
|
|
32
47
|
raise UploadchiError(r.status_code, await r.aread())
|
|
33
48
|
|
|
34
|
-
async def list_files(
|
|
49
|
+
async def list_files(
|
|
50
|
+
self,
|
|
51
|
+
limit: int = 10,
|
|
52
|
+
offset: int = 0,
|
|
53
|
+
filters: dict[str, Any] | None = None,
|
|
54
|
+
sort: list[tuple[str, str]] | None = None,
|
|
55
|
+
token: str | None = None,
|
|
56
|
+
) -> dict:
|
|
35
57
|
tok = _resolve_token(token)
|
|
36
58
|
q = _build_list_query(limit, offset, filters, sort)
|
|
37
59
|
r = await self._client.get("", params=q, headers=_auth_headers(tok))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .event_bus import InMemoryEventBus
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import Callable, Dict, Iterable, List
|
|
5
|
+
|
|
6
|
+
from nlbone.core.domain.base import DomainEvent
|
|
7
|
+
from nlbone.core.ports.event_bus import EventBusPort
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InMemoryEventBus(EventBusPort):
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._handlers: Dict[str, List[Callable[[DomainEvent], None]]] = defaultdict(list)
|
|
13
|
+
|
|
14
|
+
def publish(self, events: Iterable[DomainEvent]) -> None:
|
|
15
|
+
for evt in events:
|
|
16
|
+
for h in self._handlers.get(evt.name, []):
|
|
17
|
+
try:
|
|
18
|
+
h(evt)
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def subscribe(self, event_name: str, handler: Callable[[DomainEvent], None]) -> None:
|
|
23
|
+
self._handlers[event_name].append(handler)
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextvars
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
5
6
|
import sys
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
from typing import Any, MutableMapping, Optional
|
|
8
9
|
|
|
9
|
-
import contextvars
|
|
10
|
-
|
|
11
10
|
from nlbone.config.settings import get_settings
|
|
12
11
|
|
|
13
12
|
# Context variable for request/correlation id
|
|
14
|
-
_request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
15
|
-
"request_id", default=None
|
|
16
|
-
)
|
|
13
|
+
_request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("request_id", default=None)
|
|
17
14
|
|
|
18
15
|
|
|
19
16
|
def set_request_id(request_id: Optional[str]) -> None:
|
|
@@ -97,9 +94,7 @@ def _build_stream_handler(json_enabled: bool, level: int) -> logging.Handler:
|
|
|
97
94
|
handler.setFormatter(JsonFormatter())
|
|
98
95
|
else:
|
|
99
96
|
# human-friendly text format
|
|
100
|
-
fmt = (
|
|
101
|
-
"%(asctime)s | %(levelname)s | %(name)s | rid=%(request_id)s | %(message)s"
|
|
102
|
-
)
|
|
97
|
+
fmt = "%(asctime)s | %(levelname)s | %(name)s | rid=%(request_id)s | %(message)s"
|
|
103
98
|
datefmt = "%Y-%m-%dT%H:%M:%S%z"
|
|
104
99
|
handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
|
|
105
100
|
return handler
|
|
@@ -2,7 +2,8 @@ import os
|
|
|
2
2
|
from functools import lru_cache
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Literal
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
from pydantic import AnyHttpUrl, Field, SecretStr
|
|
6
7
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
8
|
|
|
8
9
|
|
|
@@ -34,9 +35,7 @@ class Settings(BaseSettings):
|
|
|
34
35
|
# App
|
|
35
36
|
# ---------------------------
|
|
36
37
|
PORT: int = 8000
|
|
37
|
-
ENV: Literal["local", "dev", "staging", "prod"] = Field(default="local"
|
|
38
|
-
validation_alias=AliasChoices("NLBONE_ENV", "ENV",
|
|
39
|
-
"ENVIRONMENT"))
|
|
38
|
+
ENV: Literal["local", "dev", "staging", "prod"] = Field(default="local")
|
|
40
39
|
DEBUG: bool = Field(default=False)
|
|
41
40
|
LOG_LEVEL: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = Field(default="INFO")
|
|
42
41
|
LOG_JSON: bool = Field(default=True)
|
|
@@ -49,40 +48,37 @@ class Settings(BaseSettings):
|
|
|
49
48
|
# ---------------------------
|
|
50
49
|
# Keycloak / Auth
|
|
51
50
|
# ---------------------------
|
|
52
|
-
KEYCLOAK_SERVER_URL: AnyHttpUrl = Field(default="https://keycloak.local/auth"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
validation_alias=AliasChoices("NLBONE_KEYCLOAK_REALM_NAME", "KEYCLOAK_REALM_NAME"))
|
|
57
|
-
KEYCLOAK_CLIENT_ID: str = Field(default="nlbone",
|
|
58
|
-
validation_alias=AliasChoices("NLBONE_KEYCLOAK_CLIENT_ID", "KEYCLOAK_CLIENT_ID"))
|
|
59
|
-
KEYCLOAK_CLIENT_SECRET: SecretStr = Field(default=SecretStr("dev-secret"),
|
|
60
|
-
validation_alias=AliasChoices("NLBONE_KEYCLOAK_CLIENT_SECRET",
|
|
61
|
-
"KEYCLOAK_CLIENT_SECRET"))
|
|
51
|
+
KEYCLOAK_SERVER_URL: AnyHttpUrl = Field(default="https://keycloak.local/auth")
|
|
52
|
+
KEYCLOAK_REALM_NAME: str = Field(default="numberland")
|
|
53
|
+
KEYCLOAK_CLIENT_ID: str = Field(default="nlbone")
|
|
54
|
+
KEYCLOAK_CLIENT_SECRET: SecretStr = Field(default=SecretStr("dev-secret"))
|
|
62
55
|
|
|
63
56
|
# ---------------------------
|
|
64
57
|
# Database
|
|
65
58
|
# ---------------------------
|
|
66
|
-
POSTGRES_DB_DSN: str = Field(default="postgresql+asyncpg://user:pass@localhost:5432/nlbone"
|
|
67
|
-
|
|
68
|
-
|
|
59
|
+
POSTGRES_DB_DSN: str = Field(default="postgresql+asyncpg://user:pass@localhost:5432/nlbone")
|
|
60
|
+
DB_ECHO: bool = Field(default=False)
|
|
61
|
+
DB_POOL_SIZE: int = Field(default=5)
|
|
62
|
+
DB_MAX_OVERFLOW: int = Field(default=10)
|
|
69
63
|
|
|
70
64
|
# ---------------------------
|
|
71
65
|
# Messaging / Cache
|
|
72
66
|
# ---------------------------
|
|
73
67
|
REDIS_URL: str = Field(default="redis://localhost:6379/0")
|
|
74
68
|
|
|
69
|
+
# --- Event bus / Outbox ---
|
|
70
|
+
EVENT_BUS_BACKEND: Literal["inmemory"] = Field(default="inmemory")
|
|
71
|
+
OUTBOX_ENABLED: bool = Field(default=False)
|
|
72
|
+
OUTBOX_POLL_INTERVAL_MS: int = Field(default=500)
|
|
73
|
+
|
|
75
74
|
# ---------------------------
|
|
76
75
|
# UPLOADCHI
|
|
77
76
|
# ---------------------------
|
|
78
77
|
UPLOADCHI_BASE_URL: AnyHttpUrl = Field(default="https://uploadchi.numberland.ir/v1/files")
|
|
79
|
-
UPLOADCHI_TOKEN: SecretStr
|
|
80
|
-
default=None,
|
|
81
|
-
validation_alias=AliasChoices("NLBONE_UPLOADCHI_TOKEN", "UPLOADCHI_TOKEN"),
|
|
82
|
-
)
|
|
78
|
+
UPLOADCHI_TOKEN: SecretStr = Field(default="")
|
|
83
79
|
|
|
84
80
|
model_config = SettingsConfigDict(
|
|
85
|
-
env_prefix="
|
|
81
|
+
env_prefix="",
|
|
86
82
|
env_file=None,
|
|
87
83
|
env_file_encoding="utf-8",
|
|
88
84
|
extra="ignore",
|