nlbone 0.4.3__tar.gz → 0.6.0__tar.gz
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.
- {nlbone-0.4.3 → nlbone-0.6.0}/PKG-INFO +2 -1
- {nlbone-0.4.3 → nlbone-0.6.0}/pyproject.toml +3 -2
- nlbone-0.6.0/src/nlbone/adapters/cache/async_redis.py +180 -0
- nlbone-0.6.0/src/nlbone/adapters/cache/memory.py +104 -0
- nlbone-0.6.0/src/nlbone/adapters/cache/pubsub_listener.py +42 -0
- nlbone-0.6.0/src/nlbone/adapters/cache/redis.py +136 -0
- nlbone-0.6.0/src/nlbone/adapters/http_clients/__init__.py +2 -0
- nlbone-0.6.0/src/nlbone/adapters/http_clients/pricing/__init__.py +1 -0
- nlbone-0.6.0/src/nlbone/adapters/http_clients/pricing/pricing_service.py +96 -0
- nlbone-0.6.0/src/nlbone/adapters/http_clients/uploadchi/__init__.py +2 -0
- {nlbone-0.4.3/src/nlbone/adapters/http_clients → nlbone-0.6.0/src/nlbone/adapters/http_clients/uploadchi}/uploadchi.py +10 -34
- {nlbone-0.4.3/src/nlbone/adapters/http_clients → nlbone-0.6.0/src/nlbone/adapters/http_clients/uploadchi}/uploadchi_async.py +11 -10
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/config/settings.py +22 -12
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/container.py +19 -1
- nlbone-0.6.0/src/nlbone/core/ports/cache.py +37 -0
- nlbone-0.6.0/src/nlbone/utils/cache.py +196 -0
- nlbone-0.6.0/src/nlbone/utils/cache_keys.py +30 -0
- nlbone-0.6.0/src/nlbone/utils/cache_registry.py +23 -0
- nlbone-0.6.0/src/nlbone/utils/http.py +29 -0
- nlbone-0.4.3/src/nlbone/adapters/http_clients/email_gateway.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/.gitignore +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/LICENSE +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/README.md +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/auth/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/auth/keycloak.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/auth/token_provider.py +0 -0
- {nlbone-0.4.3/src/nlbone/adapters/db/redis → nlbone-0.6.0/src/nlbone/adapters/cache}/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/audit.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/base.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/engine.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/query_builder.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/repository.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/schema.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/uow.py +0 -0
- {nlbone-0.4.3/src/nlbone/adapters/http_clients → nlbone-0.6.0/src/nlbone/adapters/db/redis}/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/redis/client.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/messaging/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/messaging/event_bus.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/messaging/redis.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/percolation/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/percolation/connection.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/config/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/config/logging.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/base_worker.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/events.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/services/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/services.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/use_case.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/domain/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/domain/base.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/domain/events.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/domain/models.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/auth.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/event_bus.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/files.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/messaging.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/repo.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/uow.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/async_auth.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/auth.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/db.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/uow.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/exception_handlers.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/exceptions.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/middleware/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/middleware/access_log.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/middleware/add_request_context.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/middleware/authentication.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/pagination/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/pagination/offset_base.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/routers.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/schemas.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/cli/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/cli/init_db.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/cli/main.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/jobs/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/jobs/sync_tokens.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/types.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/utils/__init__.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/utils/context.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/utils/redactor.py +0 -0
- {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/utils/time.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nlbone
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -11,6 +11,7 @@ Requires-Dist: dependency-injector>=4.48.1
|
|
|
11
11
|
Requires-Dist: elasticsearch==8.14.0
|
|
12
12
|
Requires-Dist: fastapi>=0.116
|
|
13
13
|
Requires-Dist: httpx>=0.27
|
|
14
|
+
Requires-Dist: makefun>=1.16.0
|
|
14
15
|
Requires-Dist: psycopg>=3.2.9
|
|
15
16
|
Requires-Dist: pydantic-settings>=2.0
|
|
16
17
|
Requires-Dist: pydantic>=2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nlbone"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Backbone package for interfaces and infrastructure in Python projects"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -26,7 +26,8 @@ dependencies = [
|
|
|
26
26
|
"elasticsearch==8.14.0",
|
|
27
27
|
"redis~=6.4.0",
|
|
28
28
|
"python-dateutil~=2.9.0.post0",
|
|
29
|
-
"typer>=0.17.4"
|
|
29
|
+
"typer>=0.17.4",
|
|
30
|
+
"makefun>=1.16.0"
|
|
30
31
|
]
|
|
31
32
|
|
|
32
33
|
[tool.ruff]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional, Iterable, Any, Mapping, Sequence, List
|
|
5
|
+
|
|
6
|
+
from redis.asyncio import Redis
|
|
7
|
+
from nlbone.core.ports.cache import AsyncCachePort
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _nsver_key(ns: str) -> str: return f"nsver:{ns}"
|
|
11
|
+
def _tag_key(tag: str) -> str: return f"tag:{tag}"
|
|
12
|
+
|
|
13
|
+
class AsyncRedisCache(AsyncCachePort):
|
|
14
|
+
def __init__(self, url: str, *, invalidate_channel: str | None = None):
|
|
15
|
+
self._r = Redis.from_url(url, decode_responses=False)
|
|
16
|
+
self._ch = invalidate_channel or os.getenv("NLBONE_REDIS_INVALIDATE_CHANNEL", "cache:invalidate")
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def redis(self) -> Redis:
|
|
20
|
+
return self._r
|
|
21
|
+
|
|
22
|
+
async def _current_ver(self, ns: str) -> int:
|
|
23
|
+
v = await self._r.get(_nsver_key(ns))
|
|
24
|
+
return int(v) if v else 1
|
|
25
|
+
|
|
26
|
+
async def _full_key(self, key: str) -> str:
|
|
27
|
+
try:
|
|
28
|
+
ns, rest = key.split(":", 1)
|
|
29
|
+
except ValueError:
|
|
30
|
+
ns, rest = "app", key
|
|
31
|
+
ver = await self._current_ver(ns)
|
|
32
|
+
return f"{ns}:{ver}:{rest}"
|
|
33
|
+
|
|
34
|
+
# -------- basic --------
|
|
35
|
+
async def get(self, key: str) -> Optional[bytes]:
|
|
36
|
+
fk = await self._full_key(key)
|
|
37
|
+
return await self._r.get(fk)
|
|
38
|
+
|
|
39
|
+
async def set(self, key: str, value: bytes, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None:
|
|
40
|
+
fk = await self._full_key(key)
|
|
41
|
+
if ttl is None:
|
|
42
|
+
await self._r.set(fk, value)
|
|
43
|
+
else:
|
|
44
|
+
await self._r.setex(fk, ttl, value)
|
|
45
|
+
if tags:
|
|
46
|
+
pipe = self._r.pipeline()
|
|
47
|
+
for t in tags:
|
|
48
|
+
pipe.sadd(_tag_key(t), fk)
|
|
49
|
+
await pipe.execute()
|
|
50
|
+
|
|
51
|
+
async def delete(self, key: str) -> None:
|
|
52
|
+
fk = await self._full_key(key)
|
|
53
|
+
await self._r.delete(fk)
|
|
54
|
+
|
|
55
|
+
async def exists(self, key: str) -> bool:
|
|
56
|
+
return (await self.get(key)) is not None
|
|
57
|
+
|
|
58
|
+
async def ttl(self, key: str) -> Optional[int]:
|
|
59
|
+
fk = await self._full_key(key)
|
|
60
|
+
t = await self._r.ttl(fk)
|
|
61
|
+
return None if t < 0 else int(t)
|
|
62
|
+
|
|
63
|
+
# -------- multi --------
|
|
64
|
+
|
|
65
|
+
async def mget(self, keys: Sequence[str]) -> list[Optional[bytes]]:
|
|
66
|
+
fks = [await self._full_key(k) for k in keys]
|
|
67
|
+
return await self._r.mget(fks)
|
|
68
|
+
|
|
69
|
+
async def mset(self, items: Mapping[str, bytes], *, ttl: Optional[int] = None,
|
|
70
|
+
tags: Optional[Iterable[str]] = None) -> None:
|
|
71
|
+
pipe = self._r.pipeline()
|
|
72
|
+
if ttl is None:
|
|
73
|
+
for k, v in items.items():
|
|
74
|
+
fk = await self._full_key(k)
|
|
75
|
+
pipe.set(fk, v)
|
|
76
|
+
else:
|
|
77
|
+
for k, v in items.items():
|
|
78
|
+
fk = await self._full_key(k)
|
|
79
|
+
pipe.setex(fk, ttl, v)
|
|
80
|
+
await pipe.execute()
|
|
81
|
+
|
|
82
|
+
if tags:
|
|
83
|
+
pipe = self._r.pipeline()
|
|
84
|
+
for t in tags:
|
|
85
|
+
for k in items.keys():
|
|
86
|
+
fk = await self._full_key(k)
|
|
87
|
+
pipe.sadd(_tag_key(t), fk)
|
|
88
|
+
await pipe.execute()
|
|
89
|
+
|
|
90
|
+
# -------- json --------
|
|
91
|
+
|
|
92
|
+
async def get_json(self, key: str) -> Optional[Any]:
|
|
93
|
+
b = await self.get(key)
|
|
94
|
+
return None if b is None else json.loads(b)
|
|
95
|
+
|
|
96
|
+
async def set_json(self, key: str, value: Any, *, ttl: Optional[int] = None,
|
|
97
|
+
tags: Optional[Iterable[str]] = None) -> None:
|
|
98
|
+
await self.set(key, json.dumps(value).encode("utf-8"), ttl=ttl, tags=tags)
|
|
99
|
+
|
|
100
|
+
# -------- invalidation --------
|
|
101
|
+
|
|
102
|
+
async def invalidate_tags(self, tags: Iterable[str]) -> int:
|
|
103
|
+
removed = 0
|
|
104
|
+
pipe = self._r.pipeline()
|
|
105
|
+
key_sets: list[tuple[str, set[bytes]]] = []
|
|
106
|
+
for t in tags:
|
|
107
|
+
tk = _tag_key(t)
|
|
108
|
+
members = await self._r.smembers(tk)
|
|
109
|
+
if members:
|
|
110
|
+
pipe.delete(*members)
|
|
111
|
+
pipe.delete(tk)
|
|
112
|
+
key_sets.append((tk, members))
|
|
113
|
+
removed += len(members or [])
|
|
114
|
+
await pipe.execute()
|
|
115
|
+
|
|
116
|
+
# publish notification for other processes
|
|
117
|
+
try:
|
|
118
|
+
payload = json.dumps({"tags": list(tags)}).encode("utf-8")
|
|
119
|
+
await self._r.publish(self._ch, payload)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
return removed
|
|
124
|
+
|
|
125
|
+
async def bump_namespace(self, namespace: str) -> int:
|
|
126
|
+
v = await self._r.incr(_nsver_key(namespace))
|
|
127
|
+
# اطلاعرسانی اختیاری
|
|
128
|
+
try:
|
|
129
|
+
await self._r.publish(self._ch, json.dumps({"ns_bump": namespace}).encode("utf-8"))
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
return int(v)
|
|
133
|
+
|
|
134
|
+
async def clear_namespace(self, namespace: str) -> int:
|
|
135
|
+
cnt = 0
|
|
136
|
+
cursor = 0
|
|
137
|
+
pattern = f"{namespace}:*"
|
|
138
|
+
while True:
|
|
139
|
+
cursor, keys = await self._r.scan(cursor=cursor, match=pattern, count=1000)
|
|
140
|
+
if keys:
|
|
141
|
+
await self._r.delete(*keys)
|
|
142
|
+
cnt += len(keys)
|
|
143
|
+
if cursor == 0:
|
|
144
|
+
break
|
|
145
|
+
try:
|
|
146
|
+
await self._r.publish(self._ch, json.dumps({"ns_clear": namespace}).encode("utf-8"))
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
return cnt
|
|
150
|
+
|
|
151
|
+
# -------- dogpile-safe get_or_set --------
|
|
152
|
+
|
|
153
|
+
async def get_or_set(self, key: str, producer, *, ttl: int, tags=None) -> bytes:
|
|
154
|
+
fk = await self._full_key(key)
|
|
155
|
+
val = await self._r.get(fk)
|
|
156
|
+
if val is not None:
|
|
157
|
+
return val
|
|
158
|
+
|
|
159
|
+
lock_key = f"lock:{fk}"
|
|
160
|
+
got = await self._r.set(lock_key, b"1", ex=10, nx=True)
|
|
161
|
+
if got:
|
|
162
|
+
try:
|
|
163
|
+
produced = await producer() if asyncio.iscoroutinefunction(producer) else producer()
|
|
164
|
+
if isinstance(produced, str):
|
|
165
|
+
produced = produced.encode("utf-8")
|
|
166
|
+
await self.set(key, produced, ttl=ttl, tags=tags)
|
|
167
|
+
return produced
|
|
168
|
+
finally:
|
|
169
|
+
await self._r.delete(lock_key)
|
|
170
|
+
|
|
171
|
+
await asyncio.sleep(0.05)
|
|
172
|
+
val2 = await self._r.get(fk)
|
|
173
|
+
if val2 is not None:
|
|
174
|
+
return val2
|
|
175
|
+
# fallback
|
|
176
|
+
produced = await producer() if asyncio.iscoroutinefunction(producer) else producer()
|
|
177
|
+
if isinstance(produced, str):
|
|
178
|
+
produced = produced.encode("utf-8")
|
|
179
|
+
await self.set(key, produced, ttl=ttl, tags=tags)
|
|
180
|
+
return produced
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import json, threading, time
|
|
2
|
+
from typing import Optional, Iterable, Any, Mapping, Sequence, Dict, Set
|
|
3
|
+
from nlbone.core.ports.cache import CachePort
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InMemoryCache(CachePort):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self._data: Dict[str, tuple[bytes, Optional[float]]] = {}
|
|
9
|
+
self._tags: Dict[str, Set[str]] = {}
|
|
10
|
+
self._ns_ver: Dict[str, int] = {}
|
|
11
|
+
self._lock = threading.RLock()
|
|
12
|
+
|
|
13
|
+
def _expired(self, key: str) -> bool:
|
|
14
|
+
v = self._data.get(key)
|
|
15
|
+
if not v: return True
|
|
16
|
+
_, exp = v
|
|
17
|
+
return exp is not None and time.time() > exp
|
|
18
|
+
|
|
19
|
+
def _gc(self, key: str) -> None:
|
|
20
|
+
if self._expired(key):
|
|
21
|
+
self._data.pop(key, None)
|
|
22
|
+
|
|
23
|
+
def _attach_tags(self, key: str, tags: Optional[Iterable[str]]) -> None:
|
|
24
|
+
if not tags: return
|
|
25
|
+
for t in tags:
|
|
26
|
+
self._tags.setdefault(t, set()).add(key)
|
|
27
|
+
|
|
28
|
+
def get(self, key: str) -> Optional[bytes]:
|
|
29
|
+
with self._lock:
|
|
30
|
+
self._gc(key)
|
|
31
|
+
v = self._data.get(key)
|
|
32
|
+
return v[0] if v else None
|
|
33
|
+
|
|
34
|
+
def set(self, key: str, value: bytes, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None:
|
|
35
|
+
with self._lock:
|
|
36
|
+
exp = None if ttl is None else time.time() + ttl
|
|
37
|
+
self._data[key] = (value, exp)
|
|
38
|
+
self._attach_tags(key, tags)
|
|
39
|
+
|
|
40
|
+
def delete(self, key: str) -> None:
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._data.pop(key, None)
|
|
43
|
+
for s in self._tags.values():
|
|
44
|
+
s.discard(key)
|
|
45
|
+
|
|
46
|
+
def exists(self, key: str) -> bool:
|
|
47
|
+
return self.get(key) is not None
|
|
48
|
+
|
|
49
|
+
def ttl(self, key: str) -> Optional[int]:
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._gc(key)
|
|
52
|
+
v = self._data.get(key)
|
|
53
|
+
if not v: return None
|
|
54
|
+
_, exp = v
|
|
55
|
+
if exp is None: return None
|
|
56
|
+
rem = int(exp - time.time())
|
|
57
|
+
return rem if rem >= 0 else 0
|
|
58
|
+
|
|
59
|
+
def mget(self, keys: Sequence[str]) -> list[Optional[bytes]]:
|
|
60
|
+
return [self.get(k) for k in keys]
|
|
61
|
+
|
|
62
|
+
def mset(self, items: Mapping[str, bytes], *, ttl: Optional[int] = None,
|
|
63
|
+
tags: Optional[Iterable[str]] = None) -> None:
|
|
64
|
+
for k, v in items.items():
|
|
65
|
+
self.set(k, v, ttl=ttl, tags=tags)
|
|
66
|
+
|
|
67
|
+
def get_json(self, key: str) -> Optional[Any]:
|
|
68
|
+
b = self.get(key)
|
|
69
|
+
return None if b is None else json.loads(b)
|
|
70
|
+
|
|
71
|
+
def set_json(self, key: str, value: Any, *, ttl: Optional[int] = None,
|
|
72
|
+
tags: Optional[Iterable[str]] = None) -> None:
|
|
73
|
+
self.set(key, json.dumps(value).encode("utf-8"), ttl=ttl, tags=tags)
|
|
74
|
+
|
|
75
|
+
def invalidate_tags(self, tags: Iterable[str]) -> int:
|
|
76
|
+
removed = 0
|
|
77
|
+
with self._lock:
|
|
78
|
+
for t in tags:
|
|
79
|
+
keys = self._tags.pop(t, set())
|
|
80
|
+
for k in keys:
|
|
81
|
+
if k in self._data:
|
|
82
|
+
self._data.pop(k, None)
|
|
83
|
+
removed += 1
|
|
84
|
+
return removed
|
|
85
|
+
|
|
86
|
+
def bump_namespace(self, namespace: str) -> int:
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._ns_ver[namespace] = self._ns_ver.get(namespace, 0) + 1
|
|
89
|
+
return self._ns_ver[namespace]
|
|
90
|
+
|
|
91
|
+
def clear_namespace(self, namespace: str) -> int:
|
|
92
|
+
with self._lock:
|
|
93
|
+
keys = [k for k in self._data.keys() if k.startswith(namespace + ":")]
|
|
94
|
+
for k in keys: self.delete(k)
|
|
95
|
+
return len(keys)
|
|
96
|
+
|
|
97
|
+
def get_or_set(self, key: str, producer, *, ttl: int, tags=None) -> bytes:
|
|
98
|
+
with self._lock:
|
|
99
|
+
b = self.get(key)
|
|
100
|
+
if b is not None:
|
|
101
|
+
return b
|
|
102
|
+
val: bytes = producer()
|
|
103
|
+
self.set(key, val, ttl=ttl, tags=tags)
|
|
104
|
+
return val
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
from typing import Awaitable, Callable, Optional
|
|
5
|
+
from redis.asyncio import Redis
|
|
6
|
+
|
|
7
|
+
async def run_cache_invalidation_listener(
|
|
8
|
+
redis: Redis,
|
|
9
|
+
channel: str = "cache:invalidate",
|
|
10
|
+
*,
|
|
11
|
+
on_tags: Optional[Callable[[list[str]], Awaitable[None]]] = None,
|
|
12
|
+
on_ns_bump: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
13
|
+
on_ns_clear: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
14
|
+
stop_event: Optional[asyncio.Event] = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
pubsub = redis.pubsub()
|
|
17
|
+
await pubsub.subscribe(channel)
|
|
18
|
+
try:
|
|
19
|
+
while True:
|
|
20
|
+
if stop_event and stop_event.is_set():
|
|
21
|
+
break
|
|
22
|
+
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
|
23
|
+
if not message:
|
|
24
|
+
await asyncio.sleep(0.05)
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(message["data"])
|
|
28
|
+
except Exception:
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
if "tags" in data and on_tags:
|
|
32
|
+
tags = data.get("tags") or []
|
|
33
|
+
await on_tags(list(tags))
|
|
34
|
+
if "ns_bump" in data and on_ns_bump:
|
|
35
|
+
await on_ns_bump(str(data["ns_bump"]))
|
|
36
|
+
if "ns_clear" in data and on_ns_clear:
|
|
37
|
+
await on_ns_clear(str(data["ns_clear"]))
|
|
38
|
+
finally:
|
|
39
|
+
try:
|
|
40
|
+
await pubsub.unsubscribe(channel)
|
|
41
|
+
finally:
|
|
42
|
+
await pubsub.close()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json, os, time
|
|
3
|
+
from typing import Optional, Iterable, Any, Mapping, Sequence, List, Set
|
|
4
|
+
import redis # redis-py (sync)
|
|
5
|
+
from nlbone.core.ports.cache import CachePort
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _nsver_key(ns: str) -> str: return f"nsver:{ns}"
|
|
10
|
+
def _tag_key(tag: str) -> str: return f"tag:{tag}"
|
|
11
|
+
|
|
12
|
+
class RedisCache(CachePort):
|
|
13
|
+
def __init__(self, url: str):
|
|
14
|
+
self.r = redis.Redis.from_url(url, decode_responses=False)
|
|
15
|
+
|
|
16
|
+
def _current_ver(self, ns: str) -> int:
|
|
17
|
+
v = self.r.get(_nsver_key(ns))
|
|
18
|
+
return int(v) if v else 1
|
|
19
|
+
|
|
20
|
+
def _full_key(self, key: str) -> str:
|
|
21
|
+
try:
|
|
22
|
+
ns, rest = key.split(":", 1)
|
|
23
|
+
except ValueError:
|
|
24
|
+
ns, rest = "app", key
|
|
25
|
+
ver = self._current_ver(ns)
|
|
26
|
+
return f"{ns}:{ver}:{rest}"
|
|
27
|
+
|
|
28
|
+
def get(self, key: str) -> Optional[bytes]:
|
|
29
|
+
fk = self._full_key(key)
|
|
30
|
+
return self.r.get(fk)
|
|
31
|
+
|
|
32
|
+
def set(self, key: str, value: bytes, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None:
|
|
33
|
+
fk = self._full_key(key)
|
|
34
|
+
if ttl is None:
|
|
35
|
+
self.r.set(fk, value)
|
|
36
|
+
else:
|
|
37
|
+
self.r.setex(fk, ttl, value)
|
|
38
|
+
if tags:
|
|
39
|
+
pipe = self.r.pipeline()
|
|
40
|
+
for t in tags:
|
|
41
|
+
pipe.sadd(_tag_key(t), fk)
|
|
42
|
+
pipe.execute()
|
|
43
|
+
|
|
44
|
+
def delete(self, key: str) -> None:
|
|
45
|
+
fk = self._full_key(key)
|
|
46
|
+
self.r.delete(fk)
|
|
47
|
+
|
|
48
|
+
def exists(self, key: str) -> bool:
|
|
49
|
+
return bool(self.get(key))
|
|
50
|
+
|
|
51
|
+
def ttl(self, key: str) -> Optional[int]:
|
|
52
|
+
fk = self._full_key(key)
|
|
53
|
+
t = self.r.ttl(fk)
|
|
54
|
+
return None if t < 0 else int(t)
|
|
55
|
+
|
|
56
|
+
def mget(self, keys: Sequence[str]) -> list[Optional[bytes]]:
|
|
57
|
+
fks = [self._full_key(k) for k in keys]
|
|
58
|
+
return self.r.mget(fks)
|
|
59
|
+
|
|
60
|
+
def mset(self, items: Mapping[str, bytes], *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None:
|
|
61
|
+
pipe = self.r.pipeline()
|
|
62
|
+
if ttl is None:
|
|
63
|
+
for k, v in items.items():
|
|
64
|
+
pipe.set(self._full_key(k), v)
|
|
65
|
+
else:
|
|
66
|
+
for k, v in items.items():
|
|
67
|
+
pipe.setex(self._full_key(k), ttl, v)
|
|
68
|
+
pipe.execute()
|
|
69
|
+
if tags:
|
|
70
|
+
pipe = self.r.pipeline()
|
|
71
|
+
for t in tags:
|
|
72
|
+
for k in items.keys():
|
|
73
|
+
pipe.sadd(_tag_key(t), self._full_key(k))
|
|
74
|
+
pipe.execute()
|
|
75
|
+
|
|
76
|
+
def get_json(self, key: str) -> Optional[Any]:
|
|
77
|
+
b = self.get(key)
|
|
78
|
+
return None if b is None else json.loads(b)
|
|
79
|
+
|
|
80
|
+
def set_json(self, key: str, value: Any, *, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None:
|
|
81
|
+
self.set(key, json.dumps(value).encode("utf-8"), ttl=ttl, tags=tags)
|
|
82
|
+
|
|
83
|
+
def invalidate_tags(self, tags: Iterable[str]) -> int:
|
|
84
|
+
removed = 0
|
|
85
|
+
pipe = self.r.pipeline()
|
|
86
|
+
for t in tags:
|
|
87
|
+
tk = _tag_key(t)
|
|
88
|
+
keys = self.r.smembers(tk)
|
|
89
|
+
if keys:
|
|
90
|
+
pipe.delete(*keys)
|
|
91
|
+
pipe.delete(tk)
|
|
92
|
+
removed += len(keys or [])
|
|
93
|
+
pipe.execute()
|
|
94
|
+
try:
|
|
95
|
+
ch = os.getenv("NLBONE_REDIS_INVALIDATE_CHANNEL", "cache:invalidate")
|
|
96
|
+
self.r.publish(ch, json.dumps({"tags": list(tags)}).encode("utf-8"))
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return removed
|
|
100
|
+
|
|
101
|
+
def bump_namespace(self, namespace: str) -> int:
|
|
102
|
+
v = self.r.incr(_nsver_key(namespace))
|
|
103
|
+
return int(v)
|
|
104
|
+
|
|
105
|
+
def clear_namespace(self, namespace: str) -> int:
|
|
106
|
+
cnt = 0
|
|
107
|
+
cursor = 0
|
|
108
|
+
pattern = f"{namespace}:*"
|
|
109
|
+
while True:
|
|
110
|
+
cursor, keys = self.r.scan(cursor=cursor, match=pattern, count=1000)
|
|
111
|
+
if keys:
|
|
112
|
+
self.r.delete(*keys); cnt += len(keys)
|
|
113
|
+
if cursor == 0: break
|
|
114
|
+
return cnt
|
|
115
|
+
|
|
116
|
+
def get_or_set(self, key: str, producer, *, ttl: int, tags=None) -> bytes:
|
|
117
|
+
fk = self._full_key(key)
|
|
118
|
+
val = self.r.get(fk)
|
|
119
|
+
if val is not None:
|
|
120
|
+
return val
|
|
121
|
+
lock_key = f"lock:{fk}"
|
|
122
|
+
got = self.r.set(lock_key, b"1", nx=True, ex=10)
|
|
123
|
+
if got:
|
|
124
|
+
try:
|
|
125
|
+
produced: bytes = producer()
|
|
126
|
+
self.set(key, produced, ttl=ttl, tags=tags)
|
|
127
|
+
return produced
|
|
128
|
+
finally:
|
|
129
|
+
self.r.delete(lock_key)
|
|
130
|
+
time.sleep(0.05)
|
|
131
|
+
val2 = self.r.get(fk)
|
|
132
|
+
if val2 is not None:
|
|
133
|
+
return val2
|
|
134
|
+
produced: bytes = producer()
|
|
135
|
+
self.set(key, produced, ttl=ttl, tags=tags)
|
|
136
|
+
return produced
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .pricing_service import PricingService, CalculatePriceIn, CalculatePriceOut
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional, Literal, List
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import requests
|
|
6
|
+
from pydantic import BaseModel, Field, NonNegativeInt, RootModel
|
|
7
|
+
|
|
8
|
+
from nlbone.adapters.auth.token_provider import ClientTokenProvider
|
|
9
|
+
from nlbone.config.settings import get_settings
|
|
10
|
+
from nlbone.utils.http import normalize_https_base, auth_headers
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PricingError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CalculatePriceIn(BaseModel):
|
|
18
|
+
params: dict[str, str]
|
|
19
|
+
product_id: NonNegativeInt | None = None
|
|
20
|
+
product_title: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DiscountType(str, Enum):
|
|
24
|
+
percent = "percent"
|
|
25
|
+
amount = "amount"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Product(BaseModel):
|
|
29
|
+
id: Optional[int] = Field(None, description="Nullable product id")
|
|
30
|
+
service_product_id: NonNegativeInt
|
|
31
|
+
title: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Pricing(BaseModel):
|
|
35
|
+
source: Literal["formula", "static"]
|
|
36
|
+
price: float
|
|
37
|
+
discount: Optional[float] = None
|
|
38
|
+
discount_type: Optional[DiscountType] = None
|
|
39
|
+
params: dict
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Formula(BaseModel):
|
|
43
|
+
id: int
|
|
44
|
+
title: str
|
|
45
|
+
key: str
|
|
46
|
+
status: str
|
|
47
|
+
description: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PricingRule(BaseModel):
|
|
51
|
+
product: Product
|
|
52
|
+
segment_name: str | None
|
|
53
|
+
formula: Optional[Formula] = None
|
|
54
|
+
specificity: int
|
|
55
|
+
matched_fields: list
|
|
56
|
+
pricing: Pricing
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CalculatePriceOut(RootModel[List[PricingRule]]):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PricingService:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
token_provider: ClientTokenProvider,
|
|
67
|
+
base_url: Optional[str] = None,
|
|
68
|
+
timeout_seconds: Optional[float] = None,
|
|
69
|
+
client: httpx.Client | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
s = get_settings()
|
|
72
|
+
self._base_url = normalize_https_base(base_url or str(s.PRICING_SERVICE_URL), enforce_https=False)
|
|
73
|
+
self._timeout = timeout_seconds or float(s.HTTP_TIMEOUT_SECONDS)
|
|
74
|
+
self._client = client or requests.session()
|
|
75
|
+
self._token_provider = token_provider
|
|
76
|
+
|
|
77
|
+
def calculate(self, items: list[CalculatePriceIn]) -> CalculatePriceOut:
|
|
78
|
+
body = {
|
|
79
|
+
"items": [i.model_dump() for i in items]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
r = self._client.post(
|
|
83
|
+
f"{self._base_url}/priced",
|
|
84
|
+
headers=auth_headers(self._token_provider.get_access_token()),
|
|
85
|
+
json=body,
|
|
86
|
+
timeout=self._timeout,
|
|
87
|
+
verify=False
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if r.status_code not in (200, 204):
|
|
91
|
+
raise PricingError(r.status_code, r.text)
|
|
92
|
+
|
|
93
|
+
if r.status_code == 204 or not r.content:
|
|
94
|
+
return CalculatePriceOut.model_validate(root=[])
|
|
95
|
+
|
|
96
|
+
return CalculatePriceOut.model_validate(r.json())
|