nlbone 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,15 +43,23 @@ class KeycloakAuthService(AuthService):
43
43
  print(f"Failed to get client token: {e}")
44
44
  return None
45
45
 
46
- def is_client_token(self, token: str, allowed_clients: set[str] | None = None) -> bool:
46
+ def get_client_id(self, token: str):
47
47
  data = self.verify_token(token)
48
48
  if not data:
49
- return False
49
+ return None
50
50
 
51
- is_service_account = bool(data.get("username").startswith('service-account-'))
51
+ is_service_account = bool(data.get("username").startswith("service-account-"))
52
52
  client_id = data.get("client_id")
53
53
 
54
54
  if not is_service_account or not client_id:
55
+ return None
56
+
57
+ return client_id
58
+
59
+ def is_client_token(self, token: str, allowed_clients: set[str] | None = None) -> bool:
60
+ client_id = self.get_client_id(token)
61
+
62
+ if not client_id:
55
63
  return False
56
64
 
57
65
  if allowed_clients is not None and client_id not in allowed_clients:
@@ -62,4 +70,4 @@ class KeycloakAuthService(AuthService):
62
70
  def client_has_access(self, token: str, permissions: list[str], allowed_clients: set[str] | None = None) -> bool:
63
71
  if not self.is_client_token(token, allowed_clients):
64
72
  return False
65
- return self.has_access(token, permissions)
73
+ return self.has_access(token, permissions)
@@ -1,2 +1,3 @@
1
1
  from .sqlalchemy.engine import init_async_engine, async_session, async_ping, init_sync_engine, sync_session, sync_ping
2
- from .sqlalchemy import *
2
+ from .sqlalchemy import get_paginated_response, apply_pagination
3
+ from .sqlalchemy.base import Base
@@ -1,4 +1 @@
1
- from .query import apply_pagination, apply_filters, apply_order
2
- from .base import Base
3
-
4
- __all__ = ["apply_pagination", "apply_filters", "apply_order", "Base"]
1
+ from .query_builder import get_paginated_response, apply_pagination
@@ -1,4 +1,7 @@
1
+ from typing import Union, Callable, Any, Optional, Type, Sequence
2
+
1
3
  from sqlalchemy import asc, desc, or_
4
+ from sqlalchemy.orm.interfaces import LoaderOption
2
5
  from sqlalchemy.sql.sqltypes import (
3
6
  String, Text, Integer, BigInteger, SmallInteger, Numeric, Float, Boolean, Enum as SAEnum
4
7
  )
@@ -170,8 +173,9 @@ def _apply_filters(pagination, entity, query):
170
173
  return query
171
174
 
172
175
 
173
- def apply_pagination(pagination: PaginateRequest, entity, session: Session, limit=True) -> Query:
174
- query = session.query(entity)
176
+ def apply_pagination(pagination: PaginateRequest, entity, session: Session, limit=True, query=None) -> Query:
177
+ if not query:
178
+ query = session.query(entity)
175
179
  query = _apply_filters(pagination, entity, query)
176
180
  query = _apply_order(pagination, entity, query)
177
181
  if limit:
@@ -179,11 +183,74 @@ def apply_pagination(pagination: PaginateRequest, entity, session: Session, limi
179
183
  return query
180
184
 
181
185
 
182
- def get_paginated_response(pagination: PaginateRequest, entity, session: Session, with_count=True) -> PaginateResponse:
183
- query = apply_pagination(pagination, entity, session, not with_count)
186
+ OutputType = Union[type, Callable[[Any], Any], None]
187
+
188
+
189
+ def _serialize_item(item: Any, output_cls: OutputType) -> Any:
190
+ """Serialize a single ORM item based on output_cls (Pydantic v1/v2 or custom mapper)."""
191
+ if output_cls is None:
192
+ return item
193
+
194
+ if callable(output_cls) and not isinstance(output_cls, type):
195
+ return output_cls(item)
196
+
197
+ if hasattr(output_cls, "model_validate"):
198
+ try:
199
+ model = output_cls.model_validate(item, from_attributes=True) # type: ignore[attr-defined]
200
+ if hasattr(model, "model_dump"):
201
+ return model.model_dump() # type: ignore[attr-defined]
202
+ return model
203
+ except Exception:
204
+ pass
205
+
206
+ if hasattr(output_cls, "from_orm"):
207
+ try:
208
+ model = output_cls.from_orm(item) # type: ignore[attr-defined]
209
+ if hasattr(model, "dict"):
210
+ return model.dict() # type: ignore[attr-defined]
211
+ return model
212
+ except Exception:
213
+ pass
214
+
215
+ try:
216
+ obj = output_cls(item) # type: ignore[call-arg]
217
+ try:
218
+ from dataclasses import is_dataclass, asdict
219
+ if is_dataclass(obj):
220
+ return asdict(obj)
221
+ except Exception:
222
+ pass
223
+ return obj
224
+ except Exception:
225
+ return item
226
+
227
+
228
+ def get_paginated_response(
229
+ pagination,
230
+ entity,
231
+ session: Session,
232
+ *,
233
+ with_count: bool = True,
234
+ output_cls: Optional[Type] = None,
235
+ eager_options: Optional[Sequence[LoaderOption]] = None,
236
+ ) -> dict:
237
+ # پایه‌ی کوئری
238
+ query = session.query(entity)
239
+ if eager_options:
240
+ query = query.options(*eager_options)
241
+
242
+ query = apply_pagination(pagination, entity, session, not with_count, query=query)
243
+
184
244
  total_count = None
185
245
  if with_count:
186
246
  total_count = query.count()
187
247
  query = query.limit(pagination.limit).offset(pagination.offset)
188
- return PaginateResponse(total_count=total_count, data=query.all(), limit=pagination.limit,
189
- offset=pagination.offset).to_dict()
248
+
249
+ rows = query.all()
250
+
251
+ if output_cls is not None:
252
+ data = [output_cls.model_validate(r, from_attributes=True).model_dump() for r in rows]
253
+ else:
254
+ data = rows
255
+ return PaginateResponse(total_count=total_count, data=data, limit=pagination.limit,
256
+ offset=pagination.offset).to_dict()
@@ -5,6 +5,20 @@ from nlbone.interfaces.api.exceptions import ForbiddenException, UnauthorizedExc
5
5
  from nlbone.utils.context import current_request
6
6
 
7
7
 
8
+ def current_user_id() -> int:
9
+ user_id = current_request().state.user_id
10
+ if user_id is not None:
11
+ return int(user_id)
12
+ raise UnauthorizedException()
13
+
14
+
15
+ def current_client_id() -> str:
16
+ request = current_request()
17
+ if client_id := KeycloakAuthService().get_client_id(request.state.token):
18
+ return str(client_id)
19
+ raise UnauthorizedException()
20
+
21
+
8
22
  def client_has_access(*, permissions=None):
9
23
  def decorator(func):
10
24
  @functools.wraps(func)
@@ -19,12 +33,10 @@ def client_has_access(*, permissions=None):
19
33
  return decorator
20
34
 
21
35
 
22
-
23
36
  def user_authenticated(func):
24
37
  @functools.wraps(func)
25
38
  async def wrapper(*args, **kwargs):
26
- request = current_request()
27
- if not request.state.user_id:
39
+ if not current_user_id():
28
40
  raise UnauthorizedException()
29
41
  return await func(*args, **kwargs)
30
42
 
@@ -36,7 +48,7 @@ def has_access(*, permissions=None):
36
48
  @functools.wraps(func)
37
49
  def wrapper(*args, **kwargs):
38
50
  request = current_request()
39
- if not request.state.user_id:
51
+ if not current_user_id():
40
52
  raise UnauthorizedException()
41
53
  if not KeycloakAuthService().has_access(request.state.token, permissions=permissions):
42
54
  raise ForbiddenException(f"Forbidden {permissions}")
@@ -44,5 +56,4 @@ def has_access(*, permissions=None):
44
56
  return func(*args, **kwargs)
45
57
 
46
58
  return wrapper
47
- return decorator
48
-
59
+ return decorator
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Optional, Mapping
3
+ from uuid import uuid4
4
+
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.responses import JSONResponse
7
+ from fastapi.exceptions import RequestValidationError
8
+ from pydantic import ValidationError
9
+ from fastapi import HTTPException as FastAPIHTTPException
10
+ from starlette.exceptions import HTTPException as StarletteHTTPException
11
+
12
+ from .exceptions import BaseHttpException
13
+
14
+
15
+ # ---- Helpers ---------------------------------------------------------------
16
+
17
+ def _ensure_trace_id(request: Request) -> str:
18
+ rid = request.headers.get("X-Request-Id") or request.headers.get("X-Trace-Id")
19
+ return rid or str(uuid4())
20
+
21
+
22
+ def _json_response(
23
+ request: Request,
24
+ status_code: int,
25
+ *,
26
+ detail: Any,
27
+ headers: Optional[Mapping[str, str]] = None,
28
+ trace_id: Optional[str] = None,
29
+ extra: Optional[Mapping[str, Any]] = None,
30
+ ) -> JSONResponse:
31
+ payload: dict[str, Any] = {"detail": detail}
32
+ if extra:
33
+ payload.update(extra)
34
+ tid = trace_id or _ensure_trace_id(request)
35
+
36
+ payload.setdefault("trace_id", tid)
37
+
38
+ base_headers = {"X-Trace-Id": tid}
39
+ if headers:
40
+ base_headers.update(headers)
41
+
42
+ return JSONResponse(status_code=status_code, content=payload, headers=base_headers)
43
+
44
+
45
+ # ---- Public Installer ------------------------------------------------------
46
+
47
+ def install_exception_handlers(
48
+ app: FastAPI,
49
+ *,
50
+ logger: Any = None,
51
+ expose_server_errors: bool = False,
52
+ ) -> None:
53
+ @app.exception_handler(BaseHttpException)
54
+ async def _handle_base_http_exception(request: Request, exc: BaseHttpException):
55
+ if logger:
56
+ logger.warning(
57
+ "http_error",
58
+ extra={"status": exc.status_code, "detail": exc.detail, "path": request.url.path},
59
+ )
60
+ return _json_response(request, exc.status_code, detail=exc.detail)
61
+
62
+ @app.exception_handler(FastAPIHTTPException)
63
+ async def _handle_fastapi_http_exception(request: Request, exc: FastAPIHTTPException):
64
+ if logger:
65
+ logger.warning(
66
+ "fastapi_http_error",
67
+ extra={"status": exc.status_code, "detail": exc.detail, "path": request.url.path},
68
+ )
69
+ return _json_response(request, exc.status_code, detail=exc.detail)
70
+
71
+ @app.exception_handler(StarletteHTTPException)
72
+ async def _handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
73
+ if logger:
74
+ logger.warning(
75
+ "starlette_http_error",
76
+ extra={"status": exc.status_code, "detail": exc.detail, "path": request.url.path},
77
+ )
78
+ return _json_response(request, exc.status_code, detail=exc.detail)
79
+
80
+ # 3) خطاهای اعتبارسنجی FastAPI (request body/query/path)
81
+ @app.exception_handler(RequestValidationError)
82
+ async def _handle_request_validation_error(request: Request, exc: RequestValidationError):
83
+ errors = exc.errors()
84
+ if logger:
85
+ logger.info(
86
+ "request_validation_error",
87
+ extra={"errors": errors, "path": request.url.path},
88
+ )
89
+ return _json_response(request, 422, detail=errors)
90
+
91
+ @app.exception_handler(ValidationError)
92
+ async def _handle_pydantic_validation_error(request: Request, exc: ValidationError):
93
+ errors = exc.errors()
94
+ if logger:
95
+ logger.info(
96
+ "pydantic_validation_error",
97
+ extra={"errors": errors, "path": request.url.path},
98
+ )
99
+ return _json_response(request, 422, detail=errors)
100
+
101
+ @app.exception_handler(Exception)
102
+ async def _handle_unexpected_exception(request: Request, exc: Exception):
103
+ tid = _ensure_trace_id(request)
104
+ if logger:
105
+ logger.exception("unhandled_exception", extra={"trace_id": tid, "path": request.url.path})
106
+ detail = str(exc) if expose_server_errors else "internal server error"
107
+ return _json_response(request, 500, detail=detail, trace_id=tid)
@@ -2,66 +2,80 @@ from typing import Any, Iterable
2
2
  from fastapi import HTTPException, status
3
3
 
4
4
 
5
- def _error_entry(loc: Iterable[Any] | None, msg: str, type_: str) -> dict:
5
+ def _error_entry(loc: Iterable[Any] | None, detail: str, type_: str) -> dict:
6
6
  return {
7
7
  "loc": list(loc) if loc else [],
8
- "msg": msg,
8
+ "msg": detail,
9
9
  "type": type_,
10
10
  }
11
11
 
12
12
 
13
- def _errors(loc: Iterable[Any] | None, msg: str, type_: str) -> list[dict]:
14
- return [_error_entry(loc, msg, type_)]
13
+ def _errors(loc: Iterable[Any] | None, detail: str, type_: str) -> list[dict]:
14
+ return [_error_entry(loc, detail, type_)]
15
15
 
16
16
 
17
- class BadRequestException(HTTPException):
18
- def __init__(self, msg: str):
17
+ class BaseHttpException(HTTPException):
18
+ pass
19
+
20
+
21
+ class BadRequestException(BaseHttpException):
22
+ def __init__(self, detail: str):
19
23
  super().__init__(
20
24
  status_code=status.HTTP_400_BAD_REQUEST,
21
- detail=msg,
25
+ detail=detail,
22
26
  )
23
27
 
24
28
 
25
- class UnauthorizedException(HTTPException):
26
- def __init__(self, msg: str = "unauthorized"):
29
+ class UnauthorizedException(BaseHttpException):
30
+ def __init__(self, detail: str = "unauthorized"):
27
31
  super().__init__(
28
32
  status_code=status.HTTP_401_UNAUTHORIZED,
29
- detail=msg,
33
+ detail=detail,
30
34
  )
31
35
 
32
36
 
33
- class ForbiddenException(HTTPException):
34
- def __init__(self, msg: str = "forbidden"):
37
+ class ForbiddenException(BaseHttpException):
38
+ def __init__(self, detail: str = "forbidden"):
35
39
  super().__init__(
36
40
  status_code=status.HTTP_403_FORBIDDEN,
37
- detail=msg,
41
+ detail=detail,
38
42
  )
39
43
 
40
44
 
41
- class NotFoundException(HTTPException):
42
- def __init__(self, msg: str = "not found"):
45
+ class NotFoundException(BaseHttpException):
46
+ def __init__(self, detail: str = "not found"):
43
47
  super().__init__(
44
48
  status_code=status.HTTP_404_NOT_FOUND,
45
- detail=msg,
49
+ detail=detail,
46
50
  )
47
51
 
48
52
 
49
- class ConflictException(HTTPException):
50
- def __init__(self, msg: str = "conflict"):
53
+ class ConflictException(BaseHttpException):
54
+ def __init__(self, detail: str = "conflict"):
51
55
  super().__init__(
52
56
  status_code=status.HTTP_409_CONFLICT,
53
- detail=msg,
57
+ detail=detail,
58
+ )
59
+
60
+
61
+ class NotSupportedException(BaseHttpException):
62
+ def __init__(self, detail: str = "NotSupported"):
63
+ super().__init__(
64
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
65
+ detail=detail,
54
66
  )
55
67
 
56
68
 
57
- class UnprocessableEntityException(HTTPException):
58
- def __init__(self, msg: str, loc: Iterable[Any] | None = None, type_: str = "unprocessable_entity"):
69
+ class UnprocessableEntityException(BaseHttpException):
70
+ def __init__(self, detail: str, loc: Iterable[Any] | None = None, type_: str = "unprocessable_entity"):
59
71
  super().__init__(
60
72
  status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
61
- detail=_errors(loc, msg, type_),
73
+ detail=_errors(loc, detail, type_),
62
74
  )
63
75
 
64
76
 
65
77
  class LogicalValidationException(UnprocessableEntityException):
66
- def __init__(self, msg: str, loc: Iterable[Any] | None = None, type_: str = "logical_error"):
67
- super().__init__(msg=msg, loc=loc, type_=type_)
78
+ def __init__(self, detail: str, loc: Iterable[Any] | None = None, type_: str = "logical_error"):
79
+ super().__init__(detail=detail, loc=loc, type_=type_)
80
+
81
+
@@ -1,3 +1,5 @@
1
+ from typing import Generic, List, Optional, TypeVar
2
+ from pydantic import BaseModel
1
3
  from fastapi import Depends
2
4
 
3
5
  from .offset_base import PaginateResponse, PaginateRequest
@@ -7,3 +9,12 @@ def get_pagination(
7
9
  req: PaginateRequest = Depends(PaginateRequest)
8
10
  ) -> PaginateRequest:
9
11
  return req
12
+
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ class Paginated(BaseModel, Generic[T]):
18
+ total_count: Optional[int]
19
+ total_page: Optional[int]
20
+ data: List[T]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nlbone
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -3,21 +3,15 @@ nlbone/container.py,sha256=KO8y8hfEt0graWhUi-FU_rG-WPckl-uF7H9JGcwEu38,1321
3
3
  nlbone/types.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  nlbone/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  nlbone/adapters/auth/__init__.py,sha256=Eh9kWjY1I8vi17gK0oOzBLJwJX_GFuUcJIN7cLU6lJg,41
6
- nlbone/adapters/auth/keycloak.py,sha256=lPmRmCwBuDj9fUJsGMUCOuk_MsuLBQYBrO3QvBBaV8I,2451
7
- nlbone/adapters/db/__init__.py,sha256=aHur2GuykZd26RpEmIbkAfflkksZuKWAlYLJMIYotQE,144
6
+ nlbone/adapters/auth/keycloak.py,sha256=8UjT1GMenzolR-XAzlKERJDWh3TLBrxaIasMenRYfw4,2614
7
+ nlbone/adapters/db/__init__.py,sha256=VzhE_VOTFxDE0yUOw-f8UCLFCVhdyKnrrhbqzZUy76A,218
8
8
  nlbone/adapters/db/memory.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  nlbone/adapters/db/postgres.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- nlbone/adapters/db/query_builder.py,sha256=xrhBiWRSsyLHzGft0weHwqRPOiP2i6ajqcxJOruHOF4,6606
11
- nlbone/adapters/db/sqlalchemy/__init__.py,sha256=REK4P8SF6kvDNYgkZIbSowhHPivyFVnMCYGfYT1dlxk,158
10
+ nlbone/adapters/db/sqlalchemy/__init__.py,sha256=1yw3keC1GYqQ6AM4283if_TpeB0xbm0-FBD7XUf5bcg,67
12
11
  nlbone/adapters/db/sqlalchemy/base.py,sha256=-5FnCMKETJ2xykhViHQuNBdbRMaxuieuatgiEl4Lllw,73
13
12
  nlbone/adapters/db/sqlalchemy/engine.py,sha256=m3nVY7KU12ksM3vK9fEgYEobrz9L8hfsgjI8cSNtBMY,3030
13
+ nlbone/adapters/db/sqlalchemy/query_builder.py,sha256=mgj0mE0tbBVOAm1nd2nfR3p4EgV_NPYASsfP_4lF6yA,8504
14
14
  nlbone/adapters/db/sqlalchemy/schema.py,sha256=iiE42UT-DJh-ohezLFBWTBFN5WtrfZdtKToQDNLvoOs,1044
15
- nlbone/adapters/db/sqlalchemy/query/__init__.py,sha256=9SFkoNkklaji6kZt_Nl6X6vqMFE92GiqW36Zezr5PuE,129
16
- nlbone/adapters/db/sqlalchemy/query/builder.py,sha256=AzUX2MlfrMGY-EITzVJoq9naM5sTL9h9yzI7cCeEBRk,886
17
- nlbone/adapters/db/sqlalchemy/query/coercion.py,sha256=uYClfEuew_W4r-vbwj2c5MVpayejhpOMl6h4iUrMotQ,2142
18
- nlbone/adapters/db/sqlalchemy/query/filters.py,sha256=GL6r2l8zY-9Ys6Ge6slI0FTZ8NUrYZzbvd5OwHFyJCg,2421
19
- nlbone/adapters/db/sqlalchemy/query/ordering.py,sha256=THbuxZmoFZzJIa28KfDQ6ioS88MVvwLBb9iIj6QnEGM,490
20
- nlbone/adapters/db/sqlalchemy/query/types.py,sha256=M2j6SOSyFkLuyXq4kvPYgZQYz5TKCBe3osoIIN1yfBI,287
21
15
  nlbone/adapters/http_clients/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
16
  nlbone/adapters/http_clients/email_gateway.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
17
  nlbone/adapters/http_clients/uploadchi.py,sha256=qguGDQ2NGNSN3BOFSFUINn8mKS4LI23UGURJNhDUH2E,4647
@@ -43,17 +37,18 @@ nlbone/core/ports/messaging.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuF
43
37
  nlbone/core/ports/repo.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
38
  nlbone/interfaces/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
39
  nlbone/interfaces/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
- nlbone/interfaces/api/exceptions.py,sha256=mw0nZHO2oKml3-d7cdSBTQeVGKFQCjLn_TJy6rxcrU0,1901
40
+ nlbone/interfaces/api/exception_handlers.py,sha256=Z3_dTGAmpHfgaMe_HTW89OpRtq-aamzaMTNkMIOdW3w,4092
41
+ nlbone/interfaces/api/exceptions.py,sha256=uJWNEu5-cgoMedYebNHuIFJioXl_fnBhO89E6FINT2A,2259
47
42
  nlbone/interfaces/api/routers.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
43
  nlbone/interfaces/api/schemas.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
44
  nlbone/interfaces/api/dependencies/__init__.py,sha256=XrTGkHS8xqryfyr8XSm_s9sIzp4E0b44XQ40sVWcKMY,46
50
- nlbone/interfaces/api/dependencies/auth.py,sha256=XwzKga9GkcvCKUg37zEnVw8m30MbR-4wh1CTeLG2jH8,1445
45
+ nlbone/interfaces/api/dependencies/auth.py,sha256=GHUlZ5L2N6ilOaOJqRicHrORB3AD3z2Y-qrO9Q2dNr4,1774
51
46
  nlbone/interfaces/api/dependencies/db.py,sha256=IqDVq1lcCCxd22FBUg523lVANM_j71BYAQtsbrHc4M8,465
52
47
  nlbone/interfaces/api/middleware/__init__.py,sha256=Xcxg9Oy8uToPXaTSdBTKhst-hZwsaIEhqxx4mmo1bZI,157
53
48
  nlbone/interfaces/api/middleware/access_log.py,sha256=dEjk_m4fyQ72S2xLzDydDoaw2F9Tvmfl_acat1YThE0,972
54
49
  nlbone/interfaces/api/middleware/add_request_context.py,sha256=i8EV4BvZyrBcNfU-uTkybr7J7ypYvJq8mXSntM6oel4,1816
55
50
  nlbone/interfaces/api/middleware/authentication.py,sha256=-jfV-Ry04qsFd1cJh7KGoGpfnUKIogPF2fK42ZTFgBk,1806
56
- nlbone/interfaces/api/pagination/__init__.py,sha256=dduTd8cOUnKOipqpbsi5gANXKsD3uUttdzu4S5ObS_U,203
51
+ nlbone/interfaces/api/pagination/__init__.py,sha256=zrfmxpaeYoAQ5AjX-AKivs8Zq5Qbr4hJmGzQU4UIN14,426
57
52
  nlbone/interfaces/api/pagination/offset_base.py,sha256=2MINXX4wXoynOTPgn28lp_UQufBUluLh_qjF-5AYFxk,3655
58
53
  nlbone/interfaces/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
54
  nlbone/interfaces/cli/init_db.py,sha256=6Q05gwcPa6ywwz3Dzi0hiV8bycg0zU_3eWGsL6VNVRk,479
@@ -63,7 +58,7 @@ nlbone/interfaces/jobs/sync_tokens.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
63
58
  nlbone/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
64
59
  nlbone/utils/context.py,sha256=AUiN1jM0ebNMopZQoJSqWTfUHuVrp-HV8x6g7QsbEJ8,1601
65
60
  nlbone/utils/time.py,sha256=dC0ucyAmHdNf3wpA_JPinl2VJRubWqx2vcRpJsT3-0k,102
66
- nlbone-0.2.0.dist-info/METADATA,sha256=VVTESRwO-HZwMfXeb_EszCLS52Jv7y-zXWC54Wtmt8w,2298
67
- nlbone-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
68
- nlbone-0.2.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
69
- nlbone-0.2.0.dist-info/RECORD,,
61
+ nlbone-0.3.1.dist-info/METADATA,sha256=WJBTZ953uoswagIj1JRD-iZnykfYhQqjEFRogKc56ZU,2298
62
+ nlbone-0.3.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
63
+ nlbone-0.3.1.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
64
+ nlbone-0.3.1.dist-info/RECORD,,
@@ -1,3 +0,0 @@
1
- from .builder import apply_pagination, apply_filters, apply_order
2
-
3
- __all__ = ["apply_pagination", "apply_filters", "apply_order"]
@@ -1,21 +0,0 @@
1
- from __future__ import annotations
2
- from sqlalchemy.orm import Session, Query
3
-
4
- from nlbone.adapters.db.sqlalchemy.query.filters import apply_filters_to_query
5
- from nlbone.adapters.db.sqlalchemy.query.ordering import apply_order_to_query
6
-
7
-
8
- def apply_pagination(pagination, entity, session: Session, limit: bool = True) -> Query:
9
- q: Query = session.query(entity)
10
- q = apply_filters_to_query(pagination, entity, q)
11
- from nlbone.adapters.db.sqlalchemy.query.ordering import apply_order_to_query
12
- q = apply_order_to_query(pagination, entity, q)
13
- if limit:
14
- q = q.limit(pagination.limit).offset(pagination.offset)
15
- return q
16
-
17
- def apply_filters(pagination, entity, query: Query) -> Query:
18
- return apply_filters_to_query(pagination, entity, query)
19
-
20
- def apply_order(pagination, entity, query: Query) -> Query:
21
- return apply_order_to_query(pagination, entity, query)
@@ -1,64 +0,0 @@
1
- from sqlalchemy.sql.sqltypes import (
2
- String, Text, Integer, BigInteger, SmallInteger, Numeric, Float, Boolean, Enum as SAEnum
3
- )
4
- try:
5
- from sqlalchemy.dialects.postgresql import ENUM as PGEnum
6
- except Exception:
7
- PGEnum = type("PGEnum", (), {})
8
-
9
- from .types import InvalidEnum
10
-
11
- def is_text_type(coltype) -> bool:
12
- return isinstance(coltype, (String, Text))
13
-
14
- def looks_like_wildcard(s: str) -> bool:
15
- return isinstance(s, str) and ("*" in s or "%" in s)
16
-
17
- def to_like_pattern(s: str) -> str:
18
- s = (s or "")
19
- s = s.replace("*", "%")
20
- return s if "%" in s else f"%{s}%"
21
-
22
- def _coerce_enum(col_type, raw):
23
- if raw is None:
24
- return None
25
- enum_cls = getattr(col_type, "enum_class", None)
26
- if enum_cls is not None:
27
- if isinstance(raw, enum_cls):
28
- return raw
29
- if isinstance(raw, str):
30
- low = raw.strip().lower()
31
- for m in enum_cls:
32
- if m.name.lower() == low or str(m.value).lower() == low:
33
- return m
34
- raise InvalidEnum(f"'{raw}' is not one of {[m.name for m in enum_cls]}")
35
- choices = list(getattr(col_type, "enums", []) or [])
36
- if isinstance(raw, str):
37
- low = raw.strip().lower()
38
- for c in choices:
39
- if c.lower() == low:
40
- return c
41
- raise InvalidEnum(f"'{raw}' is not one of {choices or '[no choices defined]'}")
42
-
43
- def coerce_value_for_column(coltype, v):
44
- if v is None:
45
- return None
46
- if isinstance(coltype, (SAEnum, PGEnum)):
47
- return _coerce_enum(coltype, v)
48
- if is_text_type(coltype):
49
- return str(v)
50
- if isinstance(coltype, (Integer, BigInteger, SmallInteger)):
51
- return int(v)
52
- if isinstance(coltype, (Float, Numeric)):
53
- return float(v)
54
- if isinstance(coltype, Boolean):
55
- if isinstance(v, bool):
56
- return v
57
- if isinstance(v, (int, float)):
58
- return bool(v)
59
- if isinstance(v, str):
60
- vl = v.strip().lower()
61
- if vl in {"true", "1", "yes", "y", "t"}: return True
62
- if vl in {"false", "0", "no", "n", "f"}: return False
63
- return None
64
- return v
@@ -1,57 +0,0 @@
1
- from __future__ import annotations
2
- from sqlalchemy import or_
3
- from sqlalchemy.orm import Query
4
-
5
- try:
6
- from sqlalchemy.dialects.postgresql import ENUM as PGEnum # اختیاری
7
- except Exception:
8
- PGEnum = type("PGEnum", (), {}) # fallback dummy
9
-
10
- from nlbone.interfaces.api.exceptions import UnprocessableEntityException
11
- from nlbone.interfaces.api.pagination import PaginateRequest
12
- from .coercion import coerce_value_for_column, to_like_pattern, is_text_type, looks_like_wildcard
13
- from .types import NULL_SENTINELS, InvalidEnum, parse_field_and_op
14
-
15
- def apply_filters_to_query(pagination: PaginateRequest, entity, query: Query) -> Query:
16
- if not getattr(pagination, "filters", None):
17
- return query
18
-
19
- for raw_field, value in pagination.filters.items():
20
- if value is None or value in NULL_SENTINELS or value == [] or value == {}:
21
- value = None
22
-
23
- field, op_hint = parse_field_and_op(raw_field)
24
- if not hasattr(entity, field):
25
- continue
26
-
27
- col = getattr(entity, field)
28
- coltype = getattr(col, "type", None)
29
-
30
- def _use_ilike(v) -> bool:
31
- if op_hint == "ilike":
32
- return True
33
- return is_text_type(coltype) and isinstance(v, str) and looks_like_wildcard(v)
34
-
35
- try:
36
- if isinstance(value, (list, tuple, set)):
37
- vals = [v for v in value if v not in (None, "", "null", "None")]
38
- if not vals:
39
- continue
40
- if any(_use_ilike(v) for v in vals) and is_text_type(coltype):
41
- patterns = [to_like_pattern(str(v)) for v in vals]
42
- query = query.filter(or_(*[col.ilike(p) for p in patterns]))
43
- else:
44
- coerced = [coerce_value_for_column(coltype, v) for v in vals]
45
- if not coerced:
46
- continue
47
- query = query.filter(col.in_(coerced))
48
- else:
49
- if _use_ilike(value) and is_text_type(coltype):
50
- query = query.filter(col.ilike(to_like_pattern(str(value))))
51
- else:
52
- v = coerce_value_for_column(coltype, value)
53
- query = query.filter(col.is_(None) if v is None else (col == v))
54
- except InvalidEnum as e:
55
- raise UnprocessableEntityException(str(e), loc=["query", "filters", raw_field]) from e
56
-
57
- return query
@@ -1,14 +0,0 @@
1
- from sqlalchemy import asc, desc
2
- from sqlalchemy.orm import Query
3
-
4
- def apply_order_to_query(pagination, entity, query: Query) -> Query:
5
- if not pagination.sort:
6
- return query
7
- clauses = []
8
- for s in pagination.sort:
9
- field = s["field"]
10
- order = s["order"]
11
- if hasattr(entity, field):
12
- col = getattr(entity, field)
13
- clauses.append(asc(col) if order == "asc" else desc(col))
14
- return query.order_by(*clauses) if clauses else query
@@ -1,11 +0,0 @@
1
- NULL_SENTINELS = {"None", "null", ""}
2
-
3
- class InvalidEnum(Exception):
4
- pass
5
-
6
- def parse_field_and_op(field: str) -> tuple[str, str]:
7
- if "__" in field:
8
- base, op = field.rsplit("__", 1)
9
- if op.lower() == "ilike":
10
- return base, "ilike"
11
- return field, "eq"
File without changes