nlbone 0.6.13__py3-none-any.whl → 0.6.15__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.
@@ -36,7 +36,7 @@ class KeycloakAuthService(AuthService):
36
36
  realm_name=s.KEYCLOAK_REALM_NAME,
37
37
  client_secret_key=s.KEYCLOAK_CLIENT_SECRET.get_secret_value().strip(),
38
38
  )
39
- self.bypass = s.ENV != 'prod'
39
+ self.bypass = s.ENV != "prod"
40
40
 
41
41
  def has_access(self, token, permissions):
42
42
  if self.bypass:
@@ -145,4 +145,4 @@ class KeycloakAuthService(AuthService):
145
145
 
146
146
  @functools.lru_cache(maxsize=1)
147
147
  def get_auth_service() -> KeycloakAuthService:
148
- return KeycloakAuthService()
148
+ return KeycloakAuthService()
@@ -1,9 +1,11 @@
1
1
  from typing import Any, Callable, Optional, Sequence, Type, Union
2
2
 
3
- from sqlalchemy import asc, desc, or_, and_, case, literal
3
+ from sqlalchemy import and_, asc, case, desc, literal, or_
4
4
  from sqlalchemy.dialects.postgresql import ENUM as PGEnum
5
- from sqlalchemy.orm import Query, Session
5
+ from sqlalchemy.orm import Query, Session, aliased
6
+ from sqlalchemy.orm.attributes import InstrumentedAttribute
6
7
  from sqlalchemy.orm.interfaces import LoaderOption
8
+ from sqlalchemy.orm.relationships import RelationshipProperty
7
9
  from sqlalchemy.sql.sqltypes import (
8
10
  BigInteger,
9
11
  Boolean,
@@ -28,12 +30,6 @@ class _InvalidEnum(Exception):
28
30
  pass
29
31
 
30
32
 
31
- from sqlalchemy.orm import aliased
32
- from sqlalchemy.inspection import inspect
33
- from sqlalchemy.orm.attributes import InstrumentedAttribute
34
- from sqlalchemy.orm.relationships import RelationshipProperty
35
-
36
-
37
33
  def _resolve_column_and_joins(entity, query, field_path: str, join_cache: dict[str, Any]):
38
34
  parts = [p for p in field_path.split(".") if p]
39
35
  if not parts:
@@ -141,13 +137,15 @@ def _to_sql_like_pattern(s: str) -> str:
141
137
 
142
138
  def _parse_field_and_op(field: str):
143
139
  """
144
- Supports 'field__ilike' to force ILIKE.
145
- Returns (base_field, op) where op in {'eq', 'ilike'}
140
+ Supports suffix operators like:
141
+ __ilike, __gte, __lte, __lt, __gt, __ne
142
+ Returns (base_field, op) where op in {'eq','ilike','gte','lte','lt','gt','ne'}
146
143
  """
147
144
  if "__" in field:
148
145
  base, op = field.rsplit("__", 1)
149
- if op.lower() == "ilike":
150
- return base, "ilike"
146
+ op = op.lower()
147
+ if base and op in {"ilike", "gte", "lte", "lt", "gt", "ne"}:
148
+ return base, op
151
149
  return field, "eq"
152
150
 
153
151
 
@@ -201,6 +199,7 @@ def _apply_filters(pagination, entity, query):
201
199
  return v
202
200
 
203
201
  try:
202
+
204
203
  def _use_ilike(v) -> bool:
205
204
  if op_hint == "ilike":
206
205
  return True
@@ -217,10 +216,24 @@ def _apply_filters(pagination, entity, query):
217
216
  patterns = [_to_sql_like_pattern(str(v)) for v in vals]
218
217
  predicates.append(or_(*[col.ilike(p) for p in patterns]))
219
218
  else:
220
- coerced = [coerce(v) for v in vals]
219
+ coerced = [coerce(v) for v in vals if v is not None]
221
220
  if not coerced:
222
221
  continue
223
- predicates.append(col.in_(coerced))
222
+
223
+ if op_hint == "eq":
224
+ predicates.append(col.in_(coerced))
225
+ elif op_hint == "ne":
226
+ predicates.append(or_(*[col != v for v in coerced]))
227
+ elif op_hint == "gt":
228
+ predicates.append(or_(*[col > v for v in coerced]))
229
+ elif op_hint == "gte":
230
+ predicates.append(or_(*[col >= v for v in coerced]))
231
+ elif op_hint == "lt":
232
+ predicates.append(or_(*[col < v for v in coerced]))
233
+ elif op_hint == "lte":
234
+ predicates.append(or_(*[col <= v for v in coerced]))
235
+ else:
236
+ predicates.append(col.in_(coerced))
224
237
  else:
225
238
  if _use_ilike(value) and _is_text_type(coltype):
226
239
  pattern = _to_sql_like_pattern(str(value))
@@ -228,9 +241,25 @@ def _apply_filters(pagination, entity, query):
228
241
  else:
229
242
  v = coerce(value)
230
243
  if v is None:
231
- predicates.append(col.is_(None))
244
+ if op_hint in {"eq", "ilike"}:
245
+ predicates.append(col.is_(None))
246
+ else:
247
+ continue
232
248
  else:
233
- predicates.append(col == v)
249
+ if op_hint == "eq":
250
+ predicates.append(col == v)
251
+ elif op_hint == "ne":
252
+ predicates.append(col != v)
253
+ elif op_hint == "gt":
254
+ predicates.append(col > v)
255
+ elif op_hint == "gte":
256
+ predicates.append(col >= v)
257
+ elif op_hint == "lt":
258
+ predicates.append(col < v)
259
+ elif op_hint == "lte":
260
+ predicates.append(col <= v)
261
+ else:
262
+ predicates.append(col == v)
234
263
 
235
264
  except _InvalidEnum as e:
236
265
  raise UnprocessableEntityException(str(e), loc=["query", "filters", raw_field]) from e
@@ -304,13 +333,13 @@ def _serialize_item(item: Any, output_cls: OutputType) -> Any:
304
333
 
305
334
 
306
335
  def get_paginated_response(
307
- pagination,
308
- entity,
309
- session: Session,
310
- *,
311
- with_count: bool = True,
312
- output_cls: Optional[Type] = None,
313
- eager_options: Optional[Sequence[LoaderOption]] = None,
336
+ pagination,
337
+ entity,
338
+ session: Session,
339
+ *,
340
+ with_count: bool = True,
341
+ output_cls: Optional[Type] = None,
342
+ eager_options: Optional[Sequence[LoaderOption]] = None,
314
343
  ) -> dict:
315
344
  query = session.query(entity)
316
345
  if eager_options:
@@ -1,6 +1,6 @@
1
1
  from decimal import Decimal
2
2
  from enum import Enum
3
- from typing import Dict, List, Literal, Optional, Union
3
+ from typing import Any, Dict, List, Literal, Optional, Union
4
4
 
5
5
  import httpx
6
6
  import requests
@@ -16,7 +16,7 @@ class PricingError(Exception):
16
16
 
17
17
 
18
18
  class CalculatePriceIn(BaseModel):
19
- params: dict[str, str]
19
+ params: dict[str, Any]
20
20
  product_id: NonNegativeInt | None = None
21
21
  product_title: str | None = None
22
22
 
@@ -51,8 +51,8 @@ class Formula(BaseModel):
51
51
  id: int
52
52
  title: str
53
53
  key: str
54
- status: str
55
- description: str
54
+ status: str | None
55
+ description: str | None
56
56
 
57
57
 
58
58
  class PricingRule(BaseModel):
@@ -35,11 +35,11 @@ def _filename_from_cd(cd: str | None, fallback: str) -> str:
35
35
 
36
36
  class UploadchiClient(FileServicePort):
37
37
  def __init__(
38
- self,
39
- token_provider: ClientTokenProvider | None = None,
40
- base_url: Optional[str] = None,
41
- timeout_seconds: Optional[float] = None,
42
- client: httpx.Client | None = None,
38
+ self,
39
+ token_provider: ClientTokenProvider | None = None,
40
+ base_url: Optional[str] = None,
41
+ timeout_seconds: Optional[float] = None,
42
+ client: httpx.Client | None = None,
43
43
  ) -> None:
44
44
  s = get_settings()
45
45
  self._base_url = normalize_https_base(base_url or str(s.UPLOADCHI_BASE_URL))
@@ -51,7 +51,7 @@ class UploadchiClient(FileServicePort):
51
51
  self._client.close()
52
52
 
53
53
  def upload_file(
54
- self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
54
+ self, file_bytes: bytes, filename: str, params: dict[str, Any] | None = None, token: str | None = None
55
55
  ) -> dict:
56
56
  tok = _resolve_token(token)
57
57
  files = {"file": (filename, file_bytes)}
@@ -84,12 +84,12 @@ class UploadchiClient(FileServicePort):
84
84
  raise UploadchiError(r.status_code, r.text)
85
85
 
86
86
  def list_files(
87
- self,
88
- limit: int = 10,
89
- offset: int = 0,
90
- filters: dict[str, Any] | None = None,
91
- sort: list[tuple[str, str]] | None = None,
92
- token: str | None = None,
87
+ self,
88
+ limit: int = 10,
89
+ offset: int = 0,
90
+ filters: dict[str, Any] | None = None,
91
+ sort: list[tuple[str, str]] | None = None,
92
+ token: str | None = None,
93
93
  ) -> dict:
94
94
  tok = _resolve_token(token)
95
95
  q = build_list_query(limit, offset, filters, sort)
@@ -116,7 +116,8 @@ class UploadchiClient(FileServicePort):
116
116
 
117
117
  def delete_file(self, file_id: str, token: str | None = None) -> None:
118
118
  tok = _resolve_token(token)
119
- r = self._client.delete(f"{self._base_url}/{file_id}",
120
- headers=auth_headers(tok or self._token_provider.get_access_token()))
119
+ r = self._client.delete(
120
+ f"{self._base_url}/{file_id}", headers=auth_headers(tok or self._token_provider.get_access_token())
121
+ )
121
122
  if r.status_code not in (204, 200):
122
123
  raise UploadchiError(r.status_code, r.text)
@@ -9,6 +9,8 @@ def get_es_client():
9
9
  es = Elasticsearch(
10
10
  setting.ELASTIC_PERCOLATE_URL,
11
11
  basic_auth=(setting.ELASTIC_PERCOLATE_USER, setting.ELASTIC_PERCOLATE_PASS.get_secret_value().strip()),
12
- http_compress=True, max_retries=2, retry_on_timeout=True
12
+ http_compress=True,
13
+ max_retries=2,
14
+ retry_on_timeout=True,
13
15
  )
14
16
  return es
@@ -0,0 +1,7 @@
1
+ from nlbone.interfaces.api.additional_filed.assembler import assemble_response
2
+ from nlbone.interfaces.api.additional_filed.field_registry import FieldRule, ResourceRegistry
3
+ from nlbone.interfaces.api.additional_filed.resolver import (
4
+ AdditionalFieldsRequest,
5
+ build_query_plan,
6
+ resolve_requested_fields,
7
+ )
@@ -0,0 +1,54 @@
1
+ import inspect
2
+ from typing import Any, Dict
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from nlbone.container import Container
7
+ from nlbone.interfaces.api.additional_filed.field_registry import FieldRule, ResourceRegistry
8
+
9
+
10
+ def assemble_response(
11
+ obj: Any,
12
+ reg: ResourceRegistry,
13
+ selected_rules: Dict[str, FieldRule],
14
+ base_schema: type[BaseModel] | None,
15
+ session,
16
+ ) -> Dict[str, Any]:
17
+ base = {f: getattr(obj, f, None) for f in reg.default_fields - set(reg.rules.keys())}
18
+ if base_schema:
19
+ base = base_schema.model_validate(base).model_dump()
20
+
21
+ ctx = {"file_service": Container.file_service(), "entity": obj, "db": session}
22
+ for name, rule in selected_rules.items():
23
+ if rule.loader:
24
+ value = inject_dependencies(rule.loader, dependencies=ctx)
25
+ else:
26
+ value = _get_nested_attr(obj, name)
27
+ _put_nested_key(base, name, value)
28
+
29
+ return base
30
+
31
+
32
+ def inject_dependencies(handler, dependencies):
33
+ params = inspect.signature(handler).parameters
34
+ deps = {name: dependency for name, dependency in dependencies.items() if name in params}
35
+ return handler(**deps)
36
+
37
+
38
+ def _get_nested_attr(obj: Any, dotted: str):
39
+ cur = obj
40
+ for part in dotted.split("."):
41
+ if cur is None:
42
+ return None
43
+ cur = getattr(cur, part, None)
44
+ return cur
45
+
46
+
47
+ def _put_nested_key(base: Dict[str, Any], dotted: str, value: Any):
48
+ parts = dotted.split(".")
49
+ cur = base
50
+ for p in parts[:-1]:
51
+ if p not in cur or not isinstance(cur[p], dict):
52
+ cur[p] = {}
53
+ cur = cur[p]
54
+ cur[parts[-1]] = value
@@ -0,0 +1 @@
1
+ from .image_field_rules import IMAGE_FILED_RULE
@@ -0,0 +1,13 @@
1
+ from nlbone.interfaces.api.additional_filed import FieldRule
2
+
3
+
4
+ def get_image(entity, file_service):
5
+ image_id = entity.image_id
6
+ if image_id:
7
+ try:
8
+ return file_service.get_file(image_id)
9
+ except Exception:
10
+ return None
11
+
12
+
13
+ IMAGE_FILED_RULE = FieldRule(default=True, name="image", loader=get_image)
@@ -0,0 +1,105 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+ from typing import Any, Callable, Dict, Iterable, Optional, Set, Tuple, Type
4
+
5
+ from pydantic import BaseModel
6
+
7
+ PermissionChecker = Callable[[Any, str], bool]
8
+ Loader = Callable
9
+
10
+
11
+ def _schema_fields(schema: Type[BaseModel], by_alias: bool = True) -> Set[str]:
12
+ names = set()
13
+ for name, f in schema.model_fields.items():
14
+ names.add(f.alias or name if by_alias else name)
15
+ return names
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class FieldRule:
20
+ """
21
+ name: field name (nested with dot)
22
+ permission: None for public
23
+ deps: dependencies
24
+ columns: relationship (SQLAlchemy Column/InstrumentedAttribute)
25
+ join_paths: required paths for eager-load (ex. Product.supplier, Supplier.address)
26
+ loader:
27
+ """
28
+
29
+ name: str
30
+ default: bool = False
31
+ permission: Optional[str] = None
32
+ deps: Tuple[str, ...] = ()
33
+ columns: Tuple[Any, ...] = ()
34
+ join_paths: Tuple[Any, ...] = ()
35
+ loader: Optional[Loader] = None
36
+
37
+
38
+ class DefaultsMergeMode(str, Enum):
39
+ STRICT = "strict" # schema == rules; else raise
40
+ SCHEMA_PRIORITY = "schema" # defaults = schema_fields
41
+ RULES_PRIORITY = "rules" # defaults = rules_default_fields
42
+ UNION = "union" # defaults = schema ∪ rules
43
+ INTERSECTION = "intersection" # defaults = schema ∩ rules
44
+
45
+
46
+ @dataclass
47
+ class ResourceRegistry:
48
+ resource: str
49
+ default_fields: Set[str] = field(default_factory=set)
50
+ base_schema: Optional[Type[BaseModel]] = None
51
+ rules: Dict[str, FieldRule] = field(default_factory=dict)
52
+ bundles: Dict[str, Set[str]] = field(default_factory=dict)
53
+ _schema_fields_cache: Set[str] = field(default_factory=set, repr=False)
54
+
55
+ def add_rule(self, rule: FieldRule) -> "ResourceRegistry":
56
+ self.rules[rule.name] = rule
57
+ return self
58
+
59
+ def add_bundle(self, bundle: str, items: Iterable[str]) -> "ResourceRegistry":
60
+ self.bundles[bundle] = set(items)
61
+ return self
62
+
63
+ def set_defaults(self, fields: Iterable[str]) -> "ResourceRegistry":
64
+ self.default_fields = set(fields)
65
+ return self
66
+
67
+ def recompute_defaults_from_rules(self) -> "ResourceRegistry":
68
+ self.default_fields = {r.name for r in self.rules.values() if r.default}
69
+ return self
70
+
71
+ def from_paydantc(self, schema: Type[BaseModel], *, by_alias: bool = True) -> "ResourceRegistry":
72
+ self.base_schema = schema
73
+ self._schema_fields_cache = _schema_fields(schema, by_alias=by_alias)
74
+ return self
75
+
76
+ def finalize_defaults(self, mode: DefaultsMergeMode = DefaultsMergeMode.UNION) -> "ResourceRegistry":
77
+ schema_set: Set[str] = self._schema_fields_cache if self.base_schema else set()
78
+ rules_set: Set[str] = {r.name for r in self.rules.values() if r.default}
79
+
80
+ manual_set: Set[str] = set(self.default_fields)
81
+
82
+ if mode == DefaultsMergeMode.STRICT:
83
+ if self.base_schema is None or not rules_set:
84
+ base = schema_set or rules_set
85
+ else:
86
+ if schema_set != rules_set:
87
+ raise ValueError(
88
+ f"[{self.resource}] Default fields diverged: "
89
+ f"schema={sorted(schema_set)} vs rules={sorted(rules_set)}"
90
+ )
91
+ base = schema_set
92
+ elif mode == DefaultsMergeMode.SCHEMA_PRIORITY:
93
+ base = schema_set or rules_set
94
+ elif mode == DefaultsMergeMode.RULES_PRIORITY:
95
+ base = rules_set or schema_set
96
+ elif mode == DefaultsMergeMode.UNION:
97
+ base = schema_set | rules_set
98
+ elif mode == DefaultsMergeMode.INTERSECTION:
99
+ base = schema_set & rules_set
100
+ else:
101
+ base = schema_set or rules_set
102
+ final = base | manual_set
103
+
104
+ self.default_fields = final
105
+ return self
@@ -0,0 +1,132 @@
1
+ from typing import Dict, List, Set, Tuple
2
+
3
+ from nlbone.interfaces.api.additional_filed.field_registry import (
4
+ DefaultsMergeMode,
5
+ FieldRule,
6
+ PermissionChecker,
7
+ ResourceRegistry,
8
+ )
9
+ from nlbone.interfaces.api.dependencies.auth import client_or_user_has_access_func
10
+ from nlbone.interfaces.api.exceptions import BadRequestException, InternalServerException
11
+
12
+ MAX_FIELDS = 50
13
+ MAX_BUNDLES = 20
14
+
15
+
16
+ class AdditionalFieldsRequest:
17
+ """
18
+ FastAPI dependency
19
+ - fields: ?fields=rating,costPrice,supplier.address.city
20
+ - bundles: ?bundles=@analytics,@internal
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ fields: str | None = None,
26
+ bundles: str | None = None,
27
+ ) -> None:
28
+ self.fields = self._parse_csv(fields)[:MAX_FIELDS]
29
+ self.bundles = self._parse_csv(bundles)[:MAX_BUNDLES]
30
+
31
+ @staticmethod
32
+ def _parse_csv(s: str | None) -> List[str]:
33
+ if not s:
34
+ return []
35
+ return [x.strip() for x in s.split(",") if x.strip()]
36
+
37
+
38
+ def resolve_requested_fields(
39
+ reg: ResourceRegistry,
40
+ additional_fields: AdditionalFieldsRequest = None,
41
+ can: PermissionChecker = None,
42
+ mode: DefaultsMergeMode = DefaultsMergeMode.UNION,
43
+ ) -> Tuple[Set[str], Dict[str, FieldRule]]:
44
+ if not additional_fields:
45
+ additional_fields = AdditionalFieldsRequest()
46
+ if not can:
47
+ can = client_or_user_has_access_func
48
+ reg.finalize_defaults(mode=mode)
49
+ expanded_from_bundles = _expand_bundles(reg, set(additional_fields.bundles))
50
+
51
+ requested = set(reg.default_fields) | set(additional_fields.fields) | expanded_from_bundles
52
+
53
+ # validation
54
+ unknown = {f for f in requested if (f not in reg.default_fields and f not in reg.rules)}
55
+ if unknown:
56
+ raise BadRequestException(f"unknown_fields: {sorted(unknown)}")
57
+
58
+ # permission
59
+ selected_rules: Dict[str, FieldRule] = {}
60
+ for f in requested:
61
+ rule = reg.rules.get(f)
62
+ if rule and rule.permission:
63
+ try:
64
+ if not can(rule.permission):
65
+ continue
66
+ except Exception:
67
+ continue
68
+
69
+ if f in reg.default_fields and f not in list(reg.rules.keys()):
70
+ continue
71
+
72
+ if not rule.default and f not in additional_fields.fields:
73
+ continue
74
+ selected_rules[f] = rule
75
+
76
+ # dependencies
77
+ final = set(requested)
78
+
79
+ def add_deps(name: str):
80
+ if name in reg.default_fields:
81
+ return
82
+ r = reg.rules.get(name)
83
+ if not r:
84
+ return
85
+ for d in r.deps:
86
+ final.add(d)
87
+ add_deps(d)
88
+
89
+ for f in list(selected_rules.keys()):
90
+ add_deps(f)
91
+
92
+ # validate deps too
93
+ unknown_deps = {d for d in final if (d not in reg.default_fields and d not in reg.rules)}
94
+ if unknown_deps:
95
+ raise InternalServerException(f"registry_missing_rules_for: {sorted(unknown_deps)}")
96
+
97
+ # rules
98
+ return final, selected_rules
99
+
100
+
101
+ def _expand_bundles(reg: ResourceRegistry, bundles: Set[str]) -> Set[str]:
102
+ out: Set[str] = set()
103
+ seen_bundles: Set[str] = set()
104
+
105
+ def dfs(b: str):
106
+ if b not in reg.bundles:
107
+ raise BadRequestException(f"Unknown bundle: {b}")
108
+ if b in seen_bundles:
109
+ return
110
+ seen_bundles.add(b)
111
+ for item in reg.bundles[b]:
112
+ if item.startswith("@"):
113
+ dfs(item)
114
+ else:
115
+ out.add(item)
116
+
117
+ for b in bundles:
118
+ b = b.lstrip("@")
119
+ dfs(b)
120
+ return out
121
+
122
+
123
+ def build_query_plan(
124
+ reg: ResourceRegistry,
125
+ selected_rules: Dict[str, FieldRule],
126
+ ):
127
+ columns = []
128
+ joins = []
129
+ for r in selected_rules.values():
130
+ columns.extend(r.columns or [])
131
+ joins.extend(r.join_paths or [])
132
+ return columns, joins
@@ -20,14 +20,18 @@ def current_client_id() -> str:
20
20
  raise UnauthorizedException()
21
21
 
22
22
 
23
+ def client_has_access_func(*, permissions=None):
24
+ request = current_request()
25
+ if not KeycloakAuthService().client_has_access(request.state.token, permissions=permissions):
26
+ raise ForbiddenException(f"Forbidden {permissions}")
27
+ return True
28
+
29
+
23
30
  def client_has_access(*, permissions=None):
24
31
  def decorator(func):
25
32
  @functools.wraps(func)
26
33
  def wrapper(*args, **kwargs):
27
- request = current_request()
28
- if not KeycloakAuthService().client_has_access(request.state.token, permissions=permissions):
29
- raise ForbiddenException(f"Forbidden {permissions}")
30
-
34
+ client_has_access_func(permissions=permissions)
31
35
  return func(*args, **kwargs)
32
36
 
33
37
  return wrapper
@@ -45,18 +49,22 @@ def user_authenticated(func):
45
49
  return wrapper
46
50
 
47
51
 
52
+ def user_has_access_func(*, permissions=None):
53
+ request = current_request()
54
+ if not current_user_id():
55
+ raise UnauthorizedException()
56
+ user_permissions = get_auth_service().get_permissions(request.state.token)
57
+ for p in permissions or []:
58
+ if p not in user_permissions:
59
+ raise ForbiddenException(f"Forbidden {permissions}")
60
+ return True
61
+
62
+
48
63
  def has_access(*, permissions=None):
49
64
  def decorator(func):
50
65
  @functools.wraps(func)
51
66
  def wrapper(*args, **kwargs):
52
- request = current_request()
53
- if not current_user_id():
54
- raise UnauthorizedException()
55
- user_permissions = get_auth_service().get_permissions(request.state.token)
56
- for p in permissions or []:
57
- if p not in user_permissions:
58
- raise ForbiddenException(f"Forbidden {permissions}")
59
-
67
+ user_has_access_func(permissions=permissions)
60
68
  return func(*args, **kwargs)
61
69
 
62
70
  return wrapper
@@ -64,30 +72,23 @@ def has_access(*, permissions=None):
64
72
  return decorator
65
73
 
66
74
 
75
+ def client_or_user_has_access_func(permissions=None, client_permissions=None):
76
+ request = current_request()
77
+ token = getattr(request.state, "token", None)
78
+ if not token:
79
+ raise UnauthorizedException()
80
+ needed = client_permissions or permissions
81
+ if client_has_access_func(permissions=needed):
82
+ return
83
+ if user_has_access_func(permissions=needed):
84
+ return
85
+
86
+
67
87
  def client_or_user_has_access(*, permissions=None, client_permissions=None):
68
88
  def decorator(func):
69
89
  @functools.wraps(func)
70
90
  def wrapper(*args, **kwargs):
71
- request = current_request()
72
- token = getattr(request.state, "token", None)
73
- if not token:
74
- raise UnauthorizedException()
75
-
76
- auth = KeycloakAuthService()
77
-
78
- if auth.get_client_id(token):
79
- needed = client_permissions or permissions
80
- if not auth.client_has_access(token, permissions=needed):
81
- raise ForbiddenException(f"Forbidden (client) {needed}")
82
- else:
83
- if not current_user_id():
84
- raise UnauthorizedException()
85
-
86
- user_permissions = get_auth_service().get_permissions(request.state.token)
87
- for p in permissions or []:
88
- if p not in user_permissions:
89
- raise ForbiddenException(f"Forbidden {permissions}")
90
-
91
+ client_or_user_has_access_func(permissions=permissions, client_permissions=client_permissions)
91
92
  return func(*args, **kwargs)
92
93
 
93
94
  return wrapper
@@ -67,6 +67,14 @@ class NotSupportedException(BaseHttpException):
67
67
  )
68
68
 
69
69
 
70
+ class InternalServerException(BaseHttpException):
71
+ def __init__(self, detail: str = "internal_server_error"):
72
+ super().__init__(
73
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
74
+ detail=detail,
75
+ )
76
+
77
+
70
78
  class UnprocessableEntityException(BaseHttpException):
71
79
  def __init__(self, detail: str, loc: Iterable[Any] | None = None, type_: str = "unprocessable_entity"):
72
80
  super().__init__(
@@ -14,6 +14,6 @@ T = TypeVar("T")
14
14
 
15
15
 
16
16
  class Paginated(BaseModel, Generic[T]):
17
- total_count: Optional[int]
18
- total_page: Optional[int]
17
+ total_count: Optional[int] = None
18
+ total_page: Optional[int] = None
19
19
  data: List[T]
@@ -1,6 +1,6 @@
1
1
  import json
2
2
  from math import ceil
3
- from typing import Any, Optional, List
3
+ from typing import Any, List, Optional
4
4
 
5
5
  from fastapi import Query
6
6
 
@@ -13,18 +13,20 @@ class PaginateRequest:
13
13
  """
14
14
 
15
15
  def __init__(
16
- self,
17
- limit: int = 10,
18
- offset: int = 0,
19
- sort: Optional[str] = None,
20
- filters: Optional[str] = Query(None, description="e.g. title:abc"),
21
- include: Optional[str] = None,
16
+ self,
17
+ limit: int = 10,
18
+ offset: int = 0,
19
+ sort: Optional[str] = None,
20
+ filters: Optional[str] = Query(None, description="e.g. title:abc"),
21
+ include: Optional[str] = None,
22
22
  ) -> None:
23
23
  self.limit = max(0, limit)
24
24
  self.offset = max(0, offset)
25
25
  self.sort = self._parse_sort(sort)
26
26
  self.filters = self._parse_filters(filters or "")
27
- self.include_ids: List[int] = ([int(x) for x in include.split(",") if x.strip().isdigit()] if include else [])[:50]
27
+ self.include_ids: List[int] = ([int(x) for x in include.split(",") if x.strip().isdigit()] if include else [])[
28
+ :50
29
+ ]
28
30
 
29
31
  @staticmethod
30
32
  def _parse_sort(sort_str: Optional[str]) -> list[dict[str, str]]:
@@ -83,12 +85,12 @@ class PaginateResponse:
83
85
  """
84
86
 
85
87
  def __init__(
86
- self,
87
- data: list[Any],
88
- total_count: int | None,
89
- limit: int,
90
- offset: int,
91
- use_data_key: bool = True,
88
+ self,
89
+ data: list[Any],
90
+ total_count: int | None,
91
+ limit: int,
92
+ offset: int,
93
+ use_data_key: bool = True,
92
94
  ) -> None:
93
95
  self.data = data
94
96
  self.total_count = total_count
@@ -0,0 +1,2 @@
1
+ from .adaptive_schema import AdaptiveSchemaBase, ResponsePreference
2
+ from .base_response_model import BaseResponseModel
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from enum import Enum
5
+ from typing import Any, Iterable, Optional, Type, TypeVar, Union, get_args, get_origin
6
+
7
+ from pydantic import BaseModel, TypeAdapter
8
+
9
+
10
+ class ResponsePreference(str, Enum):
11
+ minimal = "minimal"
12
+ lite = "lite"
13
+ full = "full"
14
+
15
+
16
+ M = TypeVar("M", bound=BaseModel)
17
+
18
+
19
+ class AdaptiveSchemaBase(ABC):
20
+ """Base for a *family* of Pydantic schemas of the same resource."""
21
+
22
+ @classmethod
23
+ @abstractmethod
24
+ def minimal(cls) -> Type[M]: ...
25
+ @classmethod
26
+ @abstractmethod
27
+ def lite(cls) -> Type[M]: ...
28
+ @classmethod
29
+ @abstractmethod
30
+ def full(cls) -> Type[M]: ...
31
+
32
+ @classmethod
33
+ def choose(cls, pref: Optional[ResponsePreference]) -> Type[M]:
34
+ if pref is None:
35
+ pref = ResponsePreference.lite
36
+ return {
37
+ ResponsePreference.minimal: cls.minimal(),
38
+ ResponsePreference.lite: cls.lite(),
39
+ ResponsePreference.full: cls.full(),
40
+ }.get(pref, cls.lite())
41
+
42
+ @classmethod
43
+ def serialize(cls, obj: Any, pref: Optional[ResponsePreference] = None) -> Any:
44
+ schema = cls.choose(pref)
45
+ adapter = TypeAdapter(schema)
46
+
47
+ if _is_iterable(obj):
48
+ return [cls._serialize_one(adapter, schema, x, pref) for x in obj]
49
+ return cls._serialize_one(adapter, schema, obj, pref)
50
+
51
+ @classmethod
52
+ def _serialize_one(
53
+ cls, adapter: TypeAdapter, schema_cls: Type[BaseModel], item: Any, pref: Optional[ResponsePreference]
54
+ ) -> dict:
55
+ model = adapter.validate_python(item)
56
+ data = model.model_dump()
57
+
58
+ annotations = getattr(schema_cls, "__annotations__", {})
59
+ for field_name, annotated_type in annotations.items():
60
+ if field_name not in data:
61
+ continue
62
+ value = getattr(item, field_name, None)
63
+ if value is None:
64
+ continue
65
+
66
+ if _is_adaptive(annotated_type):
67
+ # تک‌شیء
68
+ data[field_name] = annotated_type.serialize(value, pref)
69
+ elif _is_list_of_adaptive(annotated_type):
70
+ data[field_name] = annotated_type.__args__[0].serialize(value, pref)
71
+ elif _is_optional_of_adaptive(annotated_type):
72
+ inner = _unwrap_optional(annotated_type)
73
+ data[field_name] = None if value is None else inner.serialize(value, pref)
74
+
75
+ return data
76
+
77
+
78
+ # ---------- helpers ----------
79
+ def _is_iterable(x: Any) -> bool:
80
+ if isinstance(x, (str, bytes, dict)):
81
+ return False
82
+ try:
83
+ iter(x)
84
+ return True
85
+ except TypeError:
86
+ return False
87
+
88
+
89
+ def _is_adaptive(t) -> bool:
90
+ return isinstance(t, type) and issubclass(t, AdaptiveSchemaBase)
91
+
92
+
93
+ def _is_list_of_adaptive(t) -> bool:
94
+ origin = get_origin(t)
95
+ if origin not in (
96
+ list,
97
+ list.__class__,
98
+ Iterable,
99
+ ):
100
+ return False
101
+ args = get_args(t)
102
+ return len(args) == 1 and _is_adaptive(args[0])
103
+
104
+
105
+ def _is_optional_of_adaptive(t) -> bool:
106
+ return (
107
+ get_origin(t) is Union
108
+ and len([a for a in get_args(t) if a is not type(None)]) == 1
109
+ and _is_adaptive([a for a in get_args(t) if a is not type(None)][0])
110
+ )
111
+
112
+
113
+ def _unwrap_optional(t):
114
+ return [a for a in get_args(t) if a is not type(None)][0]
@@ -0,0 +1,22 @@
1
+
2
+ from pydantic import BaseModel, ConfigDict, model_serializer
3
+
4
+ EXCLUDE_NONE = "exclude_none"
5
+
6
+
7
+ class BaseResponseModel(BaseModel):
8
+ model_config = ConfigDict(from_attributes=True)
9
+
10
+ @model_serializer(mode="wrap")
11
+ def _auto_hide_none_fields(self, handler):
12
+ data = handler(self)
13
+
14
+ hide_candidates = {
15
+ name for name, f in self.model_fields.items() if (f.json_schema_extra or {}).get(EXCLUDE_NONE) is True
16
+ }
17
+
18
+ for key in list(data.keys()):
19
+ if key in hide_candidates and data[key] is None:
20
+ data.pop(key)
21
+
22
+ return data
@@ -0,0 +1,5 @@
1
+ from nlbone.interfaces.api.schema import BaseResponseModel
2
+
3
+
4
+ class FileOut(BaseResponseModel):
5
+ url: str
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nlbone
3
- Version: 0.6.13
3
+ Version: 0.6.15
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,7 +3,7 @@ nlbone/container.py,sha256=rVYzH-jIM8iCcefDOo29mNjvFdf3nJ4EtPNUws9SDnA,3089
3
3
  nlbone/types.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  nlbone/adapters/__init__.py,sha256=NzUmk4XPyp3GJOw7VSE86xkQMZLtG3MrOoXLeoB551M,41
5
5
  nlbone/adapters/auth/__init__.py,sha256=hkDHvsFhw_UiOHG9ZSMqjiAhK4wumEforitveSZswVw,42
6
- nlbone/adapters/auth/keycloak.py,sha256=ZGWeq9by2onb440kNRm4BdcxnO7N1qDPmg8UUrXrUrQ,4924
6
+ nlbone/adapters/auth/keycloak.py,sha256=IhEriaFl5mjIGT6ZUCU9qROd678ARchvWgd4UJ6zH7s,4925
7
7
  nlbone/adapters/auth/token_provider.py,sha256=vL2Hk6HXnBbpk40Tq1wpqak5QQ7KEQf3nRquT0N8V4Q,1433
8
8
  nlbone/adapters/cache/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  nlbone/adapters/cache/async_redis.py,sha256=vvu5w4ANx0BVRHL95RAMGsD8CcaC-tSBMbCius2cuNc,6212
@@ -15,7 +15,7 @@ nlbone/adapters/db/postgres/__init__.py,sha256=6JYJH0xZs3aR-zuyMpRhsdzFugmqz8npr
15
15
  nlbone/adapters/db/postgres/audit.py,sha256=8f5XOuW7_ybJyy_STam1FNzqmZAAVAu7tmMRUkCGJOM,4594
16
16
  nlbone/adapters/db/postgres/base.py,sha256=kha9xmklzhuQAK8QEkNBn-mAHq8dUKbOM-3abaBpWmQ,71
17
17
  nlbone/adapters/db/postgres/engine.py,sha256=UCegauVB1gvo42ThytYnn5VIcQBwR-5xhcXYFApRFNk,3448
18
- nlbone/adapters/db/postgres/query_builder.py,sha256=SNNkmoMuzcuOwxZlTlplAGqJ0mDQevixLOruqHPa8zM,10855
18
+ nlbone/adapters/db/postgres/query_builder.py,sha256=V-Eb98xi9ajhJaEAW1BMtZot9A3muZa5gO_NRhYR0bQ,12505
19
19
  nlbone/adapters/db/postgres/repository.py,sha256=J_DBE73JhHPYCk90c5-O7lQtZbxDgqjjN9OcWy4Omvs,1660
20
20
  nlbone/adapters/db/postgres/schema.py,sha256=NlE7Rr8uXypsw4oWkdZhZwcIBHQEPIpoHLxcUo98i6s,1039
21
21
  nlbone/adapters/db/postgres/uow.py,sha256=nRxNpY-WoWHpym-XeZ8VHm0MYvtB9wuopOeNdV_ebk8,2088
@@ -23,15 +23,15 @@ nlbone/adapters/db/redis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZ
23
23
  nlbone/adapters/db/redis/client.py,sha256=5SUnwP2-GrueSFimUbiqDvrQsumvIE2aeozk8l-vOfQ,466
24
24
  nlbone/adapters/http_clients/__init__.py,sha256=w-Yr9CLuXMU71N0Ada5HbvP1DB53wqeP6B-i5rALlTo,150
25
25
  nlbone/adapters/http_clients/pricing/__init__.py,sha256=ElA9NFcAR9u4cqb_w3PPqKU3xGeyjNLQ8veJ0ql2iz0,81
26
- nlbone/adapters/http_clients/pricing/pricing_service.py,sha256=PDG6CbLg_SL-BsrhNwfyypywcuZIsEyj5mpQGSPH4e4,3300
26
+ nlbone/adapters/http_clients/pricing/pricing_service.py,sha256=fYfMQh3qW_YDNkBW19RkDkIet7zUDzb2HsIT5dr1Q7Y,3319
27
27
  nlbone/adapters/http_clients/uploadchi/__init__.py,sha256=uBzEOuVtY22teWW2b36Pitkdk5yVdSqa6xbg22JfTNg,105
28
- nlbone/adapters/http_clients/uploadchi/uploadchi.py,sha256=oOkjDA7MMGe7HNl7qgoPbeV_EI5PNIx1yidsxvnkhis,4939
28
+ nlbone/adapters/http_clients/uploadchi/uploadchi.py,sha256=erpjOees25FW0nuK1PkYS-oU0h8MeRV9Rhs1cf3gaEs,4881
29
29
  nlbone/adapters/http_clients/uploadchi/uploadchi_async.py,sha256=PQbVNeaYde5CmgT3vcnQoI1PGeSs9AxHlPFuB8biOmU,4717
30
30
  nlbone/adapters/messaging/__init__.py,sha256=UDAwu3s-JQmOZjWz2Nu0SgHhnkbeOhKDH_zLD75oWMY,40
31
31
  nlbone/adapters/messaging/event_bus.py,sha256=w-NPwDiPMLFPU_enRQCtfQXOALsXfg31u57R8sG_-1U,781
32
32
  nlbone/adapters/messaging/redis.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
33
  nlbone/adapters/percolation/__init__.py,sha256=0h1Bw7FzxgkDIHxeoyQXSfegrhP6VbpYV4QC8njYdRE,38
34
- nlbone/adapters/percolation/connection.py,sha256=KUlYPFBXyjv_IEt8zgwdNKynl4VnzL7bp-hcll48Z2w,398
34
+ nlbone/adapters/percolation/connection.py,sha256=1iJISSwMEh4r_6nJI7mYf_v64Q0eeU1eSI0wLIfOK14,415
35
35
  nlbone/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
36
  nlbone/config/logging.py,sha256=Ot6Ctf7EQZlW8YNB-uBdleqI6wixn5fH0Eo6QRgNkQk,4358
37
37
  nlbone/config/settings.py,sha256=W3NHZP6yjIyyKiGWNkjlUt_RYFKkcIfMBoKih_z_0Bs,3911
@@ -57,20 +57,29 @@ nlbone/core/ports/uow.py,sha256=SmBdRf0NvSdIjQ3Le1QGz8kNGBk7jgNHtNguvXRwmgs,557
57
57
  nlbone/interfaces/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
58
58
  nlbone/interfaces/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
59
  nlbone/interfaces/api/exception_handlers.py,sha256=vxNEBgAaQygLgAz1UNt3wHj0SdCJOwtLOv_BwTfir3o,4050
60
- nlbone/interfaces/api/exceptions.py,sha256=6zWkMnmNGaSa3Myk3MxPCFje0yWWDcbqivIeOKxZuhs,2258
60
+ nlbone/interfaces/api/exceptions.py,sha256=mvpfTf_CUy1V3Y1iGLMGJh_yWfNlJs_ubrYtJmpNW7o,2499
61
61
  nlbone/interfaces/api/routers.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
- nlbone/interfaces/api/schemas.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
+ nlbone/interfaces/api/schemas.py,sha256=NIEKeTdJtwwIkIxL7WURNZF8g34I4TlRAqs-x1Uq7YI,108
63
+ nlbone/interfaces/api/additional_filed/__init__.py,sha256=BWemliLSQV9iq1vdUaF733q0FOSipSWBOQk9eYj732Q,318
64
+ nlbone/interfaces/api/additional_filed/assembler.py,sha256=MyB6YimAAzMe5WUZtHei7a4ITsOpYhrDWIWNAKq7tY8,1599
65
+ nlbone/interfaces/api/additional_filed/field_registry.py,sha256=ScpMsjDBkuUdh_FaBmk1EXBeydERcNthyom1tcJK3BM,3820
66
+ nlbone/interfaces/api/additional_filed/resolver.py,sha256=eVBPj6WsUkZbM3wGPyexwA6d5GThCSgbwNzzHImaOyU,3767
67
+ nlbone/interfaces/api/additional_filed/default_field_rules/__init__.py,sha256=LUSAOO3xRUt5ptlraIx7H-7dSkdr1D-WprmnqXRB16g,48
68
+ nlbone/interfaces/api/additional_filed/default_field_rules/image_field_rules.py,sha256=ecKqPeXZ-YiF14RK9PmK7ln3PCzpCUc18S5zm5IF3fw,339
63
69
  nlbone/interfaces/api/dependencies/__init__.py,sha256=rnYRrFVZCfICQrp_PVFlzNg3BeC57yM08wn2DbOHCfk,359
64
70
  nlbone/interfaces/api/dependencies/async_auth.py,sha256=bfxgBXhp29WqevjTG4jrdPNR-75APm4jKyHdOOtxnp4,1825
65
- nlbone/interfaces/api/dependencies/auth.py,sha256=D3D-UA2fMtEU-pSQNWk4cb3W74rXiz4a8u1reI7Wrkk,3001
71
+ nlbone/interfaces/api/dependencies/auth.py,sha256=SVqdH78ek65MdT-81_tJo9QiEbnERAh9dR10pRdTDb8,2833
66
72
  nlbone/interfaces/api/dependencies/db.py,sha256=-UD39J_86UU7ZJs2ZncpdND0yhAG0NeeeALrgSDuuFw,466
67
73
  nlbone/interfaces/api/dependencies/uow.py,sha256=QfLEvLYLNWZJQN1k-0q0hBVtUld3D75P4j39q_RjcnE,1181
68
74
  nlbone/interfaces/api/middleware/__init__.py,sha256=zbX2vaEAfxRMIYwO2MVY_2O6bqG5H9o7HqGpX14U3Is,158
69
75
  nlbone/interfaces/api/middleware/access_log.py,sha256=vIkxxxfy2HcjqqKb8XCfGCcSrivAC8u6ie75FMq5x-U,1032
70
76
  nlbone/interfaces/api/middleware/add_request_context.py,sha256=av-qs0biOYuF9R6RJOo2eYsFqDL9WRYWcjVakFhbt-w,1834
71
77
  nlbone/interfaces/api/middleware/authentication.py,sha256=ze7vCm492QsX9nPL6A-PqZCmC1C5ZgUE-OWI6fCLpsU,1809
72
- nlbone/interfaces/api/pagination/__init__.py,sha256=sWKKQFa2Z-1SlprQOqImOa2c9exq4wueKpUL_9QM7wc,417
73
- nlbone/interfaces/api/pagination/offset_base.py,sha256=BPG8veDRUA-67Z_JqLQTqT2nqCalxPblzSiQv_HZXUc,3838
78
+ nlbone/interfaces/api/pagination/__init__.py,sha256=pA1uC4rK6eqDI5IkLVxmgO2B6lExnOm8Pje2-hifJZw,431
79
+ nlbone/interfaces/api/pagination/offset_base.py,sha256=iipO09sAUb0U9ctMMujBHQo1IgMRbasvwnHJFtA9ZeA,3812
80
+ nlbone/interfaces/api/schema/__init__.py,sha256=LAqgynfupeqOQ6u0I5ucrcYnojRMZUg9yW8IjKSQTNI,119
81
+ nlbone/interfaces/api/schema/adaptive_schema.py,sha256=bdWBNpP2NfOJ_in4btXn0lrZOK70x-OqfmZ-NpIJdoQ,3347
82
+ nlbone/interfaces/api/schema/base_response_model.py,sha256=Y-mqNzVheTaGkM4m2DsybK-GLhfODMhBUNyTRoaf_Zc,600
74
83
  nlbone/interfaces/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
84
  nlbone/interfaces/cli/init_db.py,sha256=C67n2MsJ1vzkJxC8zfUYOxFdd6mEB_vT9agxN6jWoG8,790
76
85
  nlbone/interfaces/cli/main.py,sha256=pNldsTgplVyXa-Hx96dySO2I9gFRi49nDXv7J_dO73s,686
@@ -84,8 +93,8 @@ nlbone/utils/context.py,sha256=MmclJ24BG2uvSTg1IK7J-Da9BhVFDQ5ag4Ggs2FF1_w,1600
84
93
  nlbone/utils/http.py,sha256=UXUoXgQdTRNT08ho8zl-C5ekfDsD8uf-JiMQ323ooqw,872
85
94
  nlbone/utils/redactor.py,sha256=-V4HrHmHwPi3Kez587Ek1uJlgK35qGSrwBOvcbw8Jas,1279
86
95
  nlbone/utils/time.py,sha256=DjjyQ9GLsfXoT6NK8RDW2rOlJg3e6sF04Jw6PBUrSvg,1268
87
- nlbone-0.6.13.dist-info/METADATA,sha256=Q34YJD_7dJxWCLX_t_Ihh-JY8jDahy_mkSCpP7vMM9Y,2228
88
- nlbone-0.6.13.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
89
- nlbone-0.6.13.dist-info/entry_points.txt,sha256=CpIL45t5nbhl1dGQPhfIIDfqqak3teK0SxPGBBr7YCk,59
90
- nlbone-0.6.13.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
91
- nlbone-0.6.13.dist-info/RECORD,,
96
+ nlbone-0.6.15.dist-info/METADATA,sha256=t9hCCQwgtmGzXZOwsvXsc5hinAX5ogPmN36zOQPdyfk,2228
97
+ nlbone-0.6.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
98
+ nlbone-0.6.15.dist-info/entry_points.txt,sha256=CpIL45t5nbhl1dGQPhfIIDfqqak3teK0SxPGBBr7YCk,59
99
+ nlbone-0.6.15.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
100
+ nlbone-0.6.15.dist-info/RECORD,,