altcodepro-polydb-python 2.5.4__py3-none-any.whl → 2.5.6__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.
@@ -0,0 +1,465 @@
1
+ # src/polydb/adapters/RabbitMQAdapter.py
2
+ import os
3
+ import json
4
+ import threading
5
+ import uuid
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from ..base.QueueAdapter import QueueAdapter
9
+ from ..errors import ConnectionError, QueueError
10
+ from ..retry import retry
11
+ from ..json_safe import json_safe
12
+
13
+
14
+ class RabbitMQAdapter(QueueAdapter):
15
+ """
16
+ RabbitMQ adapter using the synchronous `pika` client, not `aio-pika` --
17
+ same rationale as KafkaQueueAdapter: every adapter in this codebase is
18
+ synchronous end to end.
19
+
20
+ queue_name IS the AMQP queue name. Queues are declared durable and
21
+ auto-created on first use, matching SQSAdapter's/AzureQueueAdapter's
22
+ "create the underlying resource if it doesn't already exist" behavior.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ url: str = "",
28
+ host: str = "",
29
+ port: int = 0,
30
+ username: str = "",
31
+ password: str = "",
32
+ virtual_host: str = "",
33
+ ):
34
+ super().__init__()
35
+
36
+ # An explicit amqp:// URL (or RABBITMQ_URL/AMQP_URL env var) takes
37
+ # priority; otherwise build one from discrete fields/env so callers
38
+ # don't have to hand-assemble a URL just to point at localhost.
39
+ self.url = url or os.getenv("RABBITMQ_URL") or os.getenv("AMQP_URL")
40
+
41
+ if not self.url:
42
+ host = host or os.getenv("RABBITMQ_HOST", "localhost")
43
+ port = port or int(os.getenv("RABBITMQ_PORT", "5672"))
44
+ username = username or os.getenv("RABBITMQ_USER", "guest")
45
+ password = password or os.getenv("RABBITMQ_PASSWORD", "guest")
46
+ virtual_host = virtual_host or os.getenv("RABBITMQ_VHOST", "/")
47
+ vhost_path = virtual_host if virtual_host.startswith("/") else f"/{virtual_host}"
48
+ self.url = f"amqp://{username}:{password}@{host}:{port}{vhost_path}"
49
+
50
+ self._connection: Any = None
51
+ self._channel: Any = None
52
+ self._declared_queues: set = set()
53
+ self._lock = threading.Lock()
54
+
55
+ # Our own message_id (embedded in AMQP message properties on
56
+ # publish, since basic_publish has no return value to hand one
57
+ # back) -> the AMQP delivery_tag basic_get gave us for it. ack()/
58
+ # delete() look the delivery tag up here so callers never need to
59
+ # know pika's delivery-tag concept exists.
60
+ self._pending: Dict[str, int] = {}
61
+
62
+ self._initialize_connection()
63
+
64
+ # ---------------------------------------------------------
65
+ # Connection management
66
+ # ---------------------------------------------------------
67
+
68
+ def _initialize_connection(self) -> None:
69
+ import pika
70
+
71
+ try:
72
+ with self._lock:
73
+ if self._connection is not None and self._connection.is_open:
74
+ return
75
+ self._connection = pika.BlockingConnection(pika.URLParameters(self.url))
76
+ self._channel = self._connection.channel()
77
+ self.logger.info("Initialized RabbitMQ connection")
78
+ except Exception as e:
79
+ raise ConnectionError(f"RabbitMQ connection failed: {e}")
80
+
81
+ def _ensure_open(self) -> None:
82
+ needs_reconnect = (
83
+ self._connection is None
84
+ or self._connection.is_closed
85
+ or self._channel is None
86
+ or self._channel.is_closed
87
+ )
88
+ if needs_reconnect:
89
+ # A dropped connection also invalidates any queues we thought
90
+ # we'd already declared on the old channel.
91
+ self._declared_queues.clear()
92
+ self._initialize_connection()
93
+
94
+ def _ensure_queue(self, queue_name: str) -> None:
95
+ """Auto-create-on-first-use path for send()/receive(). Plain
96
+ `durable=True`, no arguments -- if this ran again on a queue
97
+ `declare()` already provisioned WITH dead-letter arguments,
98
+ RabbitMQ would reject it as a channel-level PRECONDITION_FAILED
99
+ (re-declaring an existing queue with different `arguments` is an
100
+ error, not a silent merge). That doesn't happen here because
101
+ `declare()` adds `queue_name` to `_declared_queues` itself after
102
+ declaring it -- the `if queue_name in self._declared_queues:
103
+ return` guard below short-circuits before this method's own
104
+ plain queue_declare ever runs, as long as `declare()` was called
105
+ before the first send()/receive() on that queue. Calling
106
+ `declare()` *after* an implicit auto-create already happened on
107
+ the same name is the one ordering this doesn't protect against --
108
+ the second declare (with arguments) would itself hit
109
+ PRECONDITION_FAILED against the already-durable/no-arguments
110
+ queue this method created first.
111
+ """
112
+ if queue_name in self._declared_queues:
113
+ return
114
+ self._channel.queue_declare(queue=queue_name, durable=True)
115
+ self._declared_queues.add(queue_name)
116
+
117
+ # ---------------------------------------------------------
118
+ # Queue operations
119
+ # ---------------------------------------------------------
120
+
121
+ @retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
122
+ def send(self, message: Dict[str, Any], queue_name: str = "default") -> str:
123
+ """Publish to `queue_name`, returning a message id we generate and
124
+ embed ourselves -- AMQP's basic_publish is fire-and-forget and
125
+ hands back nothing to identify the message with."""
126
+ try:
127
+ import pika
128
+
129
+ self._ensure_open()
130
+ self._ensure_queue(queue_name)
131
+
132
+ message_id = uuid.uuid4().hex
133
+ body = json.dumps(message, default=json_safe).encode("utf-8")
134
+
135
+ self._channel.basic_publish(
136
+ exchange="",
137
+ routing_key=queue_name,
138
+ body=body,
139
+ properties=pika.BasicProperties(
140
+ message_id=message_id,
141
+ delivery_mode=2, # persistent: survives a broker restart
142
+ ),
143
+ )
144
+
145
+ return message_id
146
+
147
+ except Exception as e:
148
+ raise QueueError(f"RabbitMQ send failed: {e}")
149
+
150
+ @retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
151
+ def receive(self, queue_name: str = "default", max_messages: int = 1) -> List[Dict[str, Any]]:
152
+ """
153
+ basic_get in a loop, not a long-lived basic_consume callback. The
154
+ QueueAdapter contract needs receive() to return promptly with
155
+ whatever's immediately available -- the same shape SQS/Azure/
156
+ Pub/Sub/Kafka already have. A consumer callback would instead
157
+ block indefinitely inside pika's IO loop waiting for messages,
158
+ which doesn't fit a synchronous request/response method at all.
159
+ """
160
+ try:
161
+ self._ensure_open()
162
+ self._ensure_queue(queue_name)
163
+
164
+ out: List[Dict[str, Any]] = []
165
+
166
+ for _ in range(max_messages):
167
+ method, properties, body = self._channel.basic_get(queue=queue_name, auto_ack=False)
168
+ if method is None:
169
+ break # queue is (currently) empty
170
+
171
+ message_id = (properties.message_id if properties else None) or str(
172
+ method.delivery_tag
173
+ )
174
+
175
+ try:
176
+ payload = json.loads(body.decode("utf-8"))
177
+ except Exception:
178
+ payload = body.decode("utf-8", errors="replace")
179
+
180
+ self._pending[message_id] = method.delivery_tag
181
+
182
+ out.append(
183
+ {
184
+ "id": message_id,
185
+ "receipt_handle": message_id,
186
+ "body": payload,
187
+ "redelivered": method.redelivered,
188
+ }
189
+ )
190
+
191
+ return out
192
+
193
+ except Exception as e:
194
+ raise QueueError(f"RabbitMQ receive failed: {e}")
195
+
196
+ def _ack_delivery(self, message_id: str) -> bool:
197
+ delivery_tag = self._pending.pop(message_id, None)
198
+ if delivery_tag is None:
199
+ # Unknown/already-acked id -- treat as a no-op success, matching
200
+ # VercelQueueAdapter's/BlockchainQueueAdapter's existing
201
+ # convention for a redundant ack rather than raising.
202
+ return False
203
+ try:
204
+ self._ensure_open()
205
+ self._channel.basic_ack(delivery_tag=delivery_tag)
206
+ return True
207
+ except Exception as e:
208
+ raise QueueError(f"RabbitMQ ack failed: {e}")
209
+
210
+ def ack(self, ack_id: str, queue_name: str = "default") -> bool:
211
+ if not ack_id:
212
+ raise QueueError("ack_id is required for RabbitMQ ack")
213
+ return self._ack_delivery(ack_id)
214
+
215
+ def delete(self, message_id: str, queue_name: str = "default", pop_receipt: str = "") -> bool:
216
+ """
217
+ Delete == ack. AMQP has no "delete without processing" primitive
218
+ beyond ack/nack/reject -- basic_ack is the only way to permanently
219
+ remove a message from the queue -- so this collapses to the same
220
+ call as ack(), matching the majority convention among this
221
+ codebase's other adapters (SQS, Pub/Sub: ack is delete).
222
+ """
223
+ return self._ack_delivery(message_id)
224
+
225
+ def nack(self, ack_id: str, queue_name: str = "default") -> bool:
226
+ """basic_nack(requeue=True) -- puts the message straight back on
227
+ the queue for immediate redelivery, same `_pending` delivery-tag
228
+ lookup ack()/delete() already use, same "unknown id is a
229
+ harmless no-op" convention `_ack_delivery` establishes. This is
230
+ deliberately NOT how dead-lettering gets triggered (that's
231
+ `basic_nack(requeue=False)`/`basic_reject(requeue=False)` or a
232
+ TTL/max-length policy on the queue itself, not a per-call
233
+ requeue=False here) -- this method's contract is "redeliver now",
234
+ not "give up on it", so it always requeues."""
235
+ delivery_tag = self._pending.pop(ack_id, None)
236
+ if delivery_tag is None:
237
+ return False
238
+ try:
239
+ self._ensure_open()
240
+ self._channel.basic_nack(delivery_tag=delivery_tag, requeue=True)
241
+ return True
242
+ except Exception as e:
243
+ raise QueueError(f"RabbitMQ nack failed: {e}")
244
+
245
+ def purge(self, queue_name: str = "default") -> int:
246
+ """channel.queue_purge()'s Queue.PurgeOk response frame carries
247
+ the actual count RabbitMQ purged (`.method.message_count`) --
248
+ read from pika's own BlockingChannel.queue_purge return type
249
+ rather than assumed, verified against a real local broker."""
250
+ try:
251
+ self._ensure_open()
252
+ self._ensure_queue(queue_name)
253
+ result = self._channel.queue_purge(queue=queue_name)
254
+ return result.method.message_count
255
+ except Exception as e:
256
+ raise QueueError(f"RabbitMQ purge failed: {e}")
257
+
258
+ def declare(
259
+ self,
260
+ queue_name: str = "default",
261
+ *,
262
+ durable: bool = True,
263
+ dead_letter_queue: Optional[str] = None,
264
+ ) -> bool:
265
+ """Real AMQP dead-lettering via `x-dead-letter-exchange`/
266
+ `x-dead-letter-routing-key` queue arguments -- not a hand-rolled
267
+ shadow implementation that polls and re-routes messages itself.
268
+ The default exchange ("") + the DLQ's own name as routing key is
269
+ how AMQP delivers a dead-lettered message straight to a queue by
270
+ that name with no extra exchange/binding setup required.
271
+
272
+ The DLQ target is declared first, as an ordinary durable queue,
273
+ so the dead-letter-routing-key always has somewhere real to land
274
+ before the source queue (which references it) is declared.
275
+ """
276
+ try:
277
+ self._ensure_open()
278
+
279
+ arguments: Dict[str, Any] = {}
280
+ if dead_letter_queue:
281
+ self._channel.queue_declare(queue=dead_letter_queue, durable=True)
282
+ self._declared_queues.add(dead_letter_queue)
283
+ arguments["x-dead-letter-exchange"] = ""
284
+ arguments["x-dead-letter-routing-key"] = dead_letter_queue
285
+
286
+ self._channel.queue_declare(
287
+ queue=queue_name, durable=durable, arguments=arguments or None
288
+ )
289
+ # Marking it declared here is what keeps _ensure_queue()'s own
290
+ # implicit auto-create path (plain durable=True, no
291
+ # arguments) from ever re-declaring this queue with different
292
+ # arguments on the first send()/receive() -- see that
293
+ # method's docstring for the PRECONDITION_FAILED this avoids.
294
+ self._declared_queues.add(queue_name)
295
+ return True
296
+ except Exception as e:
297
+ raise QueueError(f"RabbitMQ declare failed: {e}")
298
+
299
+ def status(self, queue_name: str = "default") -> Dict[str, Any]:
300
+ """queue_declare(passive=True) -- "don't create it, just inspect
301
+ it" (raises if the queue doesn't already exist, unlike a plain
302
+ queue_declare) -- confirmed against pika's actual
303
+ BlockingChannel.queue_declare behavior, not assumed. Its
304
+ Queue.DeclareOk response frame carries both message_count and
305
+ consumer_count for a classic queue."""
306
+ try:
307
+ self._ensure_open()
308
+ result = self._channel.queue_declare(queue=queue_name, passive=True)
309
+ return {
310
+ "message_count": result.method.message_count,
311
+ "consumer_count": result.method.consumer_count,
312
+ }
313
+ except Exception as e:
314
+ raise QueueError(f"RabbitMQ status failed: {e}")
315
+
316
+ # ---------------------------------------------------------
317
+ # delay / cancel -- real, plugin-free AMQP TTL + dead-letter-exchange
318
+ # pattern. extend() is NOT overridden: AMQP's ack/nack/reject model
319
+ # has no per-message renewable visibility timer at all, so the base
320
+ # class's NotImplementedError is the honest answer.
321
+ # ---------------------------------------------------------
322
+
323
+ # Bound on how many messages a single cancel() scan will basic_get
324
+ # through looking for the target message_id, so an adversarial or
325
+ # just very full delay queue can't make one cancel() call block
326
+ # (or nack-and-requeue-into-a-loop) indefinitely.
327
+ MAX_CANCEL_SCAN = 10_000
328
+
329
+ @staticmethod
330
+ def _delay_queue_name(queue_name: str, delay_seconds: int) -> str:
331
+ return f"{queue_name}.delay.{delay_seconds}"
332
+
333
+ def _ensure_delay_queue(self, queue_name: str, delay_seconds: int) -> str:
334
+ """Declare (once) the per-duration delay queue that dead-letters
335
+ into the real queue_name once its own TTL elapses -- the same
336
+ dead-lettering mechanism declare()'s own dead_letter_queue
337
+ argument already uses, applied here to delay instead of
338
+ failure."""
339
+ delay_queue = self._delay_queue_name(queue_name, delay_seconds)
340
+ if delay_queue in self._declared_queues:
341
+ return delay_queue
342
+
343
+ self._channel.queue_declare(
344
+ queue=delay_queue,
345
+ durable=True,
346
+ arguments={
347
+ "x-message-ttl": delay_seconds * 1000,
348
+ "x-dead-letter-exchange": "",
349
+ "x-dead-letter-routing-key": queue_name,
350
+ },
351
+ )
352
+ self._declared_queues.add(delay_queue)
353
+ return delay_queue
354
+
355
+ @retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
356
+ def delay(
357
+ self, message: Dict[str, Any], queue_name: str = "default", *, delay_seconds: int = 0
358
+ ) -> str:
359
+ """Publish to a per-duration delay queue (x-message-ttl +
360
+ dead-letter-exchange pointing at queue_name) so the message
361
+ lands there automatically once delay_seconds elapses. Returns a
362
+ self-generated message_id cancel() can use to remove it first,
363
+ the same embed-our-own-id approach send() already uses since
364
+ basic_publish itself returns nothing."""
365
+ try:
366
+ import pika
367
+
368
+ self._ensure_open()
369
+ self._ensure_queue(queue_name)
370
+ delay_queue = self._ensure_delay_queue(queue_name, delay_seconds)
371
+
372
+ message_id = uuid.uuid4().hex
373
+ body = json.dumps(message, default=json_safe).encode("utf-8")
374
+
375
+ self._channel.basic_publish(
376
+ exchange="",
377
+ routing_key=delay_queue,
378
+ body=body,
379
+ properties=pika.BasicProperties(
380
+ message_id=message_id,
381
+ delivery_mode=2,
382
+ ),
383
+ )
384
+
385
+ return message_id
386
+
387
+ except Exception as e:
388
+ raise QueueError(f"RabbitMQ delay failed: {e}")
389
+
390
+ def cancel(
391
+ self, message_id: str, queue_name: str = "default", *, delay_seconds: Optional[int] = None
392
+ ) -> bool:
393
+ """Remove a still-delayed message before its delay queue's own
394
+ TTL elapses, via a bounded scan-and-requeue of that one specific
395
+ per-duration delay queue: basic_get everything currently in it
396
+ (up to MAX_CANCEL_SCAN), basic_ack the one matching message_id
397
+ to drop it, basic_nack(requeue=True) every other message to keep
398
+ them correctly scheduled.
399
+
400
+ `delay_seconds` identifies WHICH delay queue to scan -- the same
401
+ value passed to the original delay() call. Without it there is
402
+ no way to know which per-duration queue the message is sitting
403
+ in, so it is required here (unlike the base contract's bare
404
+ `message_id`) and this override intentionally has a different,
405
+ wider signature than QueueAdapter.cancel's own.
406
+ """
407
+ if delay_seconds is None:
408
+ raise QueueError(
409
+ "RabbitMQAdapter.cancel requires delay_seconds (the same value passed to delay()) "
410
+ "to know which per-duration delay queue to scan"
411
+ )
412
+ if not message_id:
413
+ raise QueueError("message_id is required for RabbitMQ cancel")
414
+
415
+ try:
416
+ self._ensure_open()
417
+ delay_queue = self._ensure_delay_queue(queue_name, delay_seconds)
418
+
419
+ found = False
420
+ scanned = 0
421
+ # Two passes: first drain up to MAX_CANCEL_SCAN messages,
422
+ # remembering which delivery_tag (if any) matches
423
+ # message_id; then ack the match and nack(requeue=True)
424
+ # everything else, so a message that doesn't match is
425
+ # restored to the queue rather than lost.
426
+ to_requeue = []
427
+ match_tag = None
428
+
429
+ while scanned < self.MAX_CANCEL_SCAN:
430
+ method, properties, body = self._channel.basic_get(
431
+ queue=delay_queue, auto_ack=False
432
+ )
433
+ if method is None:
434
+ break
435
+ scanned += 1
436
+ this_id = properties.message_id if properties else None
437
+ if this_id == message_id and match_tag is None:
438
+ match_tag = method.delivery_tag
439
+ found = True
440
+ else:
441
+ to_requeue.append(method.delivery_tag)
442
+
443
+ if match_tag is not None:
444
+ self._channel.basic_ack(delivery_tag=match_tag)
445
+ for tag in to_requeue:
446
+ self._channel.basic_nack(delivery_tag=tag, requeue=True)
447
+
448
+ return found
449
+
450
+ except Exception as e:
451
+ raise QueueError(f"RabbitMQ cancel failed: {e}")
452
+
453
+ def close(self) -> None:
454
+ """Close the channel/connection. Not part of QueueAdapter's
455
+ abstract contract, but good hygiene for a long-lived instance."""
456
+ try:
457
+ if self._channel is not None and self._channel.is_open:
458
+ self._channel.close()
459
+ except Exception:
460
+ pass
461
+ try:
462
+ if self._connection is not None and self._connection.is_open:
463
+ self._connection.close()
464
+ except Exception:
465
+ pass
@@ -180,3 +180,63 @@ class SQSAdapter(QueueAdapter):
180
180
  raise QueueError("ack_id (receipt_handle) is required for SQS ack")
181
181
 
182
182
  return self.delete(ack_id, queue_name=queue_name)
183
+
184
+ # ---------------------------------------------------------
185
+ # extend / delay -- real SQS primitives. cancel() is NOT
186
+ # overridden: a delayed SQS message has no ReceiptHandle until its
187
+ # own DelaySeconds elapses, so there is no API call that can remove
188
+ # it before then -- the base class's NotImplementedError is the
189
+ # honest answer, not a gap to work around.
190
+ # ---------------------------------------------------------
191
+
192
+ @retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
193
+ def extend(
194
+ self, ack_id: str, queue_name: str = "default", *, visibility_timeout: int = 30
195
+ ) -> bool:
196
+ """Real SQS ChangeMessageVisibility -- ack_id is the same
197
+ receipt_handle receive() returned for this message."""
198
+ if not ack_id:
199
+ raise QueueError("ack_id (receipt_handle) is required for SQS extend")
200
+ try:
201
+ if not self._client:
202
+ self._initialize_client()
203
+
204
+ self._client.change_message_visibility(
205
+ QueueUrl=self._queue_url,
206
+ ReceiptHandle=ack_id,
207
+ VisibilityTimeout=visibility_timeout,
208
+ )
209
+ return True
210
+
211
+ except Exception as e:
212
+ raise QueueError(f"SQS extend failed: {e}")
213
+
214
+ @retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
215
+ def delay(
216
+ self, message: Dict[str, Any], queue_name: str = "default", *, delay_seconds: int = 0
217
+ ) -> str:
218
+ """Real SQS SendMessage DelaySeconds -- SQS itself caps this at
219
+ 900 seconds (15 minutes), enforced here rather than left to an
220
+ opaque API error."""
221
+ if not (0 <= delay_seconds <= 900):
222
+ raise QueueError(
223
+ f"SQS delay_seconds must be between 0 and 900 (SQS's own real ceiling), got {delay_seconds}"
224
+ )
225
+ try:
226
+ if not self._client:
227
+ self._initialize_client()
228
+
229
+ body = (
230
+ json.dumps(message, default=json_safe) if not isinstance(message, str) else message
231
+ )
232
+
233
+ resp = self._client.send_message(
234
+ QueueUrl=self._queue_url,
235
+ MessageBody=body,
236
+ DelaySeconds=delay_seconds,
237
+ )
238
+
239
+ return resp["MessageId"]
240
+
241
+ except Exception as e:
242
+ raise QueueError(f"SQS delay failed: {e}")
@@ -0,0 +1,75 @@
1
+ # src/polydb/adapters/VaultAdapter.py
2
+ #
3
+ # HashiCorp Vault (OSS, self-hosted) -- the cloud-agnostic default secrets
4
+ # backend, alongside the native per-cloud adapters (Azure Key Vault, AWS
5
+ # Secrets Manager, GCP Secret Manager).
6
+ import os
7
+ from typing import Optional
8
+
9
+ from ..base.SecretsAdapter import SecretsAdapter
10
+ from ..errors import ConnectionError
11
+ from ..retry import retry
12
+
13
+
14
+ class VaultAdapter(SecretsAdapter):
15
+ def __init__(
16
+ self,
17
+ url: str = "",
18
+ token: str = "",
19
+ mount_point: str = "secret",
20
+ ):
21
+ super().__init__()
22
+
23
+ self.url = url or os.getenv("VAULT_ADDR")
24
+ self.token = token or os.getenv("VAULT_TOKEN")
25
+ self.mount_point = mount_point
26
+
27
+ if not self.url or not self.token:
28
+ raise ConnectionError("VAULT_ADDR / VAULT_TOKEN are not configured")
29
+
30
+ self._client = None
31
+ self._initialize_client()
32
+
33
+ def _initialize_client(self) -> None:
34
+ import hvac
35
+
36
+ self._client = hvac.Client(url=self.url, token=self.token)
37
+ if not self._client.is_authenticated():
38
+ raise ConnectionError("Vault authentication failed")
39
+
40
+ @retry(max_attempts=3, delay=1.0, exceptions=(ConnectionError, Exception))
41
+ def get_secret(self, key: str) -> Optional[str]:
42
+ try:
43
+ resp = self._client.secrets.kv.v2.read_secret_version(
44
+ path=key, mount_point=self.mount_point, raise_on_deleted_version=True
45
+ )
46
+ except Exception as exc:
47
+ if "InvalidPath" in type(exc).__name__ or "not found" in str(exc).lower():
48
+ return None
49
+ raise
50
+ return resp["data"]["data"].get("value")
51
+
52
+ @retry(max_attempts=3, delay=1.0, exceptions=(ConnectionError, Exception))
53
+ def set_secret(self, key: str, value: str) -> None:
54
+ self._client.secrets.kv.v2.create_or_update_secret(
55
+ path=key, secret={"value": value}, mount_point=self.mount_point
56
+ )
57
+
58
+ @retry(max_attempts=3, delay=1.0, exceptions=(ConnectionError, Exception))
59
+ def delete_secret(self, key: str) -> bool:
60
+ self._client.secrets.kv.v2.delete_metadata_and_all_versions(
61
+ path=key, mount_point=self.mount_point
62
+ )
63
+ return True
64
+
65
+ @retry(max_attempts=3, delay=1.0, exceptions=(ConnectionError, Exception))
66
+ def list_secrets(self, prefix: str = "") -> list[str]:
67
+ try:
68
+ resp = self._client.secrets.kv.v2.list_secrets(
69
+ path=prefix, mount_point=self.mount_point
70
+ )
71
+ except Exception as exc:
72
+ if "InvalidPath" in type(exc).__name__ or "not found" in str(exc).lower():
73
+ return []
74
+ raise
75
+ return resp["data"]["keys"]