nlbone 0.9.7__py3-none-any.whl → 0.11.0__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.
@@ -1 +1,3 @@
1
+ from .async_auth_service import AsyncAuthService, get_async_auth_service
2
+ from .auth_service import AuthService, get_auth_service
1
3
  from .keycloak import KeycloakAuthService
@@ -0,0 +1,110 @@
1
+ from functools import lru_cache
2
+ from typing import Any, Optional, Set
3
+
4
+ import httpx
5
+
6
+ from nlbone.config.settings import get_settings
7
+ from nlbone.core.ports.auth import AsyncAuthService as BaseAuthService
8
+ from nlbone.utils.cache import cached
9
+ from nlbone.utils.http import normalize_https_base
10
+
11
+
12
+ class AsyncAuthService(BaseAuthService):
13
+ _client: Optional[httpx.AsyncClient] = None
14
+
15
+ def __init__(self):
16
+ s = get_settings()
17
+ self.client_id = s.CLIENT_ID or s.KEYCLOAK_CLIENT_ID
18
+ self.client_secret = s.CLIENT_SECRET.get_secret_value().strip()
19
+ self._base_url = normalize_https_base(s.AUTH_SERVICE_URL.unicode_string(), enforce_https=False)
20
+ self._timeout = float(s.HTTP_TIMEOUT_SECONDS)
21
+
22
+ @classmethod
23
+ def get_client(cls) -> httpx.AsyncClient:
24
+ if cls._client is None or cls._client.is_closed:
25
+ s = get_settings()
26
+ cls._client = httpx.AsyncClient(
27
+ timeout=float(s.HTTP_TIMEOUT_SECONDS),
28
+ limits=httpx.Limits(
29
+ max_keepalive_connections=s.HTTPX_MAX_KEEPALIVE_CONNECTIONS,
30
+ max_connections=s.HTTPX_MAX_CONNECTIONS,
31
+ ),
32
+ )
33
+ return cls._client
34
+
35
+ @cached(ttl=15 * 60)
36
+ async def verify_token(self, token: str) -> Optional[dict[str, Any]]:
37
+ if not token:
38
+ return None
39
+
40
+ url = f"{self._base_url}/introspect"
41
+ client = self.get_client()
42
+
43
+ try:
44
+ response = await client.post(url, data={"token": token})
45
+ if response.status_code == 200:
46
+ data = response.json()
47
+ if data.get("active") is True:
48
+ return data
49
+ except httpx.RequestError as e:
50
+ pass
51
+
52
+ return None
53
+
54
+ async def has_access(self, token: str, permissions: list[str]) -> bool:
55
+ data = await self.verify_token(token)
56
+ if not data:
57
+ return False
58
+
59
+ allowed = set(data.get("allowed_permissions", []))
60
+ required = {f"{self.client_id}#{perm}" for perm in permissions}
61
+
62
+ return required.issubset(allowed)
63
+
64
+ async def client_has_access(
65
+ self, token: str, permissions: list[str], allowed_clients: Set[str] | None = None
66
+ ) -> bool:
67
+ return await self.has_access(token, permissions)
68
+
69
+ async def get_client_id(self, token: str) -> Optional[str]:
70
+ data = await self.verify_token(token)
71
+ if data:
72
+ username = data.get("preferred_username", "")
73
+ if username.startswith("service-account"):
74
+ return username
75
+ return None
76
+
77
+ async def is_client_token(self, token: str, allowed_clients: Set[str] | None = None) -> bool:
78
+ data = await self.verify_token(token)
79
+ if data:
80
+ return data.get("preferred_username", "").startswith("service-account")
81
+ return False
82
+
83
+ async def get_client_token(self) -> dict | None:
84
+ url = f"{self._base_url}/token"
85
+ client = self.get_client()
86
+
87
+ try:
88
+ result = await client.post(
89
+ url,
90
+ data={
91
+ "client_id": self.client_id,
92
+ "client_secret": self.client_secret,
93
+ "grant_type": "client_credentials",
94
+ },
95
+ )
96
+ if result.status_code == 200:
97
+ return result.json()
98
+ except httpx.RequestError:
99
+ pass
100
+
101
+ return None
102
+
103
+ async def get_permissions(self, token: str) -> list[str]:
104
+ data = await self.verify_token(token)
105
+ return data.get("allowed_permissions", []) if data else []
106
+
107
+
108
+ @lru_cache(maxsize=1)
109
+ def get_async_auth_service() -> AsyncAuthService:
110
+ return AsyncAuthService()
@@ -0,0 +1,49 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Optional
4
+
5
+ from nlbone.core.ports.auth import AsyncAuthService as BaseAuthService
6
+
7
+
8
+ class AsyncClientTokenProvider:
9
+ """
10
+ Caches Keycloak client-credentials token and refreshes before expiry.
11
+ """
12
+
13
+ def __init__(self, auth: BaseAuthService, *, skew_seconds: int = 30) -> None:
14
+ self._auth = auth
15
+ self._skew = skew_seconds
16
+ self._lock = asyncio.Lock()
17
+ self._token: Optional[str] = None # access_token
18
+ self._expires_at: float = 0.0 # epoch seconds
19
+
20
+ def _needs_refresh(self) -> bool:
21
+ return not self._token or time.time() >= (self._expires_at - self._skew)
22
+
23
+ async def get_access_token(self) -> str:
24
+ """
25
+ Return a valid access token; refresh asynchronously if needed.
26
+ """
27
+ if not self._needs_refresh() and self._token:
28
+ return self._token
29
+
30
+ async with self._lock:
31
+ if not self._needs_refresh() and self._token:
32
+ return self._token
33
+
34
+ data = await self._auth.get_client_token()
35
+
36
+ if not data or "access_token" not in data:
37
+ raise RuntimeError("Failed to retrieve access_token")
38
+
39
+ access_token = data["access_token"]
40
+ expires_in = int(data.get("expires_in", 15 * 60))
41
+
42
+ self._token = access_token
43
+ self._expires_at = time.time() + max(1, expires_in)
44
+
45
+ return self._token
46
+
47
+ async def get_auth_header(self) -> str:
48
+ token = await self.get_access_token()
49
+ return f"Bearer {token}"
@@ -0,0 +1,66 @@
1
+ from decimal import Decimal
2
+ from typing import Dict, Literal, Optional
3
+
4
+ import httpx
5
+
6
+ from nlbone.adapters.auth.async_token_provider import AsyncClientTokenProvider
7
+ from nlbone.adapters.http_clients import CalculatePriceIn, CalculatePriceOut
8
+ from nlbone.adapters.http_clients.pricing.pricing_service import PricingError
9
+ from nlbone.config.settings import get_settings
10
+ from nlbone.utils.http import normalize_https_base
11
+
12
+
13
+ class AsyncPricingService:
14
+ def __init__(
15
+ self,
16
+ token_provider: AsyncClientTokenProvider,
17
+ base_url: Optional[str] = None,
18
+ timeout_seconds: Optional[float] = None,
19
+ client: Optional[httpx.AsyncClient] = None,
20
+ ) -> None:
21
+ s = get_settings()
22
+ self._token_provider = token_provider
23
+ self._base_url = normalize_https_base(base_url or str(s.PRICING_SERVICE_URL), enforce_https=False)
24
+ self._timeout = httpx.Timeout(timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS), connect=5.0)
25
+ self._client = client or httpx.AsyncClient(
26
+ timeout=self._timeout,
27
+ verify=True if s.ENV == "prod" else False,
28
+ limits=httpx.Limits(
29
+ max_keepalive_connections=s.HTTPX_MAX_KEEPALIVE_CONNECTIONS, max_connections=s.HTTPX_MAX_CONNECTIONS
30
+ ),
31
+ )
32
+
33
+ async def calculate(
34
+ self, items: list[CalculatePriceIn], response: Literal["list", "dict"] = "dict"
35
+ ) -> CalculatePriceOut:
36
+ payload = {"items": [i.model_dump(mode="json") for i in items]}
37
+
38
+ response_obj = await self._client.post(
39
+ f"{self._base_url}/price/calculate",
40
+ params={"response": response},
41
+ headers={"X-Api-Key": get_settings().PRICING_API_SECRET, "X-Client-Id": get_settings().KEYCLOAK_CLIENT_ID},
42
+ # headers=auth_headers(await self._token_provider.get_access_token()),
43
+ json=payload,
44
+ )
45
+
46
+ if response_obj.status_code not in (200, 204):
47
+ raise PricingError(response_obj.status_code, response_obj.text)
48
+
49
+ if response_obj.status_code == 204 or not response_obj.content:
50
+ return CalculatePriceOut.model_validate(root=[])
51
+
52
+ return CalculatePriceOut.model_validate(response_obj.json())
53
+
54
+ async def exchange_rates(self) -> Dict[str, Decimal]:
55
+ response_obj = await self._client.get(
56
+ f"{self._base_url}/variables/key/exchange_rates",
57
+ headers={"X-Api-Key": get_settings().PRICING_API_SECRET, "X-Client-Id": get_settings().KEYCLOAK_CLIENT_ID},
58
+ # headers=auth_headers(await self._token_provider.get_access_token()),
59
+ )
60
+
61
+ if response_obj.status_code != 200:
62
+ raise PricingError(response_obj.status_code, response_obj.text)
63
+
64
+ values = response_obj.json().get("values", [])
65
+
66
+ return {str(v["key"]): Decimal(str(v["value"])) for v in values}
@@ -76,11 +76,11 @@ class DecimalEncoder(json.JSONEncoder):
76
76
 
77
77
  class PricingService:
78
78
  def __init__(
79
- self,
80
- token_provider: ClientTokenProvider,
81
- base_url: Optional[str] = None,
82
- timeout_seconds: Optional[float] = None,
83
- client: httpx.Client | None = None,
79
+ self,
80
+ token_provider: ClientTokenProvider,
81
+ base_url: Optional[str] = None,
82
+ timeout_seconds: Optional[float] = None,
83
+ client: httpx.Client | None = None,
84
84
  ) -> None:
85
85
  s = get_settings()
86
86
  self._base_url = normalize_https_base(base_url or str(s.PRICING_SERVICE_URL), enforce_https=False)
@@ -89,10 +89,7 @@ class PricingService:
89
89
  self._token_provider = token_provider
90
90
 
91
91
  def calculate(self, items: list[CalculatePriceIn], response: Literal["list", "dict"] = "dict") -> CalculatePriceOut:
92
- body = json.dumps(
93
- {"items": [i.model_dump() for i in items]},
94
- cls=DecimalEncoder
95
- )
92
+ body = json.dumps({"items": [i.model_dump() for i in items]}, cls=DecimalEncoder)
96
93
  body = json.loads(body)
97
94
 
98
95
  r = self._client.post(
@@ -56,7 +56,7 @@ class UploadchiClient(FileServicePort):
56
56
  tok = _resolve_token(token)
57
57
  files = {"file": (filename, file_bytes)}
58
58
  data = (params or {}).copy()
59
- r = self._client.post(self._base_url, files=files, data=data, headers=auth_headers(tok))
59
+ r = self._client.post(f"{self._base_url}/files", files=files, data=data, headers=auth_headers(tok))
60
60
  if r.status_code >= 400:
61
61
  raise UploadchiError(r.status_code, r.text)
62
62
  return r.json()
@@ -66,7 +66,7 @@ class UploadchiClient(FileServicePort):
66
66
  raise UploadchiError(detail="token_provider is not provided", status=400)
67
67
  tok = _resolve_token(token)
68
68
  r = self._client.post(
69
- f"{self._base_url}/{file_id}/commit",
69
+ f"{self._base_url}/files/{file_id}/commit",
70
70
  headers=auth_headers(tok or self._token_provider.get_access_token()),
71
71
  )
72
72
  if r.status_code not in (204, 200):
@@ -77,7 +77,7 @@ class UploadchiClient(FileServicePort):
77
77
  raise UploadchiError(detail="token_provider is not provided", status=400)
78
78
  tok = _resolve_token(token)
79
79
  r = self._client.post(
80
- f"{self._base_url}/{file_id}/rollback",
80
+ f"{self._base_url}/files/{file_id}/rollback",
81
81
  headers=auth_headers(tok or self._token_provider.get_access_token()),
82
82
  )
83
83
  if r.status_code not in (204, 200):
@@ -93,21 +93,21 @@ class UploadchiClient(FileServicePort):
93
93
  ) -> dict:
94
94
  tok = _resolve_token(token)
95
95
  q = build_list_query(limit, offset, filters, sort)
96
- r = self._client.get(self._base_url, params=q, headers=auth_headers(tok))
96
+ r = self._client.get(f"{self._base_url}/files", params=q, headers=auth_headers(tok))
97
97
  if r.status_code >= 400:
98
98
  raise UploadchiError(r.status_code, r.text)
99
99
  return r.json()
100
100
 
101
101
  def get_file(self, file_id: str, token: str | None = None) -> dict:
102
102
  tok = _resolve_token(token)
103
- r = self._client.get(f"{self._base_url}/{file_id}", headers=auth_headers(tok))
103
+ r = self._client.get(f"{self._base_url}/files/{file_id}", headers=auth_headers(tok))
104
104
  if r.status_code >= 400:
105
105
  raise UploadchiError(r.status_code, r.text)
106
106
  return r.json()
107
107
 
108
108
  def download_file(self, file_id: str, token: str | None = None) -> tuple[bytes, str, str]:
109
109
  tok = _resolve_token(token)
110
- r = self._client.get(f"{self._base_url}/{file_id}/download", headers=auth_headers(tok))
110
+ r = self._client.get(f"{self._base_url}/files/{file_id}/download", headers=auth_headers(tok))
111
111
  if r.status_code >= 400:
112
112
  raise UploadchiError(r.status_code, r.text)
113
113
  filename = _filename_from_cd(r.headers.get("content-disposition"), fallback=f"file-{file_id}")
@@ -117,7 +117,7 @@ class UploadchiClient(FileServicePort):
117
117
  def delete_file(self, file_id: str, token: str | None = None) -> None:
118
118
  tok = _resolve_token(token)
119
119
  r = self._client.delete(
120
- f"{self._base_url}/{file_id}", headers=auth_headers(tok or self._token_provider.get_access_token())
120
+ f"{self._base_url}/files/{file_id}", headers=auth_headers(tok or self._token_provider.get_access_token())
121
121
  )
122
122
  if r.status_code not in (204, 200):
123
123
  raise UploadchiError(r.status_code, r.text)
@@ -4,7 +4,7 @@ from typing import Any, AsyncIterator, Optional
4
4
 
5
5
  import httpx
6
6
 
7
- from nlbone.adapters.auth.token_provider import ClientTokenProvider
7
+ from nlbone.adapters.auth.async_token_provider import AsyncClientTokenProvider
8
8
  from nlbone.adapters.http_clients.uploadchi.uploadchi import UploadchiError, _filename_from_cd, _resolve_token
9
9
  from nlbone.config.settings import get_settings
10
10
  from nlbone.core.ports.files import AsyncFileServicePort
@@ -14,16 +14,21 @@ from nlbone.utils.http import auth_headers, build_list_query
14
14
  class UploadchiAsyncClient(AsyncFileServicePort):
15
15
  def __init__(
16
16
  self,
17
- token_provider: ClientTokenProvider | None = None,
17
+ token_provider: Optional[AsyncClientTokenProvider] = None,
18
18
  base_url: Optional[str] = None,
19
19
  timeout_seconds: Optional[float] = None,
20
- client: httpx.AsyncClient | None = None,
20
+ client: Optional[httpx.AsyncClient] = None,
21
21
  ) -> None:
22
22
  s = get_settings()
23
23
  self._base_url = base_url or str(s.UPLOADCHI_BASE_URL)
24
24
  self._timeout = timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS)
25
25
  self._client = client or httpx.AsyncClient(
26
- base_url=self._base_url, timeout=self._timeout, follow_redirects=True
26
+ base_url=self._base_url,
27
+ timeout=self._timeout,
28
+ limits=httpx.Limits(
29
+ max_keepalive_connections=s.HTTPX_MAX_KEEPALIVE_CONNECTIONS, max_connections=s.HTTPX_MAX_CONNECTIONS
30
+ ),
31
+ follow_redirects=True,
27
32
  )
28
33
  self._token_provider = token_provider
29
34
 
@@ -36,7 +41,9 @@ class UploadchiAsyncClient(AsyncFileServicePort):
36
41
  tok = _resolve_token(token)
37
42
  files = {"file": (filename, file_bytes)}
38
43
  data = (params or {}).copy()
39
- r = await self._client.post("", files=files, data=data, headers=auth_headers(tok))
44
+ r = await self._client.post(
45
+ "/files", files=files, data=data, headers=auth_headers(tok or await self._token_provider.get_access_token())
46
+ )
40
47
  if r.status_code >= 400:
41
48
  raise UploadchiError(r.status_code, await r.aread())
42
49
  return r.json()
@@ -46,7 +53,7 @@ class UploadchiAsyncClient(AsyncFileServicePort):
46
53
  raise UploadchiError(detail="token_provider is not provided", status=400)
47
54
  tok = _resolve_token(token)
48
55
  r = await self._client.post(
49
- f"/{file_id}/commit", headers=auth_headers(tok or self._token_provider.get_access_token())
56
+ f"/files/{file_id}/commit", headers=auth_headers(tok or await self._token_provider.get_access_token())
50
57
  )
51
58
  if r.status_code not in (204, 200):
52
59
  raise UploadchiError(r.status_code, await r.aread())
@@ -56,7 +63,7 @@ class UploadchiAsyncClient(AsyncFileServicePort):
56
63
  raise UploadchiError(detail="token_provider is not provided", status=400)
57
64
  tok = _resolve_token(token)
58
65
  r = await self._client.post(
59
- f"/{file_id}/rollback", headers=auth_headers(tok or self._token_provider.get_access_token())
66
+ f"/files/{file_id}/rollback", headers=auth_headers(tok or await self._token_provider.get_access_token())
60
67
  )
61
68
  if r.status_code not in (204, 200):
62
69
  raise UploadchiError(r.status_code, await r.aread())
@@ -71,21 +78,29 @@ class UploadchiAsyncClient(AsyncFileServicePort):
71
78
  ) -> dict:
72
79
  tok = _resolve_token(token)
73
80
  q = build_list_query(limit, offset, filters, sort)
74
- r = await self._client.get("", params=q, headers=auth_headers(tok))
81
+ r = await self._client.get(
82
+ "/files", params=q, headers=auth_headers(tok or await self._token_provider.get_access_token())
83
+ )
75
84
  if r.status_code >= 400:
76
85
  raise UploadchiError(r.status_code, await r.aread())
77
86
  return r.json()
78
87
 
79
88
  async def get_file(self, file_id: str, token: str | None = None) -> dict:
80
89
  tok = _resolve_token(token)
81
- r = await self._client.get(f"/{file_id}", headers=auth_headers(tok))
90
+ r = await self._client.get(
91
+ f"/files/{file_id}", headers=auth_headers(tok or await self._token_provider.get_access_token())
92
+ )
82
93
  if r.status_code >= 400:
83
94
  raise UploadchiError(r.status_code, await r.aread())
84
95
  return r.json()
85
96
 
86
97
  async def download_file(self, file_id: str, token: str | None = None) -> tuple[AsyncIterator[bytes], str, str]:
87
98
  tok = _resolve_token(token)
88
- r = await self._client.get(f"/{file_id}/download", headers=auth_headers(tok), stream=True)
99
+ r = await self._client.get(
100
+ f"/files/{file_id}/download",
101
+ headers=auth_headers(tok or await self._token_provider.get_access_token()),
102
+ stream=True,
103
+ )
89
104
  if r.status_code >= 400:
90
105
  body = await r.aread()
91
106
  raise UploadchiError(r.status_code, body.decode(errors="ignore"))
@@ -103,7 +118,9 @@ class UploadchiAsyncClient(AsyncFileServicePort):
103
118
 
104
119
  async def delete_file(self, file_id: str, token: str | None = None) -> None:
105
120
  tok = _resolve_token(token)
106
- r = await self._client.delete(f"/{file_id}", headers=auth_headers(tok))
121
+ r = await self._client.delete(
122
+ f"/files/{file_id}", headers=auth_headers(tok or await self._token_provider.get_access_token())
123
+ )
107
124
  if r.status_code not in (204, 200):
108
125
  body = await r.aread()
109
126
  raise UploadchiError(r.status_code, body.decode(errors="ignore"))
nlbone/config/settings.py CHANGED
@@ -52,6 +52,8 @@ class Settings(BaseSettings):
52
52
  # HTTP / Timeouts
53
53
  # ---------------------------
54
54
  HTTP_TIMEOUT_SECONDS: float = Field(default=10.0)
55
+ HTTPX_MAX_KEEPALIVE_CONNECTIONS: int = Field(default=10)
56
+ HTTPX_MAX_CONNECTIONS: int = Field(default=30)
55
57
 
56
58
  # ---------------------------
57
59
  # Keycloak / Auth
@@ -104,7 +106,7 @@ class Settings(BaseSettings):
104
106
  # ---------------------------
105
107
  # UPLOADCHI
106
108
  # ---------------------------
107
- UPLOADCHI_BASE_URL: AnyHttpUrl = Field(default="https://uploadchi.numberland.ir/v1/files")
109
+ UPLOADCHI_BASE_URL: AnyHttpUrl = Field(default="https://uploadchi.numberland.ir/v1")
108
110
  UPLOADCHI_TOKEN: SecretStr = Field(default="")
109
111
 
110
112
  # ---------------------------
nlbone/container.py CHANGED
@@ -5,6 +5,8 @@ from typing import Dict, Optional
5
5
  from dependency_injector import containers, providers
6
6
  from pydantic_settings import BaseSettings
7
7
 
8
+ from nlbone.adapters.auth.async_auth_service import AsyncAuthService as AsyncAuthService_IMP
9
+ from nlbone.adapters.auth.async_token_provider import AsyncClientTokenProvider
8
10
  from nlbone.adapters.auth.auth_service import AuthService as AuthService_IMP
9
11
  from nlbone.adapters.auth.token_provider import ClientTokenProvider
10
12
  from nlbone.adapters.cache.async_redis import AsyncRedisCache
@@ -12,9 +14,10 @@ from nlbone.adapters.cache.memory import InMemoryCache
12
14
  from nlbone.adapters.cache.redis import RedisCache
13
15
  from nlbone.adapters.db.postgres.engine import get_async_session_factory, get_sync_session_factory
14
16
  from nlbone.adapters.http_clients import PricingService
17
+ from nlbone.adapters.http_clients.pricing.async_pricing_service import AsyncPricingService
15
18
  from nlbone.adapters.http_clients.uploadchi import UploadchiClient
16
19
  from nlbone.adapters.http_clients.uploadchi.uploadchi_async import UploadchiAsyncClient
17
- from nlbone.core.ports.auth import AuthService
20
+ from nlbone.core.ports.auth import AsyncAuthService, AuthService
18
21
  from nlbone.core.ports.cache import AsyncCachePort, CachePort
19
22
  from nlbone.core.ports.files import AsyncFileServicePort, FileServicePort
20
23
 
@@ -31,15 +34,21 @@ class Container(containers.DeclarativeContainer):
31
34
  # --- Services ---
32
35
  auth: providers.Singleton[AuthService] = providers.Singleton(AuthService_IMP)
33
36
  token_provider = providers.Singleton(ClientTokenProvider, auth=auth, skew_seconds=30)
37
+ async_auth: providers.Singleton[AsyncAuthService] = providers.Singleton(AsyncAuthService_IMP)
38
+ async_token_provider = providers.Singleton(AsyncClientTokenProvider, auth=async_auth, skew_seconds=30)
39
+
34
40
  file_service: providers.Singleton[FileServicePort] = providers.Singleton(
35
41
  UploadchiClient, token_provider=token_provider
36
42
  )
37
43
  afiles_service: providers.Singleton[AsyncFileServicePort] = providers.Singleton(
38
- UploadchiAsyncClient, token_provider=token_provider
44
+ UploadchiAsyncClient, token_provider=async_token_provider
39
45
  )
40
46
  pricing_service: providers.Singleton[PricingService] = providers.Singleton(
41
47
  PricingService, token_provider=token_provider
42
48
  )
49
+ async_pricing_service: providers.Singleton[AsyncPricingService] = providers.Singleton(
50
+ AsyncPricingService, token_provider=async_token_provider
51
+ )
43
52
 
44
53
  cache: providers.Singleton[CachePort] = providers.Selector(
45
54
  config.CACHE_BACKEND,
nlbone/core/ports/auth.py CHANGED
@@ -9,3 +9,15 @@ class AuthService(Protocol):
9
9
  def is_client_token(self, token: str, allowed_clients: set[str] | None = None) -> bool: ...
10
10
  def client_has_access(self, token: str, perms: list[str], allowed_clients: set[str] | None = None) -> bool: ...
11
11
  def get_permissions(self, token: str) -> list[str]: ...
12
+
13
+
14
+ @runtime_checkable
15
+ class AsyncAuthService(Protocol):
16
+ async def has_access(self, token: str, permissions: list[str]) -> bool: ...
17
+ async def verify_token(self, token: str) -> dict | None: ...
18
+ async def get_client_token(self) -> dict | None: ...
19
+ async def is_client_token(self, token: str, allowed_clients: set[str] | None = None) -> bool: ...
20
+ async def client_has_access(
21
+ self, token: str, perms: list[str], allowed_clients: set[str] | None = None
22
+ ) -> bool: ...
23
+ async def get_permissions(self, token: str) -> list[str]: ...
@@ -1,10 +1,10 @@
1
- from .async_auth import client_has_access, current_request, current_user_id, has_access, user_authenticated
2
1
  from .auth import ( # noqa: F811
3
2
  client_has_access,
4
3
  current_client_id,
5
4
  current_request,
6
5
  current_user_id,
7
6
  has_access,
7
+ is_permitted_user,
8
8
  user_authenticated,
9
9
  )
10
10
  from .db import get_async_session, get_session
@@ -1,31 +1,31 @@
1
1
  import functools
2
2
 
3
- from nlbone.adapters.auth.auth_service import get_auth_service
4
- from nlbone.interfaces.api.exceptions import UnauthorizedException
3
+ from nlbone.adapters.auth.async_auth_service import get_async_auth_service
4
+ from nlbone.interfaces.api.exceptions import ForbiddenException, UnauthorizedException
5
5
  from nlbone.utils.context import current_request
6
6
 
7
- from .auth import client_has_access_func, client_or_user_has_access_func, user_has_access_func
8
-
9
-
10
- async def current_user_id() -> int:
11
- user_id = current_request().state.user_id
12
- if user_id is not None:
13
- return int(user_id)
14
- raise UnauthorizedException()
7
+ from .auth import bypass_authz, current_user_id
15
8
 
16
9
 
17
10
  async def current_client_id() -> str:
18
11
  request = current_request()
19
- if client_id := get_auth_service().get_client_id(request.state.token):
12
+ if client_id := await get_async_auth_service().get_client_id(request.state.token):
20
13
  return str(client_id)
21
14
  raise UnauthorizedException()
22
15
 
23
16
 
17
+ async def client_has_access_func(*, permissions=None):
18
+ request = current_request()
19
+ if not await get_async_auth_service().client_has_access(request.state.token, permissions=permissions):
20
+ raise ForbiddenException(f"Forbidden {permissions}")
21
+ return True
22
+
23
+
24
24
  def client_has_access(*, permissions=None):
25
25
  def decorator(func):
26
26
  @functools.wraps(func)
27
27
  async def wrapper(*args, **kwargs):
28
- client_has_access_func(permissions=permissions)
28
+ await client_has_access_func(permissions=permissions)
29
29
  return await func(*args, **kwargs)
30
30
 
31
31
  return wrapper
@@ -36,18 +36,29 @@ def client_has_access(*, permissions=None):
36
36
  def user_authenticated(func):
37
37
  @functools.wraps(func)
38
38
  async def wrapper(*args, **kwargs):
39
- if not await current_user_id():
39
+ if not current_user_id():
40
40
  raise UnauthorizedException()
41
41
  return await func(*args, **kwargs)
42
42
 
43
43
  return wrapper
44
44
 
45
45
 
46
+ async def user_has_access_func(*, permissions=None):
47
+ request = current_request()
48
+ if not current_user_id():
49
+ raise UnauthorizedException()
50
+ if bypass_authz():
51
+ return True
52
+ if not await get_async_auth_service().has_access(request.state.token, permissions=permissions):
53
+ raise ForbiddenException(f"Forbidden {permissions}")
54
+ return True
55
+
56
+
46
57
  def has_access(*, permissions=None):
47
58
  def decorator(func):
48
59
  @functools.wraps(func)
49
60
  async def wrapper(*args, **kwargs):
50
- user_has_access_func(permissions=permissions)
61
+ await user_has_access_func(permissions=permissions)
51
62
  return await func(*args, **kwargs)
52
63
 
53
64
  return wrapper
@@ -55,13 +66,46 @@ def has_access(*, permissions=None):
55
66
  return decorator
56
67
 
57
68
 
69
+ async def client_or_user_has_access_func(permissions=None, client_permissions=None):
70
+ if bypass_authz():
71
+ return True
72
+ request = current_request()
73
+ token = getattr(request.state, "token", None)
74
+ if not token:
75
+ raise UnauthorizedException()
76
+ needed = client_permissions or permissions
77
+ try:
78
+ await client_has_access_func(permissions=needed)
79
+ except Exception:
80
+ await user_has_access_func(permissions=needed)
81
+
82
+
58
83
  def client_or_user_has_access(*, permissions=None, client_permissions=None):
59
84
  def decorator(func):
60
85
  @functools.wraps(func)
61
86
  async def wrapper(*args, **kwargs):
62
- client_or_user_has_access_func(permissions=permissions, client_permissions=client_permissions)
87
+ await client_or_user_has_access_func(permissions=permissions, client_permissions=client_permissions)
63
88
  return await func(*args, **kwargs)
64
89
 
65
90
  return wrapper
66
91
 
67
92
  return decorator
93
+
94
+
95
+ async def is_permitted_user(permissions=None):
96
+ async def check_permissions():
97
+ try:
98
+ if bypass_authz():
99
+ return True
100
+ request = current_request()
101
+ if not current_user_id():
102
+ raise UnauthorizedException()
103
+ if not await get_async_auth_service().has_access(request.state.token, permissions=permissions):
104
+ raise ForbiddenException(f"Forbidden {permissions}")
105
+ return True
106
+ except ForbiddenException:
107
+ return False
108
+ except UnauthorizedException:
109
+ return False
110
+
111
+ return check_permissions
@@ -7,9 +7,9 @@ from nlbone.interfaces.api.exceptions import ForbiddenException, UnauthorizedExc
7
7
  from nlbone.utils.context import current_request
8
8
 
9
9
 
10
- @functools.lru_cache()
10
+ @functools.lru_cache(maxsize=1)
11
11
  def bypass_authz() -> bool:
12
- if get_settings().ENV != "prod":
12
+ if get_settings().ENV not in ("prod", "staging"):
13
13
  return True
14
14
  return False
15
15
 
@@ -1,21 +1,19 @@
1
- from typing import Callable, Optional, Union
1
+ from typing import Any, Callable, Optional, Union
2
2
 
3
3
  from fastapi import Request
4
4
  from starlette.middleware.base import BaseHTTPMiddleware
5
5
 
6
- from nlbone.adapters.auth.auth_service import AuthService
7
6
  from nlbone.config.settings import get_settings
8
7
  from nlbone.core.domain.models import CurrentUserData
8
+ from nlbone.core.ports.auth import AsyncAuthService as BaseAuthService
9
9
 
10
10
  try:
11
11
  from dependency_injector import providers
12
12
 
13
13
  ProviderType = providers.Provider # type: ignore
14
- except Exception:
14
+ except ImportError:
15
15
  ProviderType = object
16
16
 
17
- from nlbone.core.ports.auth import AuthService as BaseAuthService
18
-
19
17
 
20
18
  def _to_factory(auth: Union[BaseAuthService, Callable[[], BaseAuthService], ProviderType]):
21
19
  try:
@@ -25,50 +23,43 @@ def _to_factory(auth: Union[BaseAuthService, Callable[[], BaseAuthService], Prov
25
23
  return auth
26
24
  except Exception:
27
25
  pass
28
- if callable(auth) and not hasattr(auth, "verify_token"):
26
+ if callable(auth):
29
27
  return auth
30
28
  return lambda: auth
31
29
 
32
30
 
33
- def authenticate_admin_user(request, auth_service):
34
- token: Optional[str] = None
35
- authz = request.headers.get("Authorization")
36
- if authz:
31
+ def _extract_token(request: Request) -> Optional[str]:
32
+ auth_header = request.headers.get("Authorization")
33
+ if auth_header:
37
34
  try:
38
- scheme, token = authz.split(" ", 1)
39
- if scheme.lower() != "bearer":
40
- token = None
35
+ scheme, token = auth_header.split(" ", 1)
36
+ if scheme.lower() == "bearer":
37
+ return token
41
38
  except ValueError:
42
- token = None
43
-
44
- if token:
45
- request.state.token = token
46
- try:
47
- service: BaseAuthService = auth_service()
48
- data = service.verify_token(token)
49
- if data:
50
- request.state.user_id = data.get("user_id")
51
- except Exception:
52
39
  pass
53
40
 
41
+ return request.cookies.get("access_token") or request.cookies.get("j_token")
54
42
 
55
- def authenticate_user(request):
56
- token = (
57
- request.cookies.get("access_token") or request.cookies.get("j_token") or request.headers.get("Authorization")
58
- )
59
- if request.headers.get("Authorization"):
60
- scheme, token = request.headers.get("Authorization").split(" ", 1)
61
43
 
62
- if token:
63
- request.state.token = token
64
- try:
65
- service: BaseAuthService = AuthService()
66
- data = service.verify_token(token)
67
- if data:
44
+ async def authenticate_request(request: Request, auth_factory: Callable[[], BaseAuthService]) -> None:
45
+ token = _extract_token(request)
46
+ if not token:
47
+ return
48
+
49
+ request.state.token = token
50
+ try:
51
+ auth_service = auth_factory()
52
+ data = await auth_service.verify_token(token)
53
+
54
+ if data:
55
+ request.state.user_id = data.get("sub") or data.get("user_id")
56
+
57
+ try:
68
58
  request.state.user = CurrentUserData.from_dict(data)
69
- request.state.user_id = data.get("sub")
70
- except Exception:
71
- pass
59
+ except Exception:
60
+ pass
61
+ except Exception:
62
+ pass
72
63
 
73
64
 
74
65
  class AuthenticationMiddleware(BaseHTTPMiddleware):
@@ -76,20 +67,25 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
76
67
  super().__init__(app)
77
68
  self._get_auth = _to_factory(auth)
78
69
 
79
- async def dispatch(self, request: Request, call_next):
70
+ async def dispatch(self, request: Request, call_next: Callable) -> Any:
80
71
  request.state.client_id = None
81
72
  request.state.user_id = None
82
73
  request.state.token = None
83
74
  request.state.user = None
84
- if request.headers.get("X-Client-Id") and request.headers.get("X-Api-Key"):
85
- if request.headers.get("X-Api-Key") == get_settings().PRICING_API_SECRET:
86
- request.state.client_id = request.headers.get("X-Client-Id")
75
+
76
+ client_id = request.headers.get("X-Client-Id")
77
+ api_key = request.headers.get("X-Api-Key")
78
+
79
+ if client_id and api_key:
80
+ if api_key == get_settings().PRICING_API_SECRET:
81
+ request.state.client_id = client_id
87
82
  return await call_next(request)
88
- if request.headers.get("X-Client-Id") == "website" and request.headers.get("Authorization"):
89
- authenticate_user(request)
90
- elif request.cookies.get("access_token") or request.headers.get("Authorization"):
91
- authenticate_user(request)
92
- elif request.headers.get("Authorization"):
93
- authenticate_admin_user(request, auth_service=self._get_auth)
83
+
84
+ if (
85
+ request.headers.get("Authorization")
86
+ or request.cookies.get("access_token")
87
+ or request.cookies.get("j_token")
88
+ ):
89
+ await authenticate_request(request, self._get_auth)
94
90
 
95
91
  return await call_next(request)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nlbone
3
- Version: 0.9.7
3
+ Version: 0.11.0
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
@@ -1,9 +1,11 @@
1
1
  nlbone/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- nlbone/container.py,sha256=VSc72QBllFzW_3kj5znRkqnmK3WZvez404S13kb9ifw,2748
2
+ nlbone/container.py,sha256=c1frCaCwfV5ImOexyeX2vJYou0ZvuItPlQimtCZZxeA,3403
3
3
  nlbone/types.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  nlbone/adapters/__init__.py,sha256=NzUmk4XPyp3GJOw7VSE86xkQMZLtG3MrOoXLeoB551M,41
5
5
  nlbone/adapters/snowflake.py,sha256=eC5eXWgkTIJlO5J44VFbD1-MXj8HYs0lCNp37paSfXY,2324
6
- nlbone/adapters/auth/__init__.py,sha256=hkDHvsFhw_UiOHG9ZSMqjiAhK4wumEforitveSZswVw,42
6
+ nlbone/adapters/auth/__init__.py,sha256=v2yU52Eq0pQQgFLC9BedyyWbu9xp_JCbsmjLGNj8Tvs,171
7
+ nlbone/adapters/auth/async_auth_service.py,sha256=gnz7GqJvoHYJFoIs444jm7XWadQkMYS3S1UMOxoUgdE,3722
8
+ nlbone/adapters/auth/async_token_provider.py,sha256=HVAuInFG483IZzrQ-agqOhiRiu4ix8kynwvcOniYr2E,1567
7
9
  nlbone/adapters/auth/auth_service.py,sha256=CYfOX5M19SGsteHOTNiRLFohaVXx5X5NwmKQI2WLjCQ,2621
8
10
  nlbone/adapters/auth/keycloak.py,sha256=IhEriaFl5mjIGT6ZUCU9qROd678ARchvWgd4UJ6zH7s,4925
9
11
  nlbone/adapters/auth/token_provider.py,sha256=kzjFAaFY8SPnU0Tn6l-YVrhEOAiFV0QE3eit3D7u2VQ,1438
@@ -26,10 +28,11 @@ nlbone/adapters/db/redis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZ
26
28
  nlbone/adapters/db/redis/client.py,sha256=cT9TCigE-ndB-ckcpuh8cTS1AOVKS3rWNG_WYxD0CAM,930
27
29
  nlbone/adapters/http_clients/__init__.py,sha256=w-Yr9CLuXMU71N0Ada5HbvP1DB53wqeP6B-i5rALlTo,150
28
30
  nlbone/adapters/http_clients/pricing/__init__.py,sha256=ElA9NFcAR9u4cqb_w3PPqKU3xGeyjNLQ8veJ0ql2iz0,81
29
- nlbone/adapters/http_clients/pricing/pricing_service.py,sha256=EIHxOPEJRGzUKwI3zaFQAawM15xt2Ox3D0Z_mu541_A,3764
31
+ nlbone/adapters/http_clients/pricing/async_pricing_service.py,sha256=6vwVQ8SGTxFMOKhqlri8CQzzL5RgmDHI4icBiUATdXA,2846
32
+ nlbone/adapters/http_clients/pricing/pricing_service.py,sha256=TPDoeryibAXZndXOoREGTQfRh4lm4K45Cv4ng4gpwD4,3710
30
33
  nlbone/adapters/http_clients/uploadchi/__init__.py,sha256=uBzEOuVtY22teWW2b36Pitkdk5yVdSqa6xbg22JfTNg,105
31
- nlbone/adapters/http_clients/uploadchi/uploadchi.py,sha256=erpjOees25FW0nuK1PkYS-oU0h8MeRV9Rhs1cf3gaEs,4881
32
- nlbone/adapters/http_clients/uploadchi/uploadchi_async.py,sha256=PQbVNeaYde5CmgT3vcnQoI1PGeSs9AxHlPFuB8biOmU,4717
34
+ nlbone/adapters/http_clients/uploadchi/uploadchi.py,sha256=zSvUkDDL1m-OuCD-oJtqxNBghmK9yFWKpXi7t5-11-M,4933
35
+ nlbone/adapters/http_clients/uploadchi/uploadchi_async.py,sha256=onCf2Jm6LskB3vqFTPhM4pgi-zJmmcmBVbo4vOSffwY,5363
33
36
  nlbone/adapters/i18n/__init__.py,sha256=fS97TR7HEc7fiDC2ufQKoFOxXDNkGA4njAFIB3EmhLk,426
34
37
  nlbone/adapters/i18n/engine.py,sha256=yH_b614oJQ2PgM8qpQ5_Prroi4Jjqb-xPguHCCJm0_0,1305
35
38
  nlbone/adapters/i18n/loaders.py,sha256=7td0Cmn0ehjDO2S2qeH31zwl8UyWI7quzedP9PnPhWk,2381
@@ -47,7 +50,7 @@ nlbone/adapters/ticketing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
47
50
  nlbone/adapters/ticketing/client.py,sha256=b9U3ouK8sIVq1A_1Z6PgAEaT0_-a3PIP7DvRv-WaoAU,1384
48
51
  nlbone/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
52
  nlbone/config/logging.py,sha256=Ot6Ctf7EQZlW8YNB-uBdleqI6wixn5fH0Eo6QRgNkQk,4358
50
- nlbone/config/settings.py,sha256=4OLG9M9wB5H6slNcg61NuNEPoWQsjoA6RvesRYa1C7c,5330
53
+ nlbone/config/settings.py,sha256=oFkBsLX955JJkIZwL5d8b5LjKg6mrL75Db9wYsHHc6Y,5436
51
54
  nlbone/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
55
  nlbone/core/application/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
56
  nlbone/core/application/base_worker.py,sha256=5brIToSd-vi6tw0ukhHnUZGZhOLq1SQ-NRRy-kp6D24,1193
@@ -60,7 +63,7 @@ nlbone/core/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuF
60
63
  nlbone/core/domain/base.py,sha256=dz3gwNnOk048zLghbNNM_Wa0qPXLoRmH5w72fwtXNFM,2592
61
64
  nlbone/core/domain/models.py,sha256=HSgICH4_0kPyZKGIawCd6VyJ5w6ZFXkSymBgVu79peI,4131
62
65
  nlbone/core/ports/__init__.py,sha256=syJg3fAjQALD5Rjfm9wi9bQpkIvNTWjE9AURBmy587o,132
63
- nlbone/core/ports/auth.py,sha256=C-GmUqHNx4bAku6KbW_OTpPXCEfurBWWyDi9KxpTi9M,553
66
+ nlbone/core/ports/auth.py,sha256=RhRKFcO5NFfvhKJkHb9anSxpai5w6D1N4Fhy-bGPWV0,1114
64
67
  nlbone/core/ports/cache.py,sha256=8pP_z4ta7PNNG8UiSrEF4xMZRm2wLPxISZvdPt7QnxQ,2351
65
68
  nlbone/core/ports/event_bus.py,sha256=7iC8WRBg-EmcKJx7AVPkP-r823SLKGuDxGp9WF4q-_U,824
66
69
  nlbone/core/ports/files.py,sha256=7Ov2ITYRpPwwDTZGCeNVISg8e3A9l08jbOgpTImgfK8,1863
@@ -80,16 +83,16 @@ nlbone/interfaces/api/additional_filed/field_registry.py,sha256=IhIvzHWOMtKv8iTd
80
83
  nlbone/interfaces/api/additional_filed/resolver.py,sha256=jv1TIBBHN4LBIMwHGipcy4iq0uP0r6udyaqvhRzb8Bk,4655
81
84
  nlbone/interfaces/api/additional_filed/default_field_rules/__init__.py,sha256=LUSAOO3xRUt5ptlraIx7H-7dSkdr1D-WprmnqXRB16g,48
82
85
  nlbone/interfaces/api/additional_filed/default_field_rules/image_field_rules.py,sha256=ecKqPeXZ-YiF14RK9PmK7ln3PCzpCUc18S5zm5IF3fw,339
83
- nlbone/interfaces/api/dependencies/__init__.py,sha256=rnYRrFVZCfICQrp_PVFlzNg3BeC57yM08wn2DbOHCfk,359
84
- nlbone/interfaces/api/dependencies/async_auth.py,sha256=TZlFzT-mnPz1WBL0wh3nOlBXDjY-7B0-b4wBT8O6pLM,1890
85
- nlbone/interfaces/api/dependencies/auth.py,sha256=59oBL3FyRl0mCr0qaRKOCuY5kH5QkZekdbMr8hNOOGQ,3810
86
+ nlbone/interfaces/api/dependencies/__init__.py,sha256=nrmQftdCfKlqSE44R6PkcxtkwCdNpZgI8yLlHyeiACA,274
87
+ nlbone/interfaces/api/dependencies/async_auth.py,sha256=c6PohIprT35konFbHQht0y0MJHBouhKqIK_XFQ9Rcbg,3465
88
+ nlbone/interfaces/api/dependencies/auth.py,sha256=4L-hspOyv9HpaCO-rAi7rk52PlZTgMClEG2LA2tMriM,3836
86
89
  nlbone/interfaces/api/dependencies/client_credential.py,sha256=Bo4dYx75Qw0JzTKD9ZfV5EXDEOuwndJk2D-V37K2ePg,1293
87
90
  nlbone/interfaces/api/dependencies/db.py,sha256=-UD39J_86UU7ZJs2ZncpdND0yhAG0NeeeALrgSDuuFw,466
88
91
  nlbone/interfaces/api/dependencies/uow.py,sha256=QfLEvLYLNWZJQN1k-0q0hBVtUld3D75P4j39q_RjcnE,1181
89
92
  nlbone/interfaces/api/middleware/__init__.py,sha256=zbX2vaEAfxRMIYwO2MVY_2O6bqG5H9o7HqGpX14U3Is,158
90
93
  nlbone/interfaces/api/middleware/access_log.py,sha256=vIkxxxfy2HcjqqKb8XCfGCcSrivAC8u6ie75FMq5x-U,1032
91
94
  nlbone/interfaces/api/middleware/add_request_context.py,sha256=o8mdo-D6fODM9OyHunE5UodkVxsh4F__5tDv8ju8Sxg,1952
92
- nlbone/interfaces/api/middleware/authentication.py,sha256=dWxA9Aw8eFUqsufT2mHGk6mgehXKdAA9AAEIOk_jZWY,3326
95
+ nlbone/interfaces/api/middleware/authentication.py,sha256=tF5tXUE8Ln14LLx6Hkx5AlTHpOxDwrN0UfkkgvV87ug,2810
93
96
  nlbone/interfaces/api/pagination/__init__.py,sha256=pA1uC4rK6eqDI5IkLVxmgO2B6lExnOm8Pje2-hifJZw,431
94
97
  nlbone/interfaces/api/pagination/offset_base.py,sha256=pdfNgmP99eFC5qCWyY1JgW8hNhOuEGnmrlvQPGArdj8,4709
95
98
  nlbone/interfaces/api/schema/__init__.py,sha256=LAqgynfupeqOQ6u0I5ucrcYnojRMZUg9yW8IjKSQTNI,119
@@ -116,8 +119,8 @@ nlbone/utils/normalize_mobile.py,sha256=sGH4tV9gX-6eVKozviNWJhm1DN1J28Nj-ERldCYk
116
119
  nlbone/utils/read_files.py,sha256=mx8dfvtaaARQFRp_U7OOiERg-GT62h09_lpTzIQsVhs,291
117
120
  nlbone/utils/redactor.py,sha256=-V4HrHmHwPi3Kez587Ek1uJlgK35qGSrwBOvcbw8Jas,1279
118
121
  nlbone/utils/time.py,sha256=DjjyQ9GLsfXoT6NK8RDW2rOlJg3e6sF04Jw6PBUrSvg,1268
119
- nlbone-0.9.7.dist-info/METADATA,sha256=SAtWlKKfb4Hnfmgr4tRsiryWZSNr7DWjJAbIZOnpfmE,2294
120
- nlbone-0.9.7.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
121
- nlbone-0.9.7.dist-info/entry_points.txt,sha256=CpIL45t5nbhl1dGQPhfIIDfqqak3teK0SxPGBBr7YCk,59
122
- nlbone-0.9.7.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
123
- nlbone-0.9.7.dist-info/RECORD,,
122
+ nlbone-0.11.0.dist-info/METADATA,sha256=tCSglsRxWG-yPYxEmv_M9TMoZqsq_ixuNCgXXK7jJBM,2295
123
+ nlbone-0.11.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
124
+ nlbone-0.11.0.dist-info/entry_points.txt,sha256=CpIL45t5nbhl1dGQPhfIIDfqqak3teK0SxPGBBr7YCk,59
125
+ nlbone-0.11.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
126
+ nlbone-0.11.0.dist-info/RECORD,,