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.
Files changed (92) hide show
  1. {nlbone-0.4.3 → nlbone-0.6.0}/PKG-INFO +2 -1
  2. {nlbone-0.4.3 → nlbone-0.6.0}/pyproject.toml +3 -2
  3. nlbone-0.6.0/src/nlbone/adapters/cache/async_redis.py +180 -0
  4. nlbone-0.6.0/src/nlbone/adapters/cache/memory.py +104 -0
  5. nlbone-0.6.0/src/nlbone/adapters/cache/pubsub_listener.py +42 -0
  6. nlbone-0.6.0/src/nlbone/adapters/cache/redis.py +136 -0
  7. nlbone-0.6.0/src/nlbone/adapters/http_clients/__init__.py +2 -0
  8. nlbone-0.6.0/src/nlbone/adapters/http_clients/pricing/__init__.py +1 -0
  9. nlbone-0.6.0/src/nlbone/adapters/http_clients/pricing/pricing_service.py +96 -0
  10. nlbone-0.6.0/src/nlbone/adapters/http_clients/uploadchi/__init__.py +2 -0
  11. {nlbone-0.4.3/src/nlbone/adapters/http_clients → nlbone-0.6.0/src/nlbone/adapters/http_clients/uploadchi}/uploadchi.py +10 -34
  12. {nlbone-0.4.3/src/nlbone/adapters/http_clients → nlbone-0.6.0/src/nlbone/adapters/http_clients/uploadchi}/uploadchi_async.py +11 -10
  13. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/config/settings.py +22 -12
  14. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/container.py +19 -1
  15. nlbone-0.6.0/src/nlbone/core/ports/cache.py +37 -0
  16. nlbone-0.6.0/src/nlbone/utils/cache.py +196 -0
  17. nlbone-0.6.0/src/nlbone/utils/cache_keys.py +30 -0
  18. nlbone-0.6.0/src/nlbone/utils/cache_registry.py +23 -0
  19. nlbone-0.6.0/src/nlbone/utils/http.py +29 -0
  20. nlbone-0.4.3/src/nlbone/adapters/http_clients/email_gateway.py +0 -0
  21. {nlbone-0.4.3 → nlbone-0.6.0}/.gitignore +0 -0
  22. {nlbone-0.4.3 → nlbone-0.6.0}/LICENSE +0 -0
  23. {nlbone-0.4.3 → nlbone-0.6.0}/README.md +0 -0
  24. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/__init__.py +0 -0
  25. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/__init__.py +0 -0
  26. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/auth/__init__.py +0 -0
  27. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/auth/keycloak.py +0 -0
  28. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/auth/token_provider.py +0 -0
  29. {nlbone-0.4.3/src/nlbone/adapters/db/redis → nlbone-0.6.0/src/nlbone/adapters/cache}/__init__.py +0 -0
  30. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/__init__.py +0 -0
  31. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/__init__.py +0 -0
  32. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/audit.py +0 -0
  33. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/base.py +0 -0
  34. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/engine.py +0 -0
  35. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/query_builder.py +0 -0
  36. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/repository.py +0 -0
  37. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/schema.py +0 -0
  38. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/postgres/uow.py +0 -0
  39. {nlbone-0.4.3/src/nlbone/adapters/http_clients → nlbone-0.6.0/src/nlbone/adapters/db/redis}/__init__.py +0 -0
  40. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/db/redis/client.py +0 -0
  41. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/messaging/__init__.py +0 -0
  42. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/messaging/event_bus.py +0 -0
  43. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/messaging/redis.py +0 -0
  44. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/percolation/__init__.py +0 -0
  45. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/adapters/percolation/connection.py +0 -0
  46. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/config/__init__.py +0 -0
  47. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/config/logging.py +0 -0
  48. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/__init__.py +0 -0
  49. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/__init__.py +0 -0
  50. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/base_worker.py +0 -0
  51. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/events.py +0 -0
  52. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/services/__init__.py +0 -0
  53. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/services.py +0 -0
  54. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/application/use_case.py +0 -0
  55. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/domain/__init__.py +0 -0
  56. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/domain/base.py +0 -0
  57. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/domain/events.py +0 -0
  58. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/domain/models.py +0 -0
  59. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/__init__.py +0 -0
  60. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/auth.py +0 -0
  61. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/event_bus.py +0 -0
  62. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/files.py +0 -0
  63. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/messaging.py +0 -0
  64. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/repo.py +0 -0
  65. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/core/ports/uow.py +0 -0
  66. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/__init__.py +0 -0
  67. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/__init__.py +0 -0
  68. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/__init__.py +0 -0
  69. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/async_auth.py +0 -0
  70. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/auth.py +0 -0
  71. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/db.py +0 -0
  72. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/dependencies/uow.py +0 -0
  73. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/exception_handlers.py +0 -0
  74. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/exceptions.py +0 -0
  75. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/middleware/__init__.py +0 -0
  76. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/middleware/access_log.py +0 -0
  77. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/middleware/add_request_context.py +0 -0
  78. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/middleware/authentication.py +0 -0
  79. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/pagination/__init__.py +0 -0
  80. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/pagination/offset_base.py +0 -0
  81. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/routers.py +0 -0
  82. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/api/schemas.py +0 -0
  83. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/cli/__init__.py +0 -0
  84. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/cli/init_db.py +0 -0
  85. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/cli/main.py +0 -0
  86. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/jobs/__init__.py +0 -0
  87. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/interfaces/jobs/sync_tokens.py +0 -0
  88. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/types.py +0 -0
  89. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/utils/__init__.py +0 -0
  90. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/utils/context.py +0 -0
  91. {nlbone-0.4.3 → nlbone-0.6.0}/src/nlbone/utils/redactor.py +0 -0
  92. {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.4.3
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.4.3"
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,2 @@
1
+ from .uploadchi import UploadchiClient, UploadchiAsyncClient, UploadchiError
2
+ from .pricing import PricingService, CalculatePriceIn, CalculatePriceOut
@@ -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())
@@ -0,0 +1,2 @@
1
+ from .uploadchi import UploadchiClient, UploadchiError
2
+ from .uploadchi_async import UploadchiAsyncClient