nlbone 0.5.0__py3-none-any.whl → 0.6.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. nlbone/adapters/__init__.py +1 -0
  2. nlbone/adapters/auth/keycloak.py +1 -1
  3. nlbone/adapters/auth/token_provider.py +1 -1
  4. nlbone/adapters/cache/async_redis.py +18 -8
  5. nlbone/adapters/cache/memory.py +21 -11
  6. nlbone/adapters/cache/pubsub_listener.py +3 -0
  7. nlbone/adapters/cache/redis.py +23 -8
  8. nlbone/adapters/db/__init__.py +0 -1
  9. nlbone/adapters/db/postgres/audit.py +14 -11
  10. nlbone/adapters/db/redis/client.py +1 -4
  11. nlbone/adapters/http_clients/__init__.py +2 -0
  12. nlbone/adapters/http_clients/pricing/__init__.py +1 -0
  13. nlbone/adapters/http_clients/pricing/pricing_service.py +100 -0
  14. nlbone/adapters/http_clients/uploadchi/__init__.py +2 -0
  15. nlbone/adapters/http_clients/{uploadchi.py → uploadchi/uploadchi.py} +22 -46
  16. nlbone/adapters/http_clients/{uploadchi_async.py → uploadchi/uploadchi_async.py} +23 -23
  17. nlbone/adapters/percolation/__init__.py +1 -1
  18. nlbone/adapters/percolation/connection.py +2 -1
  19. nlbone/config/logging.py +54 -24
  20. nlbone/config/settings.py +20 -12
  21. nlbone/container.py +12 -6
  22. nlbone/core/application/base_worker.py +1 -1
  23. nlbone/core/domain/models.py +4 -2
  24. nlbone/core/ports/cache.py +25 -9
  25. nlbone/interfaces/api/dependencies/auth.py +26 -0
  26. nlbone/interfaces/cli/init_db.py +1 -1
  27. nlbone/interfaces/cli/main.py +6 -5
  28. nlbone/utils/cache.py +10 -0
  29. nlbone/utils/cache_keys.py +6 -0
  30. nlbone/utils/cache_registry.py +5 -2
  31. nlbone/utils/http.py +29 -0
  32. nlbone/utils/redactor.py +2 -1
  33. nlbone/utils/time.py +1 -1
  34. {nlbone-0.5.0.dist-info → nlbone-0.6.8.dist-info}/METADATA +1 -1
  35. {nlbone-0.5.0.dist-info → nlbone-0.6.8.dist-info}/RECORD +38 -35
  36. nlbone/adapters/http_clients/email_gateway.py +0 -0
  37. {nlbone-0.5.0.dist-info → nlbone-0.6.8.dist-info}/WHEEL +0 -0
  38. {nlbone-0.5.0.dist-info → nlbone-0.6.8.dist-info}/entry_points.txt +0 -0
  39. {nlbone-0.5.0.dist-info → nlbone-0.6.8.dist-info}/licenses/LICENSE +0 -0
@@ -4,20 +4,20 @@ from typing import Any, AsyncIterator, Optional
4
4
 
5
5
  import httpx
6
6
 
7
+ from nlbone.adapters.auth.token_provider import ClientTokenProvider
8
+ from nlbone.adapters.http_clients.uploadchi.uploadchi import UploadchiError, _filename_from_cd, _resolve_token
7
9
  from nlbone.config.settings import get_settings
8
10
  from nlbone.core.ports.files import AsyncFileServicePort
9
-
10
- from .uploadchi import UploadchiError, _auth_headers, _build_list_query, _filename_from_cd, _resolve_token
11
- from ..auth.token_provider import ClientTokenProvider
11
+ from nlbone.utils.http import auth_headers, build_list_query
12
12
 
13
13
 
14
14
  class UploadchiAsyncClient(AsyncFileServicePort):
15
15
  def __init__(
16
- self,
17
- token_provider: ClientTokenProvider | None = None,
18
- base_url: Optional[str] = None,
19
- timeout_seconds: Optional[float] = None,
20
- client: httpx.AsyncClient | None = None,
16
+ self,
17
+ token_provider: ClientTokenProvider | None = None,
18
+ base_url: Optional[str] = None,
19
+ timeout_seconds: Optional[float] = None,
20
+ client: httpx.AsyncClient | None = None,
21
21
  ) -> None:
22
22
  s = get_settings()
23
23
  self._base_url = base_url or str(s.UPLOADCHI_BASE_URL)
@@ -31,12 +31,12 @@ class UploadchiAsyncClient(AsyncFileServicePort):
31
31
  await self._client.aclose()
32
32
 
33
33
  async def upload_file(
34
- self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
34
+ self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
35
35
  ) -> dict:
36
36
  tok = _resolve_token(token)
37
37
  files = {"file": (filename, file_bytes)}
38
38
  data = (params or {}).copy()
39
- r = await self._client.post("", files=files, data=data, headers=_auth_headers(tok))
39
+ r = await self._client.post("", files=files, data=data, headers=auth_headers(tok))
40
40
  if r.status_code >= 400:
41
41
  raise UploadchiError(r.status_code, await r.aread())
42
42
  return r.json()
@@ -46,7 +46,7 @@ class UploadchiAsyncClient(AsyncFileServicePort):
46
46
  raise UploadchiError(detail="token_provider is not provided", status=400)
47
47
  tok = _resolve_token(token)
48
48
  r = await self._client.post(
49
- f"/{file_id}/commit", headers=_auth_headers(tok or self._token_provider.get_access_token())
49
+ f"/{file_id}/commit", headers=auth_headers(tok or self._token_provider.get_access_token())
50
50
  )
51
51
  if r.status_code not in (204, 200):
52
52
  raise UploadchiError(r.status_code, await r.aread())
@@ -56,36 +56,36 @@ class UploadchiAsyncClient(AsyncFileServicePort):
56
56
  raise UploadchiError(detail="token_provider is not provided", status=400)
57
57
  tok = _resolve_token(token)
58
58
  r = await self._client.post(
59
- f"/{file_id}/rollback", headers=_auth_headers(tok or self._token_provider.get_access_token())
59
+ f"/{file_id}/rollback", headers=auth_headers(tok or self._token_provider.get_access_token())
60
60
  )
61
61
  if r.status_code not in (204, 200):
62
62
  raise UploadchiError(r.status_code, await r.aread())
63
63
 
64
64
  async def list_files(
65
- self,
66
- limit: int = 10,
67
- offset: int = 0,
68
- filters: dict[str, Any] | None = None,
69
- sort: list[tuple[str, str]] | None = None,
70
- token: str | None = None,
65
+ self,
66
+ limit: int = 10,
67
+ offset: int = 0,
68
+ filters: dict[str, Any] | None = None,
69
+ sort: list[tuple[str, str]] | None = None,
70
+ token: str | None = None,
71
71
  ) -> dict:
72
72
  tok = _resolve_token(token)
73
- q = _build_list_query(limit, offset, filters, sort)
74
- r = await self._client.get("", params=q, headers=_auth_headers(tok))
73
+ q = build_list_query(limit, offset, filters, sort)
74
+ r = await self._client.get("", params=q, headers=auth_headers(tok))
75
75
  if r.status_code >= 400:
76
76
  raise UploadchiError(r.status_code, await r.aread())
77
77
  return r.json()
78
78
 
79
79
  async def get_file(self, file_id: str, token: str | None = None) -> dict:
80
80
  tok = _resolve_token(token)
81
- r = await self._client.get(f"/{file_id}", headers=_auth_headers(tok))
81
+ r = await self._client.get(f"/{file_id}", headers=auth_headers(tok))
82
82
  if r.status_code >= 400:
83
83
  raise UploadchiError(r.status_code, await r.aread())
84
84
  return r.json()
85
85
 
86
86
  async def download_file(self, file_id: str, token: str | None = None) -> tuple[AsyncIterator[bytes], str, str]:
87
87
  tok = _resolve_token(token)
88
- r = await self._client.get(f"/{file_id}/download", headers=_auth_headers(tok), stream=True)
88
+ r = await self._client.get(f"/{file_id}/download", headers=auth_headers(tok), stream=True)
89
89
  if r.status_code >= 400:
90
90
  body = await r.aread()
91
91
  raise UploadchiError(r.status_code, body.decode(errors="ignore"))
@@ -103,7 +103,7 @@ class UploadchiAsyncClient(AsyncFileServicePort):
103
103
 
104
104
  async def delete_file(self, file_id: str, token: str | None = None) -> None:
105
105
  tok = _resolve_token(token)
106
- r = await self._client.delete(f"/{file_id}", headers=_auth_headers(tok))
106
+ r = await self._client.delete(f"/{file_id}", headers=auth_headers(tok))
107
107
  if r.status_code not in (204, 200):
108
108
  body = await r.aread()
109
109
  raise UploadchiError(r.status_code, body.decode(errors="ignore"))
@@ -1 +1 @@
1
- from .connection import get_es_client
1
+ from .connection import get_es_client
@@ -4,9 +4,10 @@ from nlbone.config.settings import get_settings
4
4
 
5
5
  setting = get_settings()
6
6
 
7
+
7
8
  def get_es_client():
8
9
  es = Elasticsearch(
9
10
  setting.ELASTIC_PERCOLATE_URL,
10
- basic_auth=(setting.ELASTIC_PERCOLATE_USER, setting.ELASTIC_PERCOLATE_PASS.get_secret_value().strip())
11
+ basic_auth=(setting.ELASTIC_PERCOLATE_USER, setting.ELASTIC_PERCOLATE_PASS.get_secret_value().strip()),
11
12
  )
12
13
  return es
nlbone/config/logging.py CHANGED
@@ -11,6 +11,7 @@ from nlbone.utils.redactor import PiiRedactor
11
11
 
12
12
  settings = get_settings()
13
13
 
14
+
14
15
  # ---------- Filters ----------
15
16
  class ContextFilter(logging.Filter):
16
17
  def filter(self, record: logging.LogRecord) -> bool:
@@ -21,12 +22,32 @@ class ContextFilter(logging.Filter):
21
22
  record.user_agent = ctx.get("user_agent")
22
23
  return True
23
24
 
25
+
24
26
  # ---------- Formatter ----------
25
27
  class JsonFormatter(logging.Formatter):
26
28
  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",
29
+ "args",
30
+ "asctime",
31
+ "created",
32
+ "exc_info",
33
+ "exc_text",
34
+ "filename",
35
+ "funcName",
36
+ "levelname",
37
+ "levelno",
38
+ "lineno",
39
+ "module",
40
+ "msecs",
41
+ "message",
42
+ "msg",
43
+ "name",
44
+ "pathname",
45
+ "process",
46
+ "processName",
47
+ "relativeCreated",
48
+ "stack_info",
49
+ "thread",
50
+ "threadName",
30
51
  }
31
52
 
32
53
  def format(self, record: logging.LogRecord) -> str:
@@ -53,17 +74,23 @@ class JsonFormatter(logging.Formatter):
53
74
 
54
75
  return json.dumps(payload, ensure_ascii=False)
55
76
 
77
+
56
78
  class PlainFormatter(logging.Formatter):
57
79
  def __init__(self):
58
80
  super().__init__(
59
- fmt="%(asctime)s | %(levelname)s | %(name)s | "
60
- "req=%(request_id)s user=%(user_id)s ip=%(ip)s | %(message)s",
81
+ fmt="%(asctime)s | %(levelname)s | %(name)s | req=%(request_id)s user=%(user_id)s ip=%(ip)s | %(message)s",
61
82
  datefmt="%Y-%m-%dT%H:%M:%S%z",
62
83
  )
63
84
 
85
+
64
86
  # ---------- 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):
87
+ def setup_logging(
88
+ *,
89
+ log_json: bool = settings.LOG_JSON,
90
+ log_level: str = settings.LOG_LEVEL,
91
+ log_file: str | None = None,
92
+ silence_uvicorn_access: bool = True,
93
+ ):
67
94
  handlers = {
68
95
  "console": {
69
96
  "class": "logging.StreamHandler",
@@ -82,23 +109,25 @@ def setup_logging(*, log_json: bool=settings.LOG_JSON, log_level: str =settings.
82
109
  "formatter": "json" if log_json else "plain",
83
110
  }
84
111
 
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
- })
112
+ dictConfig(
113
+ {
114
+ "version": 1,
115
+ "disable_existing_loggers": False,
116
+ "filters": {
117
+ "ctx": {"()": ContextFilter},
118
+ "pii": {"()": PiiRedactor},
119
+ },
120
+ "formatters": {
121
+ "json": {"()": JsonFormatter},
122
+ "plain": {"()": PlainFormatter},
123
+ },
124
+ "handlers": handlers,
125
+ "root": {
126
+ "level": log_level,
127
+ "handlers": list(handlers.keys()),
128
+ },
129
+ }
130
+ )
102
131
 
103
132
  logging.getLogger("asyncio").setLevel(logging.WARNING)
104
133
  logging.getLogger("httpx").setLevel(logging.WARNING)
@@ -115,5 +144,6 @@ def setup_logging(*, log_json: bool=settings.LOG_JSON, log_level: str =settings.
115
144
  uvicorn_access.handlers = []
116
145
  uvicorn_access.propagate = True
117
146
 
147
+
118
148
  def get_logger(name: str | None = None) -> logging.Logger:
119
149
  return logging.getLogger(name or "app")
nlbone/config/settings.py CHANGED
@@ -8,19 +8,22 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
8
8
 
9
9
 
10
10
  def _guess_env_file() -> str | None:
11
- explicit = os.getenv("NLBONE_ENV_FILE")
12
- if explicit:
13
- return explicit
11
+ try:
12
+ explicit = os.getenv("NLBONE_ENV_FILE")
13
+ if explicit:
14
+ return explicit
14
15
 
15
- cwd_env = Path.cwd() / ".env"
16
- if cwd_env.exists():
17
- return str(cwd_env)
16
+ cwd_env = Path.cwd() / ".env"
17
+ if cwd_env.exists():
18
+ return str(cwd_env)
18
19
 
19
- for i in range(0, 8):
20
- p = Path.cwd().resolve().parents[i]
21
- f = p / ".env"
22
- if f.exists():
23
- return str(f)
20
+ for i in range(0, 8):
21
+ p = Path.cwd().resolve().parents[i]
22
+ f = p / ".env"
23
+ if f.exists():
24
+ return str(f)
25
+ except Exception as e:
26
+ raise Exception("Failed to guess env file path!") from e
24
27
 
25
28
 
26
29
  def is_production_env() -> bool:
@@ -82,10 +85,15 @@ class Settings(BaseSettings):
82
85
  # ---------------------------
83
86
  # PERCOLATE
84
87
  # ---------------------------
85
- ELASTIC_PERCOLATE_URL: str = Field(default="http://localhost:9200")
88
+ ELASTIC_PERCOLATE_URL: str = Field(default="http://localhost:9200")
86
89
  ELASTIC_PERCOLATE_USER: str = Field(default="")
87
90
  ELASTIC_PERCOLATE_PASS: SecretStr = Field(default="")
88
91
 
92
+ # ---------------------------
93
+ # Pricing
94
+ # ---------------------------
95
+ PRICING_SERVICE_URL: AnyHttpUrl = Field(default="https://pricing.numberland.ir/v1")
96
+
89
97
  model_config = SettingsConfigDict(
90
98
  env_prefix="",
91
99
  env_file=None,
nlbone/container.py CHANGED
@@ -11,11 +11,12 @@ from nlbone.adapters.cache.memory import InMemoryCache
11
11
  from nlbone.adapters.cache.redis import RedisCache
12
12
  from nlbone.adapters.db.postgres import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
13
13
  from nlbone.adapters.db.postgres.engine import get_async_session_factory, get_sync_session_factory
14
+ from nlbone.adapters.http_clients import PricingService
14
15
  from nlbone.adapters.http_clients.uploadchi import UploadchiClient
15
- from nlbone.adapters.http_clients.uploadchi_async import UploadchiAsyncClient
16
+ from nlbone.adapters.http_clients.uploadchi.uploadchi_async import UploadchiAsyncClient
16
17
  from nlbone.adapters.messaging import InMemoryEventBus
17
18
  from nlbone.core.ports import EventBusPort
18
- from nlbone.core.ports.cache import CachePort, AsyncCachePort
19
+ from nlbone.core.ports.cache import AsyncCachePort, CachePort
19
20
  from nlbone.core.ports.files import AsyncFileServicePort, FileServicePort
20
21
 
21
22
 
@@ -35,10 +36,15 @@ class Container(containers.DeclarativeContainer):
35
36
  # --- Services ---
36
37
  auth: providers.Singleton[KeycloakAuthService] = providers.Singleton(KeycloakAuthService, settings=config)
37
38
  token_provider = providers.Singleton(ClientTokenProvider, auth=auth, skew_seconds=30)
38
- file_service: providers.Singleton[FileServicePort] = providers.Singleton(UploadchiClient,
39
- token_provider=token_provider)
40
- afiles_service: providers.Singleton[AsyncFileServicePort] = providers.Singleton(UploadchiAsyncClient,
41
- token_provider=token_provider)
39
+ file_service: providers.Singleton[FileServicePort] = providers.Singleton(
40
+ UploadchiClient, token_provider=token_provider
41
+ )
42
+ afiles_service: providers.Singleton[AsyncFileServicePort] = providers.Singleton(
43
+ UploadchiAsyncClient, token_provider=token_provider
44
+ )
45
+ pricing_service: providers.Singleton[PricingService] = providers.Singleton(
46
+ PricingService, token_provider=token_provider
47
+ )
42
48
 
43
49
  cache: providers.Singleton[CachePort] = providers.Selector(
44
50
  config.CACHE_BACKEND,
@@ -33,4 +33,4 @@ class BaseWorker(ABC):
33
33
 
34
34
  @abstractmethod
35
35
  async def process(self, *args, **kwargs) -> Any:
36
- pass
36
+ pass
@@ -1,7 +1,8 @@
1
1
  import uuid
2
2
  from datetime import datetime
3
- from sqlalchemy import String, DateTime, Index, Text
3
+
4
4
  from sqlalchemy import JSON as SA_JSON
5
+ from sqlalchemy import DateTime, Index, String, Text
5
6
  from sqlalchemy.orm import Mapped, mapped_column
6
7
  from sqlalchemy.sql import func
7
8
 
@@ -9,6 +10,7 @@ from nlbone.adapters.db import Base
9
10
 
10
11
  try:
11
12
  from sqlalchemy.dialects.postgresql import JSONB, UUID
13
+
12
14
  JSONType = JSONB
13
15
  UUIDType = UUID(as_uuid=True)
14
16
  except Exception:
@@ -35,4 +37,4 @@ class AuditLog(Base):
35
37
  __table_args__ = (
36
38
  Index("ix_audit_entity_entityid", "entity", "entity_id"),
37
39
  Index("ix_audit_created_at", "created_at"),
38
- )
40
+ )
@@ -1,37 +1,53 @@
1
- from typing import Protocol, Optional, Iterable, Any, Mapping, Sequence, Tuple, TypeVar, Callable
1
+ from typing import Any, Callable, Iterable, Mapping, Optional, Protocol, Sequence, TypeVar
2
2
 
3
3
  T = TypeVar("T")
4
4
 
5
+
5
6
  class CachePort(Protocol):
6
7
  def get(self, key: str) -> Optional[bytes]: ...
7
- def set(self, key: str, value: bytes, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None: ...
8
+ def set(
9
+ self, key: str, value: bytes, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
10
+ ) -> None: ...
8
11
  def delete(self, key: str) -> None: ...
9
12
  def exists(self, key: str) -> bool: ...
10
13
  def ttl(self, key: str) -> Optional[int]: ...
11
14
 
12
15
  def mget(self, keys: Sequence[str]) -> list[Optional[bytes]]: ...
13
- def mset(self, items: Mapping[str, bytes], *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None: ...
16
+ def mset(
17
+ self, items: Mapping[str, bytes], *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
18
+ ) -> None: ...
14
19
 
15
20
  def get_json(self, key: str) -> Optional[Any]: ...
16
- def set_json(self, key: str, value: Any, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None: ...
21
+ def set_json(
22
+ self, key: str, value: Any, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
23
+ ) -> None: ...
17
24
 
18
25
  def invalidate_tags(self, tags: Iterable[str]) -> int: ...
19
26
  def bump_namespace(self, namespace: str) -> int: ... # versioned keys
20
27
  def clear_namespace(self, namespace: str) -> int: ...
21
28
 
22
- def get_or_set(self, key: str, producer: Callable[[], bytes], *, ttl: int, tags: Optional[Iterable[str]] = None) -> bytes: ...
29
+ def get_or_set(
30
+ self, key: str, producer: Callable[[], bytes], *, ttl: int, tags: Optional[Iterable[str]] = None
31
+ ) -> bytes: ...
32
+
23
33
 
24
34
  class AsyncCachePort(Protocol):
25
35
  async def get(self, key: str) -> Optional[bytes]: ...
26
- async def set(self, key: str, value: bytes, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None: ...
36
+ async def set(
37
+ self, key: str, value: bytes, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
38
+ ) -> None: ...
27
39
  async def delete(self, key: str) -> None: ...
28
40
  async def exists(self, key: str) -> bool: ...
29
41
  async def ttl(self, key: str) -> Optional[int]: ...
30
42
  async def mget(self, keys: Sequence[str]) -> list[Optional[bytes]]: ...
31
- async def mset(self, items: Mapping[str, bytes], *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None: ...
43
+ async def mset(
44
+ self, items: Mapping[str, bytes], *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
45
+ ) -> None: ...
32
46
  async def get_json(self, key: str) -> Optional[Any]: ...
33
- async def set_json(self, key: str, value: Any, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None: ...
47
+ async def set_json(
48
+ self, key: str, value: Any, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
49
+ ) -> None: ...
34
50
  async def invalidate_tags(self, tags: Iterable[str]) -> int: ...
35
51
  async def bump_namespace(self, namespace: str) -> int: ...
36
52
  async def clear_namespace(self, namespace: str) -> int: ...
37
- async def get_or_set(self, key: str, producer, *, ttl: int, tags: Optional[Iterable[str]] = None) -> bytes: ...
53
+ async def get_or_set(self, key: str, producer, *, ttl: int, tags: Optional[Iterable[str]] = None) -> bytes: ...
@@ -59,3 +59,29 @@ def has_access(*, permissions=None):
59
59
  return wrapper
60
60
 
61
61
  return decorator
62
+
63
+
64
+ def client_or_user_has_access(*, permissions=None, client_permissions=None):
65
+ def decorator(func):
66
+ @functools.wraps(func)
67
+ def wrapper(*args, **kwargs):
68
+ request = current_request()
69
+ token = getattr(request.state, "token", None)
70
+ if not token:
71
+ raise UnauthorizedException()
72
+
73
+ auth = KeycloakAuthService()
74
+
75
+ if auth.get_client_id(token):
76
+ needed = client_permissions or permissions
77
+ if not auth.client_has_access(token, permissions=needed):
78
+ raise ForbiddenException(f"Forbidden (client) {needed}")
79
+ else:
80
+ if not current_user_id():
81
+ raise UnauthorizedException()
82
+ if not auth.has_access(token, permissions=permissions):
83
+ raise ForbiddenException(f"Forbidden (user) {permissions}")
84
+
85
+ return func(*args, **kwargs)
86
+ return wrapper
87
+ return decorator
@@ -1,6 +1,6 @@
1
1
  import typer
2
2
 
3
- from nlbone.adapters.db import init_sync_engine, Base, sync_ping
3
+ from nlbone.adapters.db import Base, init_sync_engine, sync_ping
4
4
 
5
5
  init_db_command = typer.Typer(help="Database utilities")
6
6
 
@@ -1,6 +1,7 @@
1
- import typer
2
1
  from typing import Optional
3
2
 
3
+ import typer
4
+
4
5
  from nlbone.adapters.db import init_sync_engine
5
6
  from nlbone.config.settings import get_settings
6
7
  from nlbone.interfaces.cli.init_db import init_db_command
@@ -9,12 +10,10 @@ app = typer.Typer(help="NLBone CLI")
9
10
 
10
11
  app.add_typer(init_db_command, name="db")
11
12
 
13
+
12
14
  @app.callback()
13
15
  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
- ),
16
+ env_file: Optional[str] = typer.Option(None, "--env-file", "-e", help="Path to .env file. In prod omit this."),
18
17
  debug: bool = typer.Option(False, "--debug", help="Enable debug logging"),
19
18
  ):
20
19
  settings = get_settings(env_file=env_file)
@@ -22,8 +21,10 @@ def common(
22
21
  pass
23
22
  init_sync_engine(echo=settings.DEBUG)
24
23
 
24
+
25
25
  def main():
26
26
  app()
27
27
 
28
+
28
29
  if __name__ == "__main__":
29
30
  main()
nlbone/utils/cache.py CHANGED
@@ -4,17 +4,20 @@ import json
4
4
  from typing import Any, Callable, Iterable, Optional
5
5
 
6
6
  from makefun import wraps as mf_wraps
7
+
7
8
  from nlbone.utils.cache_registry import get_cache
8
9
 
9
10
  try:
10
11
  from pydantic import BaseModel # v1/v2
11
12
  except Exception: # pragma: no cover
13
+
12
14
  class BaseModel: # minimal fallback
13
15
  pass
14
16
 
15
17
 
16
18
  # -------- helpers --------
17
19
 
20
+
18
21
  def _bind(func: Callable, args, kwargs):
19
22
  sig = inspect.signature(func)
20
23
  bound = sig.bind_partial(*args, **kwargs)
@@ -79,6 +82,7 @@ def _run_maybe_async(func: Callable, *args, **kwargs):
79
82
 
80
83
  # -------- cache decorators --------
81
84
 
85
+
82
86
  def cached(
83
87
  *,
84
88
  ttl: int,
@@ -94,10 +98,12 @@ def cached(
94
98
  - Works with sync/async cache backends (CachePort / AsyncCachePort).
95
99
  - `key` & `tags` are string templates, e.g. "file:{file_id}".
96
100
  """
101
+
97
102
  def deco(func: Callable):
98
103
  is_async_func = asyncio.iscoroutinefunction(func)
99
104
 
100
105
  if is_async_func:
106
+
101
107
  @mf_wraps(func)
102
108
  async def aw(*args, **kwargs):
103
109
  cache = (cache_resolver or get_cache)()
@@ -165,10 +171,12 @@ def invalidate_by_tags(tags_builder: Callable[..., Iterable[str]]):
165
171
  Invalidate computed tags after function finishes.
166
172
  Works with sync or async functions and cache backends.
167
173
  """
174
+
168
175
  def deco(func: Callable):
169
176
  is_async_func = asyncio.iscoroutinefunction(func)
170
177
 
171
178
  if is_async_func:
179
+
172
180
  @mf_wraps(func)
173
181
  async def aw(*args, **kwargs):
174
182
  out = await func(*args, **kwargs)
@@ -179,6 +187,7 @@ def invalidate_by_tags(tags_builder: Callable[..., Iterable[str]]):
179
187
  else:
180
188
  cache.invalidate_tags(tags)
181
189
  return out
190
+
182
191
  return aw
183
192
 
184
193
  @mf_wraps(func)
@@ -191,6 +200,7 @@ def invalidate_by_tags(tags_builder: Callable[..., Iterable[str]]):
191
200
  else:
192
201
  cache.invalidate_tags(tags)
193
202
  return out
203
+
194
204
  return sw
195
205
 
196
206
  return deco
@@ -3,21 +3,26 @@ import json
3
3
  import random
4
4
  from typing import Any, Mapping
5
5
 
6
+
6
7
  def _stable_params(params: Mapping[str, Any]) -> str:
7
8
  return json.dumps(params, sort_keys=True, separators=(",", ":"))
8
9
 
10
+
9
11
  def make_key(ns: str, *parts: str) -> str:
10
12
  safe_parts = [p.replace(" ", "_") for p in parts if p]
11
13
  return f"{ns}:{':'.join(safe_parts)}" if safe_parts else f"{ns}:root"
12
14
 
15
+
13
16
  def make_param_key(ns: str, base: str, params: Mapping[str, Any]) -> str:
14
17
  payload = _stable_params(params)
15
18
  digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
16
19
  return f"{ns}:{base}:{digest}"
17
20
 
21
+
18
22
  def tag_entity(ns: str, entity_id: Any) -> str:
19
23
  return f"{ns}:{entity_id}"
20
24
 
25
+
21
26
  def tag_list(ns: str, **filters) -> str:
22
27
  if not filters:
23
28
  return f"{ns}:list"
@@ -25,6 +30,7 @@ def tag_list(ns: str, **filters) -> str:
25
30
  digest = hashlib.md5(payload.encode("utf-8")).hexdigest()[:12]
26
31
  return f"{ns}:list:{digest}"
27
32
 
33
+
28
34
  def ttl_with_jitter(base_ttl: int, *, jitter_ratio: float = 0.1) -> int:
29
35
  jitter = int(base_ttl * jitter_ratio)
30
36
  return base_ttl + random.randint(-jitter, jitter)
@@ -1,5 +1,5 @@
1
- from typing import Callable, Optional, TypeVar
2
1
  from contextvars import ContextVar
2
+ from typing import Callable, Optional, TypeVar
3
3
 
4
4
  T = TypeVar("T")
5
5
 
@@ -7,17 +7,20 @@ _global_resolver: Optional[Callable[[], T]] = None
7
7
 
8
8
  _ctx_resolver: ContextVar[Optional[Callable[[], T]]] = ContextVar("_ctx_resolver", default=None)
9
9
 
10
+
10
11
  def set_cache_resolver(fn: Callable[[], T]) -> None:
11
12
  """Set process-wide cache resolver (e.g., lambda: container.cache())."""
12
13
  global _global_resolver
13
14
  _global_resolver = fn
14
15
 
16
+
15
17
  def set_context_cache_resolver(fn: Optional[Callable[[], T]]) -> None:
16
18
  """Override resolver in current context (useful in tests/background tasks)."""
17
19
  _ctx_resolver.set(fn)
18
20
 
21
+
19
22
  def get_cache() -> T:
20
23
  fn = _ctx_resolver.get() or _global_resolver
21
24
  if fn is None:
22
25
  raise RuntimeError("Cache resolver not configured. Call set_cache_resolver(...) first.")
23
- return fn()
26
+ return fn()
nlbone/utils/http.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+ from urllib.parse import urlparse, urlunparse
6
+
7
+
8
+ def auth_headers(token: str | None) -> dict[str, str]:
9
+ return {"Authorization": f"Bearer {token}"} if token else {}
10
+
11
+
12
+ def build_list_query(
13
+ limit: int, offset: int, filters: dict[str, Any] | None, sort: list[tuple[str, str]] | None
14
+ ) -> dict[str, Any]:
15
+ q: dict[str, Any] = {"limit": limit, "offset": offset}
16
+ if filters:
17
+ q["filters"] = json.dumps(filters)
18
+ if sort:
19
+ q["sort"] = ",".join([f"{f}:{o}" for f, o in sort])
20
+ return q
21
+
22
+
23
+ def normalize_https_base(url: str, enforce_https: bool = True) -> str:
24
+ p = urlparse(url.strip())
25
+ if enforce_https:
26
+ p = p._replace(scheme="https") # enforce https
27
+ if p.path.endswith("/"):
28
+ p = p._replace(path=p.path.rstrip("/"))
29
+ return str(urlunparse(p))