altcodepro-polydb-python 2.5.4__py3-none-any.whl → 2.5.7__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.
@@ -43,6 +43,23 @@ class VercelKVAdapter(NoSQLKVAdapter):
43
43
  if self.kv_url.startswith("redis://"):
44
44
  self._redis = redis.from_url(self.kv_url, decode_responses=True)
45
45
 
46
+ def _table_name(self, model: type) -> str:
47
+ """Same convention DynamoDBAdapter/_table_name and
48
+ FirestoreAdapter/_collection_name already use -- but here it's not
49
+ optional. Unlike DynamoDB (a real per-model table) or Firestore (a
50
+ real per-model collection), Vercel KV/Redis is one flat keyspace: a
51
+ key that was just "{pk}:{rk}" (tenant_id:id) let two different
52
+ models sharing a tenant_id and, coincidentally, the same id
53
+ collide on the exact same physical key -- one model's create
54
+ silently overwriting an unrelated model's row. Reproduced directly:
55
+ a "notes" row and a "widgets" row both keyed "tenant-a:x1" and the
56
+ second create clobbered the first. Folding the table name into the
57
+ key is the fix; every _*_raw method below must use this, not the
58
+ bare "{pk}:{rk}" shape.
59
+ """
60
+ meta = getattr(model, "__polydb__", {})
61
+ return meta.get("table") or meta.get("collection") or model.__name__.lower()
62
+
46
63
  # ------------------------------------------------------------------
47
64
  # PUT
48
65
  # ------------------------------------------------------------------
@@ -51,12 +68,20 @@ class VercelKVAdapter(NoSQLKVAdapter):
51
68
  def _put_raw(self, model: type, pk: str, rk: str, data: JsonDict) -> JsonDict:
52
69
  try:
53
70
 
54
- key = f"{pk}:{rk}"
71
+ key = f"{self._table_name(model)}:{pk}:{rk}"
55
72
 
56
73
  payload = dict(data)
57
74
  payload["_pk"] = pk
58
75
  payload["_rk"] = rk
59
- payload["id"] = pk
76
+ # NOTE: "id" is intentionally left as whatever the caller supplied
77
+ # (already present via `dict(data)` above). NoSQLKVAdapter._get_pk_rk
78
+ # derives `rk` from data.get(rk_field, ...) with rk_field defaulting
79
+ # to "id" -- i.e. the write-side convention is that a model's "id"
80
+ # field means "row key", not partition key. Overwriting it with
81
+ # `pk` here corrupted the caller's own id field and made it
82
+ # inconsistent with the read-side lookups below, which key on the
83
+ # record's real "id" value rather than the partition key.
84
+ payload.setdefault("id", rk)
60
85
 
61
86
  value = json.dumps(payload, default=json_safe)
62
87
 
@@ -89,7 +114,7 @@ class VercelKVAdapter(NoSQLKVAdapter):
89
114
 
90
115
  try:
91
116
 
92
- key = f"{pk}:{rk}"
117
+ key = f"{self._table_name(model)}:{pk}:{rk}"
93
118
 
94
119
  # LOCAL REDIS
95
120
  if self._redis:
@@ -100,7 +125,6 @@ class VercelKVAdapter(NoSQLKVAdapter):
100
125
  return None
101
126
 
102
127
  obj = json.loads(value)
103
- obj.setdefault("id", obj.get("_pk"))
104
128
  return obj
105
129
 
106
130
  # REST API
@@ -121,7 +145,6 @@ class VercelKVAdapter(NoSQLKVAdapter):
121
145
  return None
122
146
 
123
147
  obj = json.loads(result)
124
- obj.setdefault("id", obj.get("_pk"))
125
148
 
126
149
  return obj
127
150
 
@@ -143,11 +166,12 @@ class VercelKVAdapter(NoSQLKVAdapter):
143
166
  try:
144
167
 
145
168
  results: List[JsonDict] = []
169
+ table = self._table_name(model)
146
170
 
147
171
  # LOCAL REDIS
148
172
  if self._redis:
149
173
 
150
- for key in self._redis.scan_iter("*"):
174
+ for key in self._redis.scan_iter(f"{table}:*"):
151
175
 
152
176
  value: Any = self._redis.get(key)
153
177
 
@@ -160,17 +184,11 @@ class VercelKVAdapter(NoSQLKVAdapter):
160
184
 
161
185
  for k, v in filters.items():
162
186
 
163
- if k == "id":
164
- if obj.get("_pk") != v:
165
- match = False
166
- break
167
-
168
- elif obj.get(k) != v:
187
+ if obj.get(k) != v:
169
188
  match = False
170
189
  break
171
190
 
172
191
  if match:
173
- obj.setdefault("id", obj.get("_pk"))
174
192
  results.append(obj)
175
193
 
176
194
  if limit and len(results) >= limit:
@@ -182,7 +200,7 @@ class VercelKVAdapter(NoSQLKVAdapter):
182
200
  import requests
183
201
 
184
202
  resp = requests.get(
185
- f"{self.kv_url}/keys/*",
203
+ f"{self.kv_url}/keys/{table}:*",
186
204
  headers={"Authorization": f"Bearer {self.kv_token}"},
187
205
  timeout=self.timeout,
188
206
  )
@@ -217,17 +235,11 @@ class VercelKVAdapter(NoSQLKVAdapter):
217
235
 
218
236
  for k, v in filters.items():
219
237
 
220
- if k == "id":
221
- if obj.get("_pk") != v:
222
- match = False
223
- break
224
-
225
- elif obj.get(k) != v:
238
+ if obj.get(k) != v:
226
239
  match = False
227
240
  break
228
241
 
229
242
  if match:
230
- obj.setdefault("id", obj.get("_pk"))
231
243
  results.append(obj)
232
244
 
233
245
  return results
@@ -250,7 +262,7 @@ class VercelKVAdapter(NoSQLKVAdapter):
250
262
 
251
263
  try:
252
264
 
253
- key = f"{pk}:{rk}"
265
+ key = f"{self._table_name(model)}:{pk}:{rk}"
254
266
 
255
267
  # LOCAL REDIS
256
268
  if self._redis:
@@ -260,7 +272,9 @@ class VercelKVAdapter(NoSQLKVAdapter):
260
272
 
261
273
  self._redis.delete(key)
262
274
 
263
- return {"id": pk}
275
+ # "id" means row key throughout this adapter (see _put_raw) --
276
+ # return rk, not pk, for consistency.
277
+ return {"id": rk}
264
278
 
265
279
  # REST API
266
280
  import requests
@@ -280,7 +294,7 @@ class VercelKVAdapter(NoSQLKVAdapter):
280
294
  timeout=self.timeout,
281
295
  ).raise_for_status()
282
296
 
283
- return {"id": pk}
297
+ return {"id": rk}
284
298
 
285
299
  except DatabaseError:
286
300
  raise
@@ -1,7 +1,8 @@
1
1
  import os
2
2
  import json
3
3
  import requests
4
- from typing import Dict, Any, List
4
+ import redis
5
+ from typing import Dict, Any, List, Optional
5
6
 
6
7
  from ..base.QueueAdapter import QueueAdapter
7
8
  from ..errors import QueueError
@@ -14,12 +15,25 @@ class VercelQueueAdapter:
14
15
  self.url = url or os.getenv("KV_REST_API_URL")
15
16
  self.token = token or os.getenv("KV_REST_API_TOKEN")
16
17
 
18
+ # Same local-testability convention VercelKVAdapter already
19
+ # established: a plain redis:// URL means "use local Redis
20
+ # directly," no cloud account or REST endpoint needed. Redis
21
+ # Streams (XADD/XRANGE/XDEL) are the same primitive the REST
22
+ # mode already speaks (see send/receive below), so this isn't a
23
+ # different queue model locally vs. in production, just a
24
+ # different transport to the same Redis Streams operations.
25
+ self._redis: Optional["redis.Redis"] = None
26
+ if self.url and self.url.startswith("redis://"):
27
+ self._redis = redis.from_url(self.url, decode_responses=True)
28
+
17
29
  @retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
18
30
  def send(self, message: Dict[str, Any], queue_name: str = "default") -> str:
19
31
  try:
20
-
21
32
  payload = json.dumps(message)
22
33
 
34
+ if self._redis:
35
+ return self._redis.xadd(queue_name, {"payload": payload})
36
+
23
37
  r = requests.post(
24
38
  f"{self.url}/xadd/{queue_name}",
25
39
  headers={"Authorization": f"Bearer {self.token}"},
@@ -37,6 +51,14 @@ class VercelQueueAdapter:
37
51
  def receive(self, queue_name: str = "default", max_messages: int = 1) -> List[Dict]:
38
52
 
39
53
  try:
54
+ if self._redis:
55
+ entries = self._redis.xrange(queue_name, count=max_messages)
56
+ messages = []
57
+ for entry_id, fields in entries:
58
+ data = json.loads(fields["payload"])
59
+ data["_id"] = entry_id
60
+ messages.append(data)
61
+ return messages
40
62
 
41
63
  r = requests.get(
42
64
  f"{self.url}/xrange/{queue_name}/-/{max_messages}",
@@ -60,6 +82,12 @@ class VercelQueueAdapter:
60
82
  raise QueueError(f"Vercel queue receive failed: {e}")
61
83
 
62
84
  def delete(self, message_id: str, queue_name: str = "default", pop_receipt: str = "") -> bool:
85
+ if self._redis:
86
+ # Real deletion locally -- unlike the REST-mode no-op below
87
+ # (Redis Streams via the Vercel KV REST API has no XDEL
88
+ # endpoint exposed, same limitation ack() below documents for
89
+ # explicit ACK), local Redis has the primitive natively.
90
+ return bool(self._redis.xdel(queue_name, message_id))
63
91
  return True
64
92
 
65
93
  # ---------------------------------------------------------
@@ -2,7 +2,7 @@ from polydb.utils import setup_logger
2
2
 
3
3
 
4
4
  from abc import ABC, abstractmethod
5
- from typing import Any, Dict, List
5
+ from typing import Any, Dict, List, Optional
6
6
 
7
7
 
8
8
  class QueueAdapter(ABC):
@@ -25,3 +25,125 @@ class QueueAdapter(ABC):
25
25
  def delete(self, message_id: str, queue_name: str = "default", pop_receipt: str = "") -> bool:
26
26
  """Delete message from queue"""
27
27
  pass
28
+
29
+ # ------------------------------------------------------------------
30
+ # Extended queue management -- regular (non-abstract) methods, not
31
+ # `@abstractmethod`, on purpose. `send`/`receive`/`delete` above are
32
+ # the original contract every adapter (SQS/Azure/GCP/Vercel/
33
+ # Blockchain/Kafka/RabbitMQ) already implements; making the methods
34
+ # below abstract too would force every one of those subclasses to
35
+ # grow a stub the instant this file changes, even the ones nobody has
36
+ # actually implemented these for yet. Matches the same pattern
37
+ # `NoSQLKVAdapter`/`SecretsAdapter` already use for their own
38
+ # not-yet-universal capabilities (a bare `raise NotImplementedError`
39
+ # in the base, overridden only where a subclass genuinely supports
40
+ # it) -- a subclass that doesn't override one of these raises here,
41
+ # loudly and by name, instead of silently doing nothing.
42
+ # ------------------------------------------------------------------
43
+
44
+ def nack(self, ack_id: str, queue_name: str = "default") -> bool:
45
+ """Negative-acknowledge: put the message back for redelivery
46
+ immediately, without waiting out any natural visibility/
47
+ redelivery timeout the backend might otherwise enforce."""
48
+ raise NotImplementedError(f"{self.__class__.__name__} does not implement nack()")
49
+
50
+ def purge(self, queue_name: str = "default") -> int:
51
+ """Delete every message currently in the queue. Returns the
52
+ number purged where the backend reports one; adapters that
53
+ implement this and genuinely cannot get a count from the backend
54
+ document their own sentinel return value in their override --
55
+ this base default never guesses one for an adapter nobody has
56
+ implemented purge() for yet."""
57
+ raise NotImplementedError(f"{self.__class__.__name__} does not implement purge()")
58
+
59
+ def declare(
60
+ self,
61
+ queue_name: str = "default",
62
+ *,
63
+ durable: bool = True,
64
+ dead_letter_queue: Optional[str] = None,
65
+ ) -> bool:
66
+ """Explicit queue provisioning ahead of first use -- distinct
67
+ from the auto-create-on-first-send/receive some adapters already
68
+ do implicitly. When `dead_letter_queue` is given, wires up
69
+ dead-lettering to a second queue by that name (adapter-specific
70
+ mechanism; see each override for what "dead-lettering" actually
71
+ means on that backend)."""
72
+ raise NotImplementedError(f"{self.__class__.__name__} does not implement declare()")
73
+
74
+ def status(self, queue_name: str = "default") -> Dict[str, Any]:
75
+ """At minimum `{"message_count": int}`; an override may add other
76
+ fields the backend genuinely exposes."""
77
+ raise NotImplementedError(f"{self.__class__.__name__} does not implement status()")
78
+
79
+ # ------------------------------------------------------------------
80
+ # extend / delay / cancel -- the same "regular method, not abstract,
81
+ # base raises loudly by name" shape as nack/purge/declare/status
82
+ # above, for the identical reason: adding these to every existing
83
+ # subclass at once the moment this file changes would be a false
84
+ # claim of support nobody actually verified. Each is real ONLY where
85
+ # a genuine backend primitive exists for it -- checked per adapter,
86
+ # per method, against the real installed SDK, not assumed uniform:
87
+ # - extend(): SQS (ChangeMessageVisibility), Azure Queue
88
+ # (UpdateMessage with no content= change), GCP Pub/Sub
89
+ # (ModifyAckDeadline) all have a REAL "keep this ALREADY-RECEIVED
90
+ # message invisible a while longer" primitive. RabbitMQ/Kafka do
91
+ # not -- AMQP's consumer model has no per-message renewable
92
+ # visibility timer at all (ack/nack/reject only), and Kafka's
93
+ # offset-based model has no per-message delivery state to extend
94
+ # either -- so neither adapter overrides this.
95
+ # - delay(): SQS (SendMessage's own DelaySeconds, real, capped at
96
+ # 900s by SQS itself), Azure Queue (send_message's own
97
+ # visibility_timeout param), and RabbitMQ (a real, plugin-free
98
+ # AMQP pattern: publish to a per-duration queue declared with
99
+ # x-message-ttl + a dead-letter-exchange pointing at the real
100
+ # queue_name, so the message lands there automatically once the
101
+ # TTL elapses) all have a real "don't make this NEW message
102
+ # visible until N seconds from now" primitive. GCP Pub/Sub has no
103
+ # such primitive (a published message is deliverable immediately;
104
+ # Cloud Tasks/Scheduler is a genuinely different GCP service, out
105
+ # of scope for a Pub/Sub adapter) and Kafka doesn't either.
106
+ # - cancel(): only meaningful for a message a delay() call has not
107
+ # yet delivered. Azure Queue is real (the id+pop_receipt
108
+ # send_message's own response returns are already enough to
109
+ # delete that exact message before its visibility_timeout
110
+ # elapses). RabbitMQ is real too, via a bounded scan-and-requeue
111
+ # of the one specific per-duration delay queue this message was
112
+ # published to (see RabbitMQAdapter.cancel's own docstring for
113
+ # why this is the honest, bounded shape rather than an unbounded
114
+ # one). SQS has none: a delayed SQS message is not receivable
115
+ # (no ReceiptHandle exists) until its own DelaySeconds elapses,
116
+ # so there is no real API call that can remove it earlier.
117
+ # ------------------------------------------------------------------
118
+
119
+ def extend(
120
+ self, ack_id: str, queue_name: str = "default", *, visibility_timeout: int = 30
121
+ ) -> bool:
122
+ """Extend how long an already-received, not-yet-acked message
123
+ stays invisible to other consumers, without acknowledging or
124
+ requeuing it -- the redelivery-window equivalent of "I'm still
125
+ working on this, don't hand it to anyone else yet." `ack_id` is
126
+ the same receipt/handle `receive()` returned for this message."""
127
+ raise NotImplementedError(f"{self.__class__.__name__} does not implement extend()")
128
+
129
+ def delay(
130
+ self, message: Dict[str, Any], queue_name: str = "default", *, delay_seconds: int = 0
131
+ ) -> str:
132
+ """Send a NEW message that will not become visible/deliverable
133
+ until `delay_seconds` from now. Returns an id `cancel()` can use
134
+ to remove it again before that happens -- not necessarily the
135
+ same string shape `send()` returns, since some backends (Azure)
136
+ need more than a bare message id to cancel a not-yet-visible
137
+ message."""
138
+ raise NotImplementedError(f"{self.__class__.__name__} does not implement delay()")
139
+
140
+ def cancel(self, message_id: str, queue_name: str = "default") -> bool:
141
+ """Cancel a still-delayed message (one a prior `delay()` call has
142
+ not yet made visible) before it is ever delivered. `message_id`
143
+ is whatever `delay()` returned for it. Returns whether a
144
+ matching still-pending message was actually found and removed --
145
+ `False`, not an exception, when it already fired or never
146
+ existed, the same "unknown id is a no-op, not an error"
147
+ convention `RabbitMQAdapter._ack_delivery` already establishes
148
+ for a different method."""
149
+ raise NotImplementedError(f"{self.__class__.__name__} does not implement cancel()")
@@ -0,0 +1,30 @@
1
+ from ..utils import setup_logger
2
+ from abc import ABC, abstractmethod
3
+ from typing import Optional
4
+
5
+
6
+ class SecretsAdapter(ABC):
7
+ """Base class for cloud secret managers (Key Vault / Secrets Manager /
8
+ Secret Manager / Vault) -- same shape as the other base adapters
9
+ (ObjectStorageAdapter, QueueAdapter, ...), dispatched the same way via
10
+ CloudDatabaseFactory.get_secrets()."""
11
+
12
+ def __init__(self):
13
+ self.logger = setup_logger(self.__class__.__name__)
14
+
15
+ @abstractmethod
16
+ def get_secret(self, key: str) -> Optional[str]:
17
+ """Return the current value of a secret, or None if it doesn't exist."""
18
+ raise NotImplementedError
19
+
20
+ @abstractmethod
21
+ def set_secret(self, key: str, value: str) -> None:
22
+ raise NotImplementedError
23
+
24
+ @abstractmethod
25
+ def delete_secret(self, key: str) -> bool:
26
+ raise NotImplementedError
27
+
28
+ @abstractmethod
29
+ def list_secrets(self, prefix: str = "") -> list[str]:
30
+ raise NotImplementedError
polydb/cache.py CHANGED
@@ -204,6 +204,96 @@ class RedisCacheEngine:
204
204
  except Exception:
205
205
  pass
206
206
 
207
+ # ---------------------------------------------------------
208
+ # Generic raw KV operations -- for a caller that wants a plain
209
+ # Redis get/set/incr under an explicit key it picked itself, not the
210
+ # query-result caching get()/set() above (which hash a query dict
211
+ # into the key). Reuses the same _make_raw_key (no hashing) as the
212
+ # zset methods above, so a raw KV key and a zset key never collide
213
+ # as long as the caller doesn't reuse the same `key` for both.
214
+ #
215
+ # Unlike get()/set()/invalidate() above -- which are an *automatic*,
216
+ # best-effort read-through cache where a Redis hiccup or missing
217
+ # backend should never break the underlying query -- these raw
218
+ # methods back a caller's own explicit business logic (e.g. a
219
+ # workflow step doing rate limiting or a distributed lock via
220
+ # incrby()). Silently returning None/0/False there would hide a real
221
+ # misconfiguration (no REDIS_CACHE_URL set) behind wrong-looking
222
+ # business results instead of a clear error, so these raise
223
+ # CacheError when no client is configured or the operation itself
224
+ # fails, rather than swallowing the exception.
225
+ # ---------------------------------------------------------
226
+
227
+ def get_raw(self, model: str, key: str) -> Optional[Any]:
228
+ from .errors import CacheError
229
+
230
+ if not self._client:
231
+ raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
232
+ redis_key = self._make_raw_key(model, key)
233
+ try:
234
+ data = self._client.get(redis_key)
235
+ if data is None:
236
+ return None
237
+ if isinstance(data, bytes):
238
+ data = data.decode("utf-8")
239
+ return json.loads(data)
240
+ except Exception as e:
241
+ raise CacheError(f"Redis get failed: {e}")
242
+
243
+ def set_raw(self, model: str, key: str, value: Any, ttl: Optional[int] = None) -> bool:
244
+ from .errors import CacheError
245
+
246
+ if not self._client:
247
+ raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
248
+ redis_key = self._make_raw_key(model, key)
249
+ data = json.dumps(value, default=json_safe)
250
+ try:
251
+ if ttl:
252
+ self._client.setex(redis_key, ttl, data)
253
+ else:
254
+ self._client.set(redis_key, data)
255
+ return True
256
+ except Exception as e:
257
+ raise CacheError(f"Redis set failed: {e}")
258
+
259
+ def incrby(self, model: str, key: str, amount: int = 1) -> int:
260
+ """Atomic increment (INCRBY) of a raw key, creating it at 0 first
261
+ if absent -- the primitive a rate-limiter/counter workflow step
262
+ needs, distinct from the zset-based rate limiting above."""
263
+ from .errors import CacheError
264
+
265
+ if not self._client:
266
+ raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
267
+ redis_key = self._make_raw_key(model, key)
268
+ try:
269
+ return int(self._client.incrby(redis_key, amount))
270
+ except Exception as e:
271
+ raise CacheError(f"Redis incrby failed: {e}")
272
+
273
+ def exists_raw(self, model: str, key: str) -> bool:
274
+ from .errors import CacheError
275
+
276
+ if not self._client:
277
+ raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
278
+ redis_key = self._make_raw_key(model, key)
279
+ try:
280
+ return bool(self._client.exists(redis_key))
281
+ except Exception as e:
282
+ raise CacheError(f"Redis exists failed: {e}")
283
+
284
+ def ttl_raw(self, model: str, key: str) -> int:
285
+ """Seconds until expiry; -1 means the key exists with no TTL, -2
286
+ means the key doesn't exist -- standard Redis TTL semantics."""
287
+ from .errors import CacheError
288
+
289
+ if not self._client:
290
+ raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
291
+ redis_key = self._make_raw_key(model, key)
292
+ try:
293
+ return int(self._client.ttl(redis_key))
294
+ except Exception as e:
295
+ raise CacheError(f"Redis ttl failed: {e}")
296
+
207
297
 
208
298
  class CacheWarmer:
209
299
  """Pre-populate cache with frequently accessed data"""