nlbone 0.9.0__py3-none-any.whl → 0.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,16 @@
1
- import asyncio
1
+ from __future__ import annotations
2
+
3
+ import inspect
2
4
  import json
3
5
  import os
4
- from typing import Any, Iterable, Mapping, Optional, Sequence
6
+ from typing import Any, Awaitable, Callable, Iterable, Mapping, Optional, Sequence, Union
5
7
 
6
- from redis.asyncio import Redis
8
+ from redis.asyncio import ConnectionPool, Redis
9
+ from redis.asyncio.retry import Retry
10
+ from redis.backoff import ExponentialBackoff
11
+ from redis.exceptions import LockError, RedisError
7
12
 
13
+ from nlbone.config.settings import get_settings
8
14
  from nlbone.core.ports.cache import AsyncCachePort
9
15
 
10
16
 
@@ -17,23 +23,41 @@ def _tag_key(tag: str) -> str:
17
23
 
18
24
 
19
25
  class AsyncRedisCache(AsyncCachePort):
20
- def __init__(self, url: str, *, invalidate_channel: str | None = None):
21
- self._r = Redis.from_url(url, decode_responses=False)
26
+ def __init__(self, url: str, *, invalidate_channel: Optional[str] = None):
27
+ self._pool = ConnectionPool.from_url(
28
+ url,
29
+ decode_responses=False,
30
+ max_connections=get_settings().REDIS_MAX_CONNECTIONS,
31
+ socket_timeout=get_settings().REDIS_TIMEOUT,
32
+ socket_connect_timeout=get_settings().REDIS_TIMEOUT,
33
+ health_check_interval=get_settings().REDIS_CHECK_INTERVAL,
34
+ retry_on_timeout=True,
35
+ retry=Retry(ExponentialBackoff(), 3),
36
+ )
37
+ self._r = Redis(connection_pool=self._pool)
22
38
  self._ch = invalidate_channel or os.getenv("NLBONE_REDIS_INVALIDATE_CHANNEL", "cache:invalidate")
23
39
 
24
40
  @property
25
41
  def redis(self) -> Redis:
26
42
  return self._r
27
43
 
44
+ async def close(self):
45
+ await self._r.close()
46
+ await self._pool.disconnect()
47
+
28
48
  async def _current_ver(self, ns: str) -> int:
29
- v = await self._r.get(_nsver_key(ns))
30
- return int(v) if v else 1
49
+ try:
50
+ v = await self._r.get(_nsver_key(ns))
51
+ return int(v) if v else 1
52
+ except (ValueError, TypeError):
53
+ return 1
31
54
 
32
55
  async def _full_key(self, key: str) -> str:
33
56
  try:
34
57
  ns, rest = key.split(":", 1)
35
58
  except ValueError:
36
59
  ns, rest = "app", key
60
+
37
61
  ver = await self._current_ver(ns)
38
62
  return f"{ns}:{ver}:{rest}"
39
63
 
@@ -46,14 +70,17 @@ class AsyncRedisCache(AsyncCachePort):
46
70
  self, key: str, value: bytes, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
47
71
  ) -> None:
48
72
  fk = await self._full_key(key)
49
- if ttl is None:
50
- await self._r.set(fk, value)
51
- else:
52
- await self._r.setex(fk, ttl, value)
53
- if tags:
54
- pipe = self._r.pipeline()
55
- for t in tags:
56
- pipe.sadd(_tag_key(t), fk)
73
+
74
+ async with self._r.pipeline() as pipe:
75
+ if ttl is None:
76
+ await pipe.set(fk, value)
77
+ else:
78
+ await pipe.setex(fk, ttl, value)
79
+
80
+ if tags:
81
+ for t in tags:
82
+ await pipe.sadd(_tag_key(t), fk)
83
+
57
84
  await pipe.execute()
58
85
 
59
86
  async def delete(self, key: str) -> None:
@@ -61,83 +88,87 @@ class AsyncRedisCache(AsyncCachePort):
61
88
  await self._r.delete(fk)
62
89
 
63
90
  async def exists(self, key: str) -> bool:
64
- return (await self.get(key)) is not None
91
+ fk = await self._full_key(key)
92
+ return bool(await self._r.exists(fk))
65
93
 
66
94
  async def ttl(self, key: str) -> Optional[int]:
67
95
  fk = await self._full_key(key)
68
96
  t = await self._r.ttl(fk)
69
- return None if t < 0 else int(t)
97
+ return int(t) if t >= 0 else None
70
98
 
71
- # -------- multi --------
99
+ # -------- multi --------
72
100
 
73
101
  async def mget(self, keys: Sequence[str]) -> list[Optional[bytes]]:
102
+ if not keys:
103
+ return []
104
+ # Alternatively, await asyncio.gather(*[self._full_key(k) for k in keys])
74
105
  fks = [await self._full_key(k) for k in keys]
75
106
  return await self._r.mget(fks)
76
107
 
77
108
  async def mset(
78
109
  self, items: Mapping[str, bytes], *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
79
110
  ) -> None:
80
- pipe = self._r.pipeline()
81
- if ttl is None:
82
- for k, v in items.items():
83
- fk = await self._full_key(k)
84
- pipe.set(fk, v)
85
- else:
111
+ if not items:
112
+ return
113
+
114
+ async with self._r.pipeline() as pipe:
86
115
  for k, v in items.items():
87
116
  fk = await self._full_key(k)
88
- pipe.setex(fk, ttl, v)
89
- await pipe.execute()
117
+ if ttl is None:
118
+ await pipe.set(fk, v)
119
+ else:
120
+ await pipe.setex(fk, ttl, v)
121
+
122
+ if tags:
123
+ for t in tags:
124
+ await pipe.sadd(_tag_key(t), fk)
90
125
 
91
- if tags:
92
- pipe = self._r.pipeline()
93
- for t in tags:
94
- for k in items.keys():
95
- fk = await self._full_key(k)
96
- pipe.sadd(_tag_key(t), fk)
97
126
  await pipe.execute()
98
127
 
99
- # -------- json --------
128
+ # -------- json --------
100
129
 
101
130
  async def get_json(self, key: str) -> Optional[Any]:
102
131
  b = await self.get(key)
103
- return None if b is None else json.loads(b)
132
+ if b is None:
133
+ return None
134
+ try:
135
+ return json.loads(b)
136
+ except json.JSONDecodeError:
137
+ return None
104
138
 
105
139
  async def set_json(
106
140
  self, key: str, value: Any, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
107
141
  ) -> None:
108
- await self.set(key, json.dumps(value).encode("utf-8"), ttl=ttl, tags=tags)
142
+ payload = json.dumps(value).encode("utf-8")
143
+ await self.set(key, payload, ttl=ttl, tags=tags)
109
144
 
110
- # -------- invalidation --------
145
+ # -------- invalidation --------
111
146
 
112
147
  async def invalidate_tags(self, tags: Iterable[str]) -> int:
113
148
  removed = 0
114
- pipe = self._r.pipeline()
115
- key_sets: list[tuple[str, set[bytes]]] = []
116
- for t in tags:
117
- tk = _tag_key(t)
118
- members = await self._r.smembers(tk)
119
- if members:
120
- pipe.delete(*members)
121
- pipe.delete(tk)
122
- key_sets.append((tk, members))
123
- removed += len(members or [])
124
- await pipe.execute()
125
-
126
- # publish notification for other processes
149
+ async with self._r.pipeline() as pipe:
150
+ for t in tags:
151
+ tk = _tag_key(t)
152
+ members = await self._r.smembers(tk)
153
+ if members:
154
+ await pipe.delete(*members)
155
+ await pipe.delete(tk)
156
+ removed += len(members or [])
157
+ await pipe.execute()
158
+
127
159
  try:
128
160
  payload = json.dumps({"tags": list(tags)}).encode("utf-8")
129
161
  await self._r.publish(self._ch, payload)
130
- except Exception:
162
+ except RedisError:
131
163
  pass
132
164
 
133
165
  return removed
134
166
 
135
167
  async def bump_namespace(self, namespace: str) -> int:
136
168
  v = await self._r.incr(_nsver_key(namespace))
137
- # اطلاع‌رسانی اختیاری
138
169
  try:
139
170
  await self._r.publish(self._ch, json.dumps({"ns_bump": namespace}).encode("utf-8"))
140
- except Exception:
171
+ except RedisError:
141
172
  pass
142
173
  return int(v)
143
174
 
@@ -145,6 +176,7 @@ class AsyncRedisCache(AsyncCachePort):
145
176
  cnt = 0
146
177
  cursor = 0
147
178
  pattern = f"{namespace}:*"
179
+
148
180
  while True:
149
181
  cursor, keys = await self._r.scan(cursor=cursor, match=pattern, count=1000)
150
182
  if keys:
@@ -152,39 +184,57 @@ class AsyncRedisCache(AsyncCachePort):
152
184
  cnt += len(keys)
153
185
  if cursor == 0:
154
186
  break
187
+
155
188
  try:
156
189
  await self._r.publish(self._ch, json.dumps({"ns_clear": namespace}).encode("utf-8"))
157
- except Exception:
190
+ except RedisError:
158
191
  pass
192
+
159
193
  return cnt
160
194
 
161
- # -------- dogpile-safe get_or_set --------
195
+ # -------- dogpile-safe get_or_set --------
162
196
 
163
- async def get_or_set(self, key: str, producer, *, ttl: int, tags=None) -> bytes:
197
+ async def get_or_set(
198
+ self,
199
+ key: str,
200
+ producer: Callable[[], Union[bytes, str, Awaitable[Union[bytes, str]]]],
201
+ *,
202
+ ttl: int,
203
+ tags: Optional[Iterable[str]] = None,
204
+ ) -> bytes:
164
205
  fk = await self._full_key(key)
206
+
165
207
  val = await self._r.get(fk)
166
208
  if val is not None:
167
209
  return val
168
210
 
169
- lock_key = f"lock:{fk}"
170
- got = await self._r.set(lock_key, b"1", ex=10, nx=True)
171
- if got:
172
- try:
173
- produced = await producer() if asyncio.iscoroutinefunction(producer) else producer()
211
+ lock_name = f"lock:{fk}"
212
+
213
+ try:
214
+ async with self._r.lock(lock_name, timeout=10, blocking_timeout=5):
215
+ val = await self._r.get(fk)
216
+ if val is not None:
217
+ return val
218
+
219
+ if inspect.iscoroutinefunction(producer):
220
+ produced = await producer()
221
+ else:
222
+ produced = producer()
223
+
174
224
  if isinstance(produced, str):
175
225
  produced = produced.encode("utf-8")
226
+
176
227
  await self.set(key, produced, ttl=ttl, tags=tags)
177
228
  return produced
178
- finally:
179
- await self._r.delete(lock_key)
180
-
181
- await asyncio.sleep(0.05)
182
- val2 = await self._r.get(fk)
183
- if val2 is not None:
184
- return val2
185
- # fallback
186
- produced = await producer() if asyncio.iscoroutinefunction(producer) else producer()
187
- if isinstance(produced, str):
188
- produced = produced.encode("utf-8")
189
- await self.set(key, produced, ttl=ttl, tags=tags)
190
- return produced
229
+
230
+ except LockError:
231
+ if inspect.iscoroutinefunction(producer):
232
+ produced = await producer()
233
+ else:
234
+ produced = producer()
235
+
236
+ if isinstance(produced, str):
237
+ produced = produced.encode("utf-8")
238
+
239
+ await self.set(key, produced, ttl=ttl, tags=tags)
240
+ return produced
@@ -2,11 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
- import time
6
5
  from typing import Any, Iterable, Mapping, Optional, Sequence
7
6
 
8
- import redis # redis-py (sync)
7
+ import redis
8
+ from redis import RedisError
9
+ from redis.backoff import ExponentialBackoff
10
+ from redis.exceptions import LockError
11
+ from redis.retry import Retry
9
12
 
13
+ from nlbone.config.settings import get_settings
10
14
  from nlbone.core.ports.cache import CachePort
11
15
 
12
16
 
@@ -20,17 +24,32 @@ def _tag_key(tag: str) -> str:
20
24
 
21
25
  class RedisCache(CachePort):
22
26
  def __init__(self, url: str):
23
- self.r = redis.Redis.from_url(url, decode_responses=False)
27
+ self._pool = redis.ConnectionPool.from_url(
28
+ url,
29
+ decode_responses=False,
30
+ max_connections=get_settings().REDIS_MAX_CONNECTIONS,
31
+ socket_timeout=get_settings().REDIS_TIMEOUT,
32
+ socket_connect_timeout=get_settings().REDIS_TIMEOUT,
33
+ health_check_interval=get_settings().REDIS_CHECK_INTERVAL,
34
+ retry_on_timeout=True,
35
+ retry=Retry(ExponentialBackoff(), 3),
36
+ )
37
+
38
+ self.r = redis.Redis(connection_pool=self._pool)
24
39
 
25
40
  def _current_ver(self, ns: str) -> int:
26
- v = self.r.get(_nsver_key(ns))
27
- return int(v) if v else 1
41
+ try:
42
+ v = self.r.get(_nsver_key(ns))
43
+ return int(v) if v else 1
44
+ except (ValueError, TypeError):
45
+ return 1
28
46
 
29
47
  def _full_key(self, key: str) -> str:
30
48
  try:
31
49
  ns, rest = key.split(":", 1)
32
50
  except ValueError:
33
51
  ns, rest = "app", key
52
+
34
53
  ver = self._current_ver(ns)
35
54
  return f"{ns}:{ver}:{rest}"
36
55
 
@@ -40,53 +59,67 @@ class RedisCache(CachePort):
40
59
 
41
60
  def set(self, key: str, value: bytes, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None:
42
61
  fk = self._full_key(key)
62
+
63
+ pipe = self.r.pipeline()
64
+
43
65
  if ttl is None:
44
- self.r.set(fk, value)
66
+ pipe.set(fk, value)
45
67
  else:
46
- self.r.setex(fk, ttl, value)
68
+ pipe.setex(fk, ttl, value)
69
+
47
70
  if tags:
48
- pipe = self.r.pipeline()
49
71
  for t in tags:
50
72
  pipe.sadd(_tag_key(t), fk)
51
- pipe.execute()
73
+
74
+ pipe.execute()
52
75
 
53
76
  def delete(self, key: str) -> None:
54
77
  fk = self._full_key(key)
55
78
  self.r.delete(fk)
56
79
 
57
80
  def exists(self, key: str) -> bool:
58
- return bool(self.get(key))
81
+ return bool(self.r.exists(self._full_key(key)))
59
82
 
60
83
  def ttl(self, key: str) -> Optional[int]:
61
84
  fk = self._full_key(key)
62
85
  t = self.r.ttl(fk)
63
- return None if t < 0 else int(t)
86
+ return int(t) if t >= 0 else None
64
87
 
65
88
  def mget(self, keys: Sequence[str]) -> list[Optional[bytes]]:
89
+ if not keys:
90
+ return []
66
91
  fks = [self._full_key(k) for k in keys]
67
92
  return self.r.mget(fks)
68
93
 
69
94
  def mset(
70
95
  self, items: Mapping[str, bytes], *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
71
96
  ) -> None:
97
+ if not items:
98
+ return
99
+
72
100
  pipe = self.r.pipeline()
73
- if ttl is None:
74
- for k, v in items.items():
75
- pipe.set(self._full_key(k), v)
76
- else:
77
- for k, v in items.items():
78
- pipe.setex(self._full_key(k), ttl, v)
101
+
102
+ for k, v in items.items():
103
+ fk = self._full_key(k)
104
+ if ttl is None:
105
+ pipe.set(fk, v)
106
+ else:
107
+ pipe.setex(fk, ttl, v)
108
+
109
+ if tags:
110
+ for t in tags:
111
+ pipe.sadd(_tag_key(t), fk)
112
+
79
113
  pipe.execute()
80
- if tags:
81
- pipe = self.r.pipeline()
82
- for t in tags:
83
- for k in items.keys():
84
- pipe.sadd(_tag_key(t), self._full_key(k))
85
- pipe.execute()
86
114
 
87
115
  def get_json(self, key: str) -> Optional[Any]:
88
116
  b = self.get(key)
89
- return None if b is None else json.loads(b)
117
+ if b is None:
118
+ return None
119
+ try:
120
+ return json.loads(b)
121
+ except json.JSONDecodeError:
122
+ return None
90
123
 
91
124
  def set_json(
92
125
  self, key: str, value: Any, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None
@@ -96,6 +129,7 @@ class RedisCache(CachePort):
96
129
  def invalidate_tags(self, tags: Iterable[str]) -> int:
97
130
  removed = 0
98
131
  pipe = self.r.pipeline()
132
+
99
133
  for t in tags:
100
134
  tk = _tag_key(t)
101
135
  keys = self.r.smembers(tk)
@@ -103,12 +137,15 @@ class RedisCache(CachePort):
103
137
  pipe.delete(*keys)
104
138
  pipe.delete(tk)
105
139
  removed += len(keys or [])
140
+
106
141
  pipe.execute()
142
+
107
143
  try:
108
144
  ch = os.getenv("NLBONE_REDIS_INVALIDATE_CHANNEL", "cache:invalidate")
109
145
  self.r.publish(ch, json.dumps({"tags": list(tags)}).encode("utf-8"))
110
- except Exception:
146
+ except RedisError:
111
147
  pass
148
+
112
149
  return removed
113
150
 
114
151
  def bump_namespace(self, namespace: str) -> int:
@@ -119,6 +156,7 @@ class RedisCache(CachePort):
119
156
  cnt = 0
120
157
  cursor = 0
121
158
  pattern = f"{namespace}:*"
159
+
122
160
  while True:
123
161
  cursor, keys = self.r.scan(cursor=cursor, match=pattern, count=1000)
124
162
  if keys:
@@ -128,24 +166,28 @@ class RedisCache(CachePort):
128
166
  break
129
167
  return cnt
130
168
 
131
- def get_or_set(self, key: str, producer, *, ttl: int, tags=None) -> bytes:
169
+ def get_or_set(self, key: str, producer, *, ttl: int, tags: Optional[Iterable[str]] = None) -> bytes:
132
170
  fk = self._full_key(key)
171
+
133
172
  val = self.r.get(fk)
134
173
  if val is not None:
135
174
  return val
136
- lock_key = f"lock:{fk}"
137
- got = self.r.set(lock_key, b"1", nx=True, ex=10)
138
- if got:
139
- try:
175
+
176
+ lock_name = f"lock:{fk}"
177
+ try:
178
+ with self.r.lock(lock_name, timeout=10, blocking_timeout=5):
179
+ val = self.r.get(fk)
180
+ if val is not None:
181
+ return val
182
+
140
183
  produced: bytes = producer()
141
184
  self.set(key, produced, ttl=ttl, tags=tags)
142
185
  return produced
143
- finally:
144
- self.r.delete(lock_key)
145
- time.sleep(0.05)
146
- val2 = self.r.get(fk)
147
- if val2 is not None:
148
- return val2
149
- produced: bytes = producer()
150
- self.set(key, produced, ttl=ttl, tags=tags)
151
- return produced
186
+
187
+ except LockError:
188
+ try:
189
+ produced = producer()
190
+ self.set(key, produced, ttl=ttl, tags=tags)
191
+ return produced
192
+ except Exception:
193
+ raise
@@ -15,12 +15,16 @@ from nlbone.config.settings import get_settings
15
15
 
16
16
  _settings = get_settings()
17
17
 
18
- ASYNC_DSN: str = _settings.POSTGRES_DB_DSN
18
+ _dsn = _settings.POSTGRES_DB_DSN
19
19
 
20
- if "+asyncpg" in ASYNC_DSN:
21
- SYNC_DSN: str = ASYNC_DSN.replace("+asyncpg", "+psycopg")
20
+ if "+asyncpg" in _dsn:
21
+ ASYNC_DSN = _dsn.replace("+asyncpg", "+psycopg")
22
+ elif "+psycopg" not in _dsn:
23
+ ASYNC_DSN = _dsn.replace("postgresql://", "postgresql+psycopg://")
22
24
  else:
23
- SYNC_DSN = ASYNC_DSN
25
+ ASYNC_DSN = _dsn
26
+
27
+ SYNC_DSN = ASYNC_DSN
24
28
 
25
29
  _async_engine: Optional[AsyncEngine] = None
26
30
  _async_session_factory: Optional[async_sessionmaker[AsyncSession]] = None
@@ -38,14 +42,19 @@ def init_async_engine(echo: Optional[bool] = None) -> AsyncEngine:
38
42
  ASYNC_DSN,
39
43
  echo=_settings.DEBUG if echo is None else echo,
40
44
  pool_pre_ping=True,
41
- pool_size=5,
42
- max_overflow=10,
45
+ pool_size=_settings.POSTGRES_POOL_SIZE,
46
+ max_overflow=_settings.POSTGRES_MAX_OVERFLOW,
47
+ pool_recycle=_settings.POSTGRES_POOL_RECYCLE,
48
+ pool_timeout=_settings.POSTGRES_POOL_TIMEOUT,
43
49
  )
50
+
44
51
  _async_session_factory = async_sessionmaker(
45
52
  bind=_async_engine,
46
53
  expire_on_commit=False,
47
54
  autoflush=False,
55
+ class_=AsyncSession,
48
56
  )
57
+
49
58
  return _async_engine
50
59
 
51
60
 
@@ -54,6 +63,7 @@ async def async_session() -> AsyncGenerator[AsyncSession, Any]:
54
63
  if _async_session_factory is None:
55
64
  init_async_engine()
56
65
  assert _async_session_factory is not None
66
+
57
67
  session = _async_session_factory()
58
68
  try:
59
69
  yield session
@@ -64,12 +74,6 @@ async def async_session() -> AsyncGenerator[AsyncSession, Any]:
64
74
  await session.close()
65
75
 
66
76
 
67
- async def async_ping() -> None:
68
- eng = init_async_engine()
69
- async with eng.connect() as conn:
70
- await conn.execute(text("SELECT 1"))
71
-
72
-
73
77
  def init_sync_engine(echo: Optional[bool] = None) -> Engine:
74
78
  global _sync_engine, _sync_session_factory
75
79
  if _sync_engine is not None:
@@ -80,11 +84,12 @@ def init_sync_engine(echo: Optional[bool] = None) -> Engine:
80
84
  echo=_settings.DEBUG if echo is None else echo,
81
85
  pool_pre_ping=True,
82
86
  pool_size=_settings.POSTGRES_POOL_SIZE,
83
- max_overflow=_settings.POSTGRES_MAX_POOL_SIZE,
84
- pool_timeout=30,
85
- pool_recycle=1800,
87
+ max_overflow=_settings.POSTGRES_MAX_OVERFLOW,
88
+ pool_recycle=_settings.POSTGRES_POOL_RECYCLE,
89
+ pool_timeout=_settings.POSTGRES_POOL_TIMEOUT,
86
90
  future=True,
87
91
  )
92
+
88
93
  _sync_session_factory = sessionmaker(
89
94
  bind=_sync_engine,
90
95
  autocommit=False,
@@ -110,11 +115,17 @@ def sync_session() -> Generator[Session, None, None]:
110
115
  s.close()
111
116
 
112
117
 
118
+ # --- Health Checks & Getters ---
119
+
120
+
121
+ async def async_ping() -> None:
122
+ async with async_session() as session:
123
+ await session.execute(text("SELECT 1"))
124
+
125
+
113
126
  def sync_ping() -> None:
114
- """Health check for sync."""
115
- eng = init_sync_engine()
116
- with eng.connect() as conn:
117
- conn.execute(text("SELECT 1"))
127
+ with sync_session() as session:
128
+ session.execute(text("SELECT 1"))
118
129
 
119
130
 
120
131
  def get_async_session_factory() -> async_sessionmaker[AsyncSession]:
@@ -1,15 +1,16 @@
1
- import redis
1
+ from redis import Redis
2
+ from redis.asyncio import Redis as AsyncRedis
2
3
 
3
4
  from nlbone.config.settings import get_settings
4
5
 
5
6
 
6
7
  class RedisClient:
7
- _client: redis.Redis | None = None
8
+ _client: Redis | None = None
8
9
 
9
10
  @classmethod
10
- def get_client(cls) -> redis.Redis:
11
+ def get_client(cls) -> Redis:
11
12
  if cls._client is None:
12
- cls._client = redis.from_url(get_settings().REDIS_URL, decode_responses=True)
13
+ cls._client = Redis.from_url(get_settings().REDIS_URL, decode_responses=True)
13
14
  return cls._client
14
15
 
15
16
  @classmethod
@@ -17,3 +18,19 @@ class RedisClient:
17
18
  if cls._client is not None:
18
19
  cls._client.close()
19
20
  cls._client = None
21
+
22
+
23
+ class AsyncRedisClient:
24
+ _client: AsyncRedis | None = None
25
+
26
+ @classmethod
27
+ def get_client(cls) -> Redis:
28
+ if cls._client is None:
29
+ cls._client = AsyncRedis.from_url(get_settings().REDIS_URL, decode_responses=True)
30
+ return cls._client
31
+
32
+ @classmethod
33
+ async def close(cls):
34
+ if cls._client is not None:
35
+ await cls._client.close()
36
+ cls._client = None
nlbone/config/settings.py CHANGED
@@ -70,24 +70,37 @@ class Settings(BaseSettings):
70
70
  # Database
71
71
  # ---------------------------
72
72
  POSTGRES_DB_DSN: str = Field(default="postgresql+asyncpg://user:pass@localhost:5432/nlbone")
73
+ POSTGRES_DB_ECHO: bool = Field(default=False)
73
74
  POSTGRES_POOL_SIZE: int = Field(default=5)
74
- POSTGRES_MAX_POOL_SIZE: int = Field(default=10)
75
- DB_ECHO: bool = Field(default=False)
76
- DB_POOL_SIZE: int = Field(default=5)
77
- DB_MAX_OVERFLOW: int = Field(default=10)
75
+ POSTGRES_MAX_OVERFLOW: int = Field(default=10)
76
+ POSTGRES_POOL_TIMEOUT: int = Field(default=30)
77
+ POSTGRES_POOL_RECYCLE: int = Field(default=1800)
78
78
 
79
79
  # ---------------------------
80
80
  # Messaging / Cache
81
81
  # ---------------------------
82
82
  REDIS_URL: str = Field(default="redis://localhost:6379/0")
83
+ REDIS_MAX_CONNECTIONS: int = Field(default=5)
84
+ REDIS_CHECK_INTERVAL: int = Field(default=30)
85
+ REDIS_TIMEOUT: float = Field(default=3.0)
86
+
83
87
  CACHE_BACKEND: Literal["memory", "redis"] = Field(default="memory")
84
88
  CACHE_DEFAULT_TTL_S: int = Field(default=300)
85
89
 
86
- # --- Event bus / Outbox ---
90
+ # ---------------------------
91
+ # Event bus / Outbox
92
+ # ---------------------------
87
93
  EVENT_BUS_BACKEND: Literal["inmemory"] = Field(default="inmemory")
88
94
  OUTBOX_ENABLED: bool = Field(default=False)
89
95
  OUTBOX_POLL_INTERVAL_MS: int = Field(default=500)
90
96
 
97
+ # ---------------------------
98
+ # APM
99
+ # ---------------------------
100
+ APM_SERVER_URL: str = "https://apm.numberland.dev"
101
+ APM_SECRET_TOKEN: str = ""
102
+ APM_SAMPLE_RATE: float = Field(default=0.5)
103
+
91
104
  # ---------------------------
92
105
  # UPLOADCHI
93
106
  # ---------------------------
@@ -1,10 +1,10 @@
1
1
  import functools
2
2
 
3
3
  from nlbone.adapters.auth.auth_service import get_auth_service
4
- from nlbone.interfaces.api.exceptions import ForbiddenException, UnauthorizedException
4
+ from nlbone.interfaces.api.exceptions import 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
7
+ from .auth import client_has_access_func, client_or_user_has_access_func, user_has_access_func
8
8
 
9
9
 
10
10
  async def current_user_id() -> int:
@@ -47,18 +47,14 @@ def has_access(*, permissions=None):
47
47
  def decorator(func):
48
48
  @functools.wraps(func)
49
49
  async def wrapper(*args, **kwargs):
50
- request = current_request()
51
- if not await current_user_id():
52
- raise UnauthorizedException()
53
- if not get_auth_service().has_access(request.state.token, permissions=permissions):
54
- raise ForbiddenException(f"Forbidden {permissions}")
55
-
50
+ user_has_access_func(permissions=permissions)
56
51
  return await func(*args, **kwargs)
57
52
 
58
53
  return wrapper
59
54
 
60
55
  return decorator
61
56
 
57
+
62
58
  def client_or_user_has_access(*, permissions=None, client_permissions=None):
63
59
  def decorator(func):
64
60
  @functools.wraps(func)
@@ -69,4 +65,3 @@ def client_or_user_has_access(*, permissions=None, client_permissions=None):
69
65
  return wrapper
70
66
 
71
67
  return decorator
72
-
@@ -1,8 +1,8 @@
1
1
  import functools
2
2
 
3
- from nlbone.core.domain.models import CurrentUserData
4
3
  from nlbone.adapters.auth.auth_service import get_auth_service
5
4
  from nlbone.config.settings import get_settings
5
+ from nlbone.core.domain.models import CurrentUserData
6
6
  from nlbone.interfaces.api.exceptions import ForbiddenException, UnauthorizedException
7
7
  from nlbone.utils.context import current_request
8
8
 
@@ -66,7 +66,7 @@ def user_authenticated(func):
66
66
 
67
67
  def user_has_access_func(*, permissions=None):
68
68
  if bypass_authz():
69
- return
69
+ return True
70
70
  request = current_request()
71
71
  if not current_user_id():
72
72
  raise UnauthorizedException()
@@ -89,7 +89,7 @@ def has_access(*, permissions=None):
89
89
 
90
90
  def client_or_user_has_access_func(permissions=None, client_permissions=None):
91
91
  if bypass_authz():
92
- return
92
+ return True
93
93
  request = current_request()
94
94
  token = getattr(request.state, "token", None)
95
95
  if not token:
@@ -1,17 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Mapping, Optional, List
3
+ from typing import Any, List, Mapping, Optional
4
4
  from uuid import uuid4
5
5
 
6
6
  from fastapi import FastAPI, Request
7
- from fastapi import HTTPException as FastAPIHTTPException
8
7
  from fastapi.exceptions import RequestValidationError
9
8
  from fastapi.responses import JSONResponse
10
9
  from pydantic import BaseModel, ValidationError
11
10
  from starlette.exceptions import HTTPException as StarletteHTTPException
12
- from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR, HTTP_422_UNPROCESSABLE_ENTITY
11
+ from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
13
12
 
14
13
  from nlbone.adapters.i18n import translator as _
14
+
15
15
  from .exceptions import BaseHttpException, ErrorDetail, UnprocessableEntityException
16
16
 
17
17
 
@@ -3,9 +3,9 @@ from typing import Callable, Optional, Union
3
3
  from fastapi import Request
4
4
  from starlette.middleware.base import BaseHTTPMiddleware
5
5
 
6
- from nlbone.core.domain.models import CurrentUserData
7
6
  from nlbone.adapters.auth.auth_service import AuthService
8
7
  from nlbone.config.settings import get_settings
8
+ from nlbone.core.domain.models import CurrentUserData
9
9
 
10
10
  try:
11
11
  from dependency_injector import providers
@@ -54,8 +54,7 @@ def authenticate_admin_user(request, auth_service):
54
54
 
55
55
  def authenticate_user(request):
56
56
  token = (
57
- request.cookies.get("access_token") or request.cookies.get("j_token") or request.headers.get(
58
- "Authorization")
57
+ request.cookies.get("access_token") or request.cookies.get("j_token") or request.headers.get("Authorization")
59
58
  )
60
59
  if request.headers.get("Authorization"):
61
60
  scheme, token = request.headers.get("Authorization").split(" ", 1)
nlbone/utils/cache.py CHANGED
@@ -111,14 +111,18 @@ def default_deserialize(b: bytes) -> Any:
111
111
 
112
112
  def _is_async_method(obj: Any, name: str) -> bool:
113
113
  meth = getattr(obj, name, None)
114
- return asyncio.iscoroutinefunction(meth)
114
+ return inspect.iscoroutinefunction(meth)
115
115
 
116
116
 
117
117
  def _run_maybe_async(func: Callable, *args, **kwargs):
118
118
  """Call a function that may be async from sync context."""
119
119
  result = func(*args, **kwargs)
120
120
  if inspect.isawaitable(result):
121
- return asyncio.run(result)
121
+ try:
122
+ return asyncio.run(result)
123
+ except RuntimeError:
124
+ result.close()
125
+ raise
122
126
  return result
123
127
 
124
128
 
@@ -135,7 +139,7 @@ def cached(
135
139
  cache_resolver: Optional[Callable[[], Any]] = None,
136
140
  ):
137
141
  def deco(func: Callable):
138
- is_async_func = asyncio.iscoroutinefunction(func)
142
+ is_async_func = inspect.iscoroutinefunction(func)
139
143
 
140
144
  if is_async_func:
141
145
 
@@ -150,10 +154,11 @@ def cached(
150
154
  # SAFE GET
151
155
  cached_bytes = None
152
156
  try:
153
- if _is_async_method(cache, "get"):
154
- cached_bytes = await cache.get(k)
157
+ result = cache.get(k)
158
+ if inspect.isawaitable(result):
159
+ cached_bytes = await result
155
160
  else:
156
- cached_bytes = cache.get(k)
161
+ cached_bytes = result
157
162
  except Exception:
158
163
  pass
159
164
 
@@ -166,10 +171,9 @@ def cached(
166
171
  # SAFE SET
167
172
  data = serializer(result)
168
173
  try:
169
- if _is_async_method(cache, "set"):
170
- await cache.set(k, data, ttl=ttl, tags=tg)
171
- else:
172
- cache.set(k, data, ttl=ttl, tags=tg)
174
+ res = cache.set(k, data, ttl=ttl, tags=tg)
175
+ if inspect.isawaitable(res):
176
+ await res
173
177
  except Exception:
174
178
  pass
175
179
 
@@ -190,10 +194,7 @@ def cached(
190
194
  # SAFE GET (maybe async)
191
195
  cached_bytes = None
192
196
  try:
193
- if _is_async_method(cache, "get"):
194
- cached_bytes = _run_maybe_async(cache.get, k)
195
- else:
196
- cached_bytes = cache.get(k)
197
+ cached_bytes = _run_maybe_async(cache.get, k)
197
198
  except Exception:
198
199
  pass
199
200
 
@@ -206,10 +207,7 @@ def cached(
206
207
  # SAFE SET (maybe async)
207
208
  data = serializer(result)
208
209
  try:
209
- if _is_async_method(cache, "set"):
210
- _run_maybe_async(cache.set, k, data, ttl=ttl, tags=tg)
211
- else:
212
- cache.set(k, data, ttl=ttl, tags=tg)
210
+ _run_maybe_async(cache.set, k, data, ttl=ttl, tags=tg)
213
211
  except Exception:
214
212
  pass
215
213
 
@@ -227,7 +225,7 @@ def invalidate_by_tags(tags_builder: Callable[..., Iterable[str]]):
227
225
  """
228
226
 
229
227
  def deco(func: Callable):
230
- is_async_func = asyncio.iscoroutinefunction(func)
228
+ is_async_func = inspect.iscoroutinefunction(func)
231
229
 
232
230
  if is_async_func:
233
231
 
@@ -236,10 +234,11 @@ def invalidate_by_tags(tags_builder: Callable[..., Iterable[str]]):
236
234
  out = await func(*args, **kwargs)
237
235
  cache = get_cache()
238
236
  tags = list(tags_builder(*args, **kwargs))
239
- if _is_async_method(cache, "invalidate_tags"):
240
- await cache.invalidate_tags(tags)
241
- else:
242
- cache.invalidate_tags(tags)
237
+
238
+ res = cache.invalidate_tags(tags)
239
+ if inspect.isawaitable(res):
240
+ await res
241
+
243
242
  return out
244
243
 
245
244
  return aw
@@ -249,10 +248,9 @@ def invalidate_by_tags(tags_builder: Callable[..., Iterable[str]]):
249
248
  out = func(*args, **kwargs)
250
249
  cache = get_cache()
251
250
  tags = list(tags_builder(*args, **kwargs))
252
- if _is_async_method(cache, "invalidate_tags"):
253
- _run_maybe_async(cache.invalidate_tags, tags)
254
- else:
255
- cache.invalidate_tags(tags)
251
+
252
+ _run_maybe_async(cache.invalidate_tags, tags)
253
+
256
254
  return out
257
255
 
258
256
  return sw
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nlbone
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Backbone package for interfaces and infrastructure in Python projects
5
5
  Author-email: Amir Hosein Kahkbazzadeh <a.khakbazzadeh@gmail.com>
6
6
  License: MIT
@@ -8,22 +8,22 @@ nlbone/adapters/auth/auth_service.py,sha256=l8SyskSyswas940i-hXEnJ-gboTpjsXURm_G
8
8
  nlbone/adapters/auth/keycloak.py,sha256=IhEriaFl5mjIGT6ZUCU9qROd678ARchvWgd4UJ6zH7s,4925
9
9
  nlbone/adapters/auth/token_provider.py,sha256=kzjFAaFY8SPnU0Tn6l-YVrhEOAiFV0QE3eit3D7u2VQ,1438
10
10
  nlbone/adapters/cache/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- nlbone/adapters/cache/async_redis.py,sha256=vvu5w4ANx0BVRHL95RAMGsD8CcaC-tSBMbCius2cuNc,6212
11
+ nlbone/adapters/cache/async_redis.py,sha256=GRooGZvGdPih9XOSe_lnmV-jQFrg-vZRARdNmOWOkQw,7380
12
12
  nlbone/adapters/cache/memory.py,sha256=y8M4erHQXApiSMAqG6Qk4pxEb60hRdu1szPv6iqvO9c,3738
13
13
  nlbone/adapters/cache/pubsub_listener.py,sha256=3vfK4O4EzuQQhTsbZ_bweP06o99kDSyHJ5PrfUotUaE,1460
14
- nlbone/adapters/cache/redis.py,sha256=2Y1DYHBLCrbHTO6O7pw85u3qY6OnCIFTYJ-HBvBs0FM,4608
14
+ nlbone/adapters/cache/redis.py,sha256=cVBEfT2bO7bF8lq_G50EcIDmXgLyRbeTCCxRl1tGFmA,5445
15
15
  nlbone/adapters/db/__init__.py,sha256=0CDSySEk4jJsqmwI0eNuaaLJOJDt8_iSiHBsFdC-L3s,212
16
16
  nlbone/adapters/db/postgres/__init__.py,sha256=tvCpHOdZbpQ57o7k-plq7L0e1uZe5_Frbh7I-LxW7zM,313
17
17
  nlbone/adapters/db/postgres/audit.py,sha256=IuWkPitr70UyQ6-GkAedckp8U-Z4cTgzFbdt_bQv1VQ,4800
18
18
  nlbone/adapters/db/postgres/base.py,sha256=I89PsEeR9ADEScG8D5pVSncPrPRBmf-KQQkjajl7Koo,132
19
- nlbone/adapters/db/postgres/engine.py,sha256=TJaCj6yiIUghCD4p1zAGfuJoz3BXkpLU_ZdzlL3lh8A,3507
19
+ nlbone/adapters/db/postgres/engine.py,sha256=8bA5qbDN9kcbI2uFitxbD8bseRlI_wQRQQSgfRTH6l8,3812
20
20
  nlbone/adapters/db/postgres/query_builder.py,sha256=YSlrj7lEGI9RiJ__El5_4j7nCuFogg4pR0oVcBQ9h90,15836
21
21
  nlbone/adapters/db/postgres/repository.py,sha256=n01TAzdKd-UbOhirE6KMosuvRdJG2l1cszwVHjTM-Ks,10345
22
22
  nlbone/adapters/db/postgres/schema.py,sha256=NlE7Rr8uXypsw4oWkdZhZwcIBHQEPIpoHLxcUo98i6s,1039
23
23
  nlbone/adapters/db/postgres/types.py,sha256=0SVuIKokog6_ByrYUsYAoIypVM2-uKJhUTeDPtm0qhs,602
24
24
  nlbone/adapters/db/postgres/uow.py,sha256=I4RVeIbGEVhVGcuzhdEUJuX11RhEHTn0egrkCbcsz24,3852
25
25
  nlbone/adapters/db/redis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
- nlbone/adapters/db/redis/client.py,sha256=5SUnwP2-GrueSFimUbiqDvrQsumvIE2aeozk8l-vOfQ,466
26
+ nlbone/adapters/db/redis/client.py,sha256=cT9TCigE-ndB-ckcpuh8cTS1AOVKS3rWNG_WYxD0CAM,930
27
27
  nlbone/adapters/http_clients/__init__.py,sha256=w-Yr9CLuXMU71N0Ada5HbvP1DB53wqeP6B-i5rALlTo,150
28
28
  nlbone/adapters/http_clients/pricing/__init__.py,sha256=ElA9NFcAR9u4cqb_w3PPqKU3xGeyjNLQ8veJ0ql2iz0,81
29
29
  nlbone/adapters/http_clients/pricing/pricing_service.py,sha256=_15vyEwJD3S2DJG-yyKhKfiLDbcwZg5XdQFe0b3oadM,3441
@@ -47,7 +47,7 @@ nlbone/adapters/ticketing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
47
47
  nlbone/adapters/ticketing/client.py,sha256=J1-eT3qQDAJqrHcVpP1oqWNsRNnJ54dDdBeez-m9wyY,1291
48
48
  nlbone/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
49
  nlbone/config/logging.py,sha256=Ot6Ctf7EQZlW8YNB-uBdleqI6wixn5fH0Eo6QRgNkQk,4358
50
- nlbone/config/settings.py,sha256=yuMBUV1WsPwQrkIFq8sifnvCPUB54ux_i0rVfXxGCRk,4884
50
+ nlbone/config/settings.py,sha256=4OLG9M9wB5H6slNcg61NuNEPoWQsjoA6RvesRYa1C7c,5330
51
51
  nlbone/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
52
  nlbone/core/application/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
53
  nlbone/core/application/base_worker.py,sha256=5brIToSd-vi6tw0ukhHnUZGZhOLq1SQ-NRRy-kp6D24,1193
@@ -70,7 +70,7 @@ nlbone/core/ports/translation.py,sha256=pnqbxhdRCR7eprm8UI8ZKKx7VDUPntvBtlytrnTG
70
70
  nlbone/core/ports/uow.py,sha256=VhqSc-Ryt9m-rlNMiXTzD3dPGz6mM_JxND8D0UJGRu4,962
71
71
  nlbone/interfaces/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
72
72
  nlbone/interfaces/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
- nlbone/interfaces/api/exception_handlers.py,sha256=2wJp0HZcsSNVsmjFXma3FQ994iga7m3zm9o1E4MIncw,5582
73
+ nlbone/interfaces/api/exception_handlers.py,sha256=YpnYAwmmWaLLJs7YKLdspWuVjCkelpPnVBw5VQkTkco,5494
74
74
  nlbone/interfaces/api/exceptions.py,sha256=IggZxV9q6l4jqw-G7SWEmuyXnWgbNXJJT-rmnirRIK4,5057
75
75
  nlbone/interfaces/api/routers.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
76
76
  nlbone/interfaces/api/schemas.py,sha256=34Tz2EeXyf12rFL9iyYWaB2ftuaXUebQQQxSO9ouV94,133
@@ -81,15 +81,15 @@ nlbone/interfaces/api/additional_filed/resolver.py,sha256=jv1TIBBHN4LBIMwHGipcy4
81
81
  nlbone/interfaces/api/additional_filed/default_field_rules/__init__.py,sha256=LUSAOO3xRUt5ptlraIx7H-7dSkdr1D-WprmnqXRB16g,48
82
82
  nlbone/interfaces/api/additional_filed/default_field_rules/image_field_rules.py,sha256=ecKqPeXZ-YiF14RK9PmK7ln3PCzpCUc18S5zm5IF3fw,339
83
83
  nlbone/interfaces/api/dependencies/__init__.py,sha256=rnYRrFVZCfICQrp_PVFlzNg3BeC57yM08wn2DbOHCfk,359
84
- nlbone/interfaces/api/dependencies/async_auth.py,sha256=CSl87X76qNuUfvvmtDN6uPwFJNbdrKWUuW4U3CZ3e1E,2126
85
- nlbone/interfaces/api/dependencies/auth.py,sha256=tE9T5HeRPqtimVDB-lYX_5lBaVvERSILT9FP55P-9hk,3190
84
+ nlbone/interfaces/api/dependencies/async_auth.py,sha256=TZlFzT-mnPz1WBL0wh3nOlBXDjY-7B0-b4wBT8O6pLM,1890
85
+ nlbone/interfaces/api/dependencies/auth.py,sha256=4WrjR3UgHI4S-HnBwdyLKEKBtofHkNuJ63Piayu9Dh4,3200
86
86
  nlbone/interfaces/api/dependencies/client_credential.py,sha256=Bo4dYx75Qw0JzTKD9ZfV5EXDEOuwndJk2D-V37K2ePg,1293
87
87
  nlbone/interfaces/api/dependencies/db.py,sha256=-UD39J_86UU7ZJs2ZncpdND0yhAG0NeeeALrgSDuuFw,466
88
88
  nlbone/interfaces/api/dependencies/uow.py,sha256=QfLEvLYLNWZJQN1k-0q0hBVtUld3D75P4j39q_RjcnE,1181
89
89
  nlbone/interfaces/api/middleware/__init__.py,sha256=zbX2vaEAfxRMIYwO2MVY_2O6bqG5H9o7HqGpX14U3Is,158
90
90
  nlbone/interfaces/api/middleware/access_log.py,sha256=vIkxxxfy2HcjqqKb8XCfGCcSrivAC8u6ie75FMq5x-U,1032
91
91
  nlbone/interfaces/api/middleware/add_request_context.py,sha256=o8mdo-D6fODM9OyHunE5UodkVxsh4F__5tDv8ju8Sxg,1952
92
- nlbone/interfaces/api/middleware/authentication.py,sha256=scXytNOtV7bg7iLtw19tdhmeIFQmY5qF6HCXAUbKGXg,3339
92
+ nlbone/interfaces/api/middleware/authentication.py,sha256=dWxA9Aw8eFUqsufT2mHGk6mgehXKdAA9AAEIOk_jZWY,3326
93
93
  nlbone/interfaces/api/pagination/__init__.py,sha256=pA1uC4rK6eqDI5IkLVxmgO2B6lExnOm8Pje2-hifJZw,431
94
94
  nlbone/interfaces/api/pagination/offset_base.py,sha256=pdfNgmP99eFC5qCWyY1JgW8hNhOuEGnmrlvQPGArdj8,4709
95
95
  nlbone/interfaces/api/schema/__init__.py,sha256=LAqgynfupeqOQ6u0I5ucrcYnojRMZUg9yW8IjKSQTNI,119
@@ -104,7 +104,7 @@ nlbone/interfaces/jobs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
104
104
  nlbone/interfaces/jobs/dispatch_outbox.py,sha256=yLZSC3nvkgxT2LL4Pq_DYzCyf_tZB-FknrjjgN89GFg,809
105
105
  nlbone/interfaces/jobs/sync_tokens.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
106
106
  nlbone/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
107
- nlbone/utils/cache.py,sha256=KHUYjhIo6dbaSdY9RjbxUJQlLMdacMLjdDm5QZ5dLUw,7305
107
+ nlbone/utils/cache.py,sha256=DkOSwKXG6nShaUq5_MfZIxFWXem_CIZpbgO4CJEaRrE,6918
108
108
  nlbone/utils/cache_keys.py,sha256=Y2YSellHTbUOcoaNbl1jaD4r485VU_e4KXsfBWhYTBo,1075
109
109
  nlbone/utils/cache_registry.py,sha256=3FWYyhujW8oPBiVUPzk1CqJ3jJfxs9729Sbb1pQ5Fag,707
110
110
  nlbone/utils/context.py,sha256=Wq3QLYsMzo_xUiVAHLgEPQUG6LhgJTmFn8MO5Qa7S8w,1837
@@ -116,8 +116,8 @@ nlbone/utils/normalize_mobile.py,sha256=sGH4tV9gX-6eVKozviNWJhm1DN1J28Nj-ERldCYk
116
116
  nlbone/utils/read_files.py,sha256=mx8dfvtaaARQFRp_U7OOiERg-GT62h09_lpTzIQsVhs,291
117
117
  nlbone/utils/redactor.py,sha256=-V4HrHmHwPi3Kez587Ek1uJlgK35qGSrwBOvcbw8Jas,1279
118
118
  nlbone/utils/time.py,sha256=DjjyQ9GLsfXoT6NK8RDW2rOlJg3e6sF04Jw6PBUrSvg,1268
119
- nlbone-0.9.0.dist-info/METADATA,sha256=2GgKIXQKJN0w0vK9mjf5aPvQ3bjuC-Db0VLUY_Jwmh8,2294
120
- nlbone-0.9.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
121
- nlbone-0.9.0.dist-info/entry_points.txt,sha256=CpIL45t5nbhl1dGQPhfIIDfqqak3teK0SxPGBBr7YCk,59
122
- nlbone-0.9.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
123
- nlbone-0.9.0.dist-info/RECORD,,
119
+ nlbone-0.9.1.dist-info/METADATA,sha256=zXLVlrhKwEg_MAP8KqHT1DGOF21PHj5f4YOVM6cUPZo,2294
120
+ nlbone-0.9.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
121
+ nlbone-0.9.1.dist-info/entry_points.txt,sha256=CpIL45t5nbhl1dGQPhfIIDfqqak3teK0SxPGBBr7YCk,59
122
+ nlbone-0.9.1.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
123
+ nlbone-0.9.1.dist-info/RECORD,,
File without changes