python-etcd3-async 0.1.6__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
etcd3/__init__.py CHANGED
@@ -8,6 +8,7 @@ from etcd3.shared import Transactions
8
8
  from etcd3.async_single_client import AsyncEtcd3Client
9
9
  from etcd3.async_multi_client import AsyncMultiEndpointEtcd3Client
10
10
  from etcd3.async_endpoint import AsyncEndpoint
11
+ from etcd3.async_endpoint import AsyncEndpointConfig
11
12
  from etcd3.async_client_extensions import get_request_context, set_request_context
12
13
  from etcd3.exceptions import Etcd3Exception
13
14
  from etcd3.observability import ObservabilityConfig
@@ -50,7 +51,7 @@ from etcd3.async_single_client import async_client
50
51
 
51
52
  __author__ = "owasd"
52
53
  __email__ = ""
53
- __version__ = "0.1.6"
54
+ __version__ = "0.2.1"
54
55
 
55
56
  __all__ = (
56
57
  "etcdrpc",
@@ -71,6 +72,7 @@ __all__ = (
71
72
  "Permission",
72
73
  "MultiEndpointEtcd3Client",
73
74
  "AsyncEndpoint",
75
+ "AsyncEndpointConfig",
74
76
  "AsyncEtcd3Client",
75
77
  "AsyncMultiEndpointEtcd3Client",
76
78
  "async_client",
etcd3/async_client.py CHANGED
@@ -8,6 +8,9 @@ For new code, prefer importing from:
8
8
  - etcd3.async_endpoint: AsyncEndpoint
9
9
  """
10
10
 
11
+ import sys
12
+ import types
13
+
11
14
  # Re-export config classes
12
15
  from etcd3.async_config import AsyncClientConfig, AsyncMultiEndpointConfig
13
16
  from etcd3.config import RangeRequestConfig
@@ -48,3 +51,21 @@ __all__ = [
48
51
  "_async_handle_keepalive_errors",
49
52
  "async_client",
50
53
  ]
54
+
55
+
56
+ class _CallableModule(types.ModuleType):
57
+ """Module that can also be awaited like the factory function it re-exports.
58
+
59
+ ``import etcd3.async_client`` makes Python rebind the
60
+ ``etcd3.async_client`` attribute from the factory function to this
61
+ module. Without this, the documented ``await etcd3.async_client(...)``
62
+ call would break for anyone who imported the compatibility shim.
63
+ Calling the module delegates to the factory function, so both
64
+ spellings keep working.
65
+ """
66
+
67
+ def __call__(self, *args, **kwargs):
68
+ return async_client(*args, **kwargs)
69
+
70
+
71
+ sys.modules[__name__].__class__ = _CallableModule
@@ -117,7 +117,55 @@ class AsyncKVOperations:
117
117
 
118
118
  def __init__(self, client, max_concurrent=100):
119
119
  self.client = client
120
- self._semaphore = asyncio.Semaphore(max_concurrent)
120
+ # Client-wide ceiling for concurrent key-value operations. A batch
121
+ # call may ask for less concurrency, never for more.
122
+ self._max_concurrent = max_concurrent
123
+
124
+ def _limit(self, concurrency):
125
+ """Resolve the concurrency limit for one batch call."""
126
+ if concurrency is None:
127
+ concurrency = self._max_concurrent
128
+ return max(1, min(int(concurrency), self._max_concurrent))
129
+
130
+ async def _run_bounded(self, items, operation, concurrency=None):
131
+ """Run ``operation`` over ``items`` with bounded concurrency.
132
+
133
+ Shared by ``batch_put``, ``batch_delete`` and ``put_many`` so that
134
+ all three have one concurrency policy and one failure policy:
135
+ per-item errors are captured and never abort the whole batch.
136
+
137
+ :param items: Items to process
138
+ :type items: list
139
+ :param operation: Async callable applied to a single item
140
+ :type operation: callable
141
+ :param concurrency: Maximum number of items processed at the same time
142
+ :type concurrency: int, optional
143
+ :returns: List of ``(item, value, error)`` tuples in input order
144
+ :rtype: list[tuple]
145
+ """
146
+ limit = self._limit(concurrency)
147
+ # The semaphore is created per call so it always belongs to the
148
+ # running event loop (asyncio.Semaphore binds itself on first use).
149
+ semaphore = asyncio.Semaphore(limit)
150
+ results = [(item, None, None) for item in items]
151
+
152
+ async def run(index, item):
153
+ async with semaphore:
154
+ try:
155
+ value = await operation(item)
156
+ except Exception as exc: # noqa: BLE001 - per-item isolation
157
+ results[index] = (item, None, exc)
158
+ else:
159
+ results[index] = (item, value, None)
160
+
161
+ # asyncio.TaskGroup keeps every task inside this scope: no task can
162
+ # outlive the call unobserved, and one failing item cannot cancel
163
+ # the others (errors are captured inside ``run``).
164
+ async with asyncio.TaskGroup() as tg:
165
+ for index, item in enumerate(items):
166
+ tg.create_task(run(index, item))
167
+
168
+ return results
121
169
 
122
170
  async def _get_kv_stub(self):
123
171
  """Get the KV stub using the current channel with auto-reconnection.
@@ -128,6 +176,30 @@ class AsyncKVOperations:
128
176
  channel = await self.client._get_channel()
129
177
  return etcdrpc.KVStub(channel)
130
178
 
179
+ async def _range(self, range_request):
180
+ """Run a range request.
181
+
182
+ Reads are idempotent, so a transient failure can be retried. The
183
+ stub is created per attempt so that a retry picks up a freshly
184
+ built channel.
185
+
186
+ :param range_request: the request to send
187
+ :type range_request: etcdrpc.RangeRequest
188
+ :returns: the range response
189
+ :rtype: etcdrpc.RangeResponse
190
+ """
191
+
192
+ async def _do_range():
193
+ kv_stub = await self._get_kv_stub()
194
+ return await kv_stub.Range(
195
+ range_request,
196
+ timeout=self.client.timeout,
197
+ credentials=self.client.call_credentials,
198
+ metadata=self.client.metadata,
199
+ )
200
+
201
+ return await self.client._retry_with_backoff(_do_range)
202
+
131
203
  @_async_handle_errors
132
204
  async def get_response(self, key, **kwargs):
133
205
  """Get the value of a key from etcd.
@@ -138,14 +210,7 @@ class AsyncKVOperations:
138
210
  :returns: RangeResponse from etcd
139
211
  """
140
212
  range_request = self.client._build_get_range_request(key, **kwargs)
141
-
142
- kv_stub = await self._get_kv_stub()
143
- response = await kv_stub.Range(
144
- range_request,
145
- timeout=self.client.timeout,
146
- credentials=self.client.call_credentials,
147
- metadata=self.client.metadata,
148
- )
213
+ response = await self._range(range_request)
149
214
 
150
215
  # Set trace attributes from response
151
216
  endpoint = (
@@ -239,6 +304,9 @@ class AsyncKVOperations:
239
304
  :type ignore_value: bool
240
305
  :param ignore_lease: if True, update the key using the current lease
241
306
  :type ignore_lease: bool
307
+ :returns: the response header, or the previous key-value metadata
308
+ when ``prev_kv`` is True
309
+ :rtype: :class:`.rpc_pb2.ResponseHeader` or :class:`.KVMetadata`
242
310
  """
243
311
  put_request = RequestBuilder.build_put_request(
244
312
  key=key,
@@ -329,8 +397,18 @@ class AsyncKVOperations:
329
397
  return delete_response.deleted
330
398
 
331
399
  @_async_handle_errors
332
- async def delete_prefix(self, prefix):
333
- """Delete a range of keys with a prefix."""
400
+ async def delete_prefix(self, prefix, return_response=False):
401
+ """
402
+ Delete a range of keys with a prefix.
403
+
404
+ :param prefix: prefix of the keys to delete
405
+ :type prefix: str or bytes
406
+ :param return_response: return the full response
407
+ :type return_response: bool
408
+ :returns: the number of deleted keys when ``return_response`` is
409
+ False, and the full response when ``return_response`` is True
410
+ :rtype: int or :class:`.rpc_pb2.DeleteRangeResponse`
411
+ """
334
412
  delete_request = RequestBuilder.build_delete_request(
335
413
  key=prefix,
336
414
  range_end=utils.prefix_range_end(utils.to_bytes(prefix)),
@@ -345,8 +423,24 @@ class AsyncKVOperations:
345
423
  metadata=self.client.metadata,
346
424
  )
347
425
 
426
+ if return_response:
427
+ return delete_response
348
428
  return delete_response.deleted
349
429
 
430
+ async def transaction(self, compare, success=None, failure=None):
431
+ """Perform a transaction.
432
+
433
+ :param compare: A list of comparisons to make
434
+ :param success: A list of operations to perform if all the comparisons
435
+ are true
436
+ :param failure: A list of operations to perform if any of the
437
+ comparisons are false
438
+ :return: A tuple of (operation status, responses)
439
+ """
440
+ return await self.client.transaction(
441
+ compare=compare, success=success, failure=failure
442
+ )
443
+
350
444
  async def batch_put(self, items, concurrency=10, atomic=True):
351
445
  """Batch put multiple key-value pairs.
352
446
 
@@ -354,8 +448,8 @@ class AsyncKVOperations:
354
448
  ensuring either all operations succeed or none do. If atomic=False,
355
449
  operations are executed in parallel with concurrency control.
356
450
 
357
- :param items: Iterable of (key, value) tuples to put
358
- :type items: Iterable[tuple[str | bytes, str | bytes]]
451
+ :param items: Iterable of (key, value) tuples or a mapping to put
452
+ :type items: Iterable[tuple[str | bytes, str | bytes]] or dict
359
453
  :param concurrency: Maximum number of concurrent put operations (only used if atomic=False)
360
454
  :type concurrency: int
361
455
  :param atomic: If True (default), execute as atomic transaction. If False, parallel execution.
@@ -364,6 +458,9 @@ class AsyncKVOperations:
364
458
  :rtype: int
365
459
  :raises BatchOperationError: If atomic=True and some operations fail
366
460
  """
461
+ # Accept a mapping as well as an iterable of pairs
462
+ if isinstance(items, dict):
463
+ items = list(items.items())
367
464
  # Convert to list to allow multiple iterations
368
465
  items_list = list(items)
369
466
  if not items_list:
@@ -397,23 +494,14 @@ class AsyncKVOperations:
397
494
  ) from exc
398
495
  else:
399
496
  # Non-atomic: parallel execution with concurrency control
400
- semaphore = asyncio.Semaphore(concurrency)
401
- results = []
497
+ async def put_item(item):
498
+ key, value = item
499
+ return await self.put(key, value)
402
500
 
403
- async def put_with_semaphore(key, value):
404
- async with semaphore:
405
- try:
406
- await self.put(key, value)
407
- results.append((key, 1))
408
- except Exception:
409
- results.append((key, 0))
410
-
411
- async with asyncio.TaskGroup() as tg:
412
- for k, v in items_list:
413
- tg.create_task(put_with_semaphore(k, v))
501
+ results = await self._run_bounded(items_list, put_item, concurrency)
414
502
 
415
503
  # Count successful operations
416
- return sum(1 for _, success in results if success == 1)
504
+ return sum(1 for _, _, error in results if error is None)
417
505
 
418
506
  async def batch_delete(self, keys, concurrency=10, atomic=True):
419
507
  """Batch delete multiple keys.
@@ -464,54 +552,54 @@ class AsyncKVOperations:
464
552
  ) from exc
465
553
  else:
466
554
  # Non-atomic: parallel execution with concurrency control
467
- semaphore = asyncio.Semaphore(concurrency)
468
- results = []
469
-
470
- async def delete_with_semaphore(key):
471
- async with semaphore:
472
- try:
473
- result = await self.delete(key)
474
- # delete returns True/False or deleted count
475
- if isinstance(result, bool):
476
- results.append((key, 1 if result else 0))
477
- else:
478
- results.append((key, 1 if result > 0 else 0))
479
- except Exception:
480
- results.append((key, 0))
481
-
482
- async with asyncio.TaskGroup() as tg:
483
- for k in keys_list:
484
- tg.create_task(delete_with_semaphore(k))
555
+ async def delete_item(key):
556
+ # delete returns the number of deleted keys
557
+ return await self.delete(key)
558
+
559
+ results = await self._run_bounded(keys_list, delete_item, concurrency)
485
560
 
486
561
  # Count successful operations
487
- return sum(success for _, success in results)
562
+ return sum(count for _, count, error in results if error is None)
488
563
 
489
- async def put_many(self, items, timeout=None):
564
+ async def put_many(self, items, timeout=None, concurrency=10):
490
565
  """Put multiple key-value pairs with controlled concurrency.
491
566
 
492
567
  This is a simpler interface for batch puts that handles all
493
- the asyncio complexity internally.
568
+ the asyncio complexity internally. It shares the concurrency and
569
+ failure policy of :meth:`batch_put`: items are processed in
570
+ parallel up to ``concurrency`` at a time, and a failing item never
571
+ aborts the rest of the batch.
494
572
 
495
573
  :param items: Dict of {key: value} pairs or iterable of (key, value) tuples
496
574
  :type items: dict or Iterable[tuple]
497
575
  :param timeout: Optional timeout for each individual put operation
498
576
  :type timeout: float
577
+ :param concurrency: Maximum number of concurrent put operations
578
+ :type concurrency: int
499
579
  :returns: Tuple of (success_count, failed_keys)
500
580
  :rtype: tuple[int, list]
501
581
  """
502
582
  if isinstance(items, dict):
503
583
  items = list(items.items())
504
-
505
- success_count = 0
506
- failed_keys = []
507
-
508
- for key, value in items:
509
- try:
510
- async with self._semaphore:
511
- await asyncio.wait_for(self.put(key, value), timeout=timeout)
512
- success_count += 1
513
- except Exception as exc:
514
- failed_keys.append((key, exc))
584
+ items_list = list(items)
585
+ if not items_list:
586
+ return 0, []
587
+
588
+ async def put_item(item):
589
+ key, value = item
590
+ if timeout is None:
591
+ return await self.put(key, value)
592
+ # asyncio.timeout applies the deadline to the current task, so
593
+ # it composes with an enclosing timeout.
594
+ async with asyncio.timeout(timeout):
595
+ return await self.put(key, value)
596
+
597
+ results = await self._run_bounded(items_list, put_item, concurrency)
598
+
599
+ success_count = sum(1 for _, _, error in results if error is None)
600
+ failed_keys = [
601
+ (item[0], error) for item, _, error in results if error is not None
602
+ ]
515
603
 
516
604
  return success_count, failed_keys
517
605
 
etcd3/async_endpoint.py CHANGED
@@ -41,6 +41,12 @@ class AsyncEndpointConfig:
41
41
  class AsyncEndpoint(BaseEndpoint):
42
42
  """Represents an etcd cluster endpoint for async operations.
43
43
 
44
+ The signature mirrors the synchronous ``Endpoint`` class: ``host`` and
45
+ ``port`` come first, so both ``AsyncEndpoint('localhost', 2379)`` and
46
+ ``AsyncEndpoint(host='localhost', port=2379)`` work. An
47
+ ``AsyncEndpointConfig`` can be passed as the keyword-only ``config``
48
+ argument instead.
49
+
44
50
  :param config: AsyncEndpointConfig object with endpoint configuration
45
51
  :param str host: Endpoint host (ignored if config is provided)
46
52
  :param int port: Endpoint port (ignored if config is provided)
@@ -57,13 +63,14 @@ class AsyncEndpoint(BaseEndpoint):
57
63
 
58
64
  def __init__(
59
65
  self,
60
- config=None,
61
66
  host=None,
62
67
  port=None,
63
68
  secure=True,
64
69
  creds=None,
65
70
  time_retry=300.0,
66
71
  opts=None,
72
+ *,
73
+ config=None,
67
74
  ):
68
75
  # Use config object if provided, otherwise use individual parameters
69
76
  if config is not None:
@@ -96,17 +103,24 @@ class AsyncEndpoint(BaseEndpoint):
96
103
  except Exception:
97
104
  return False
98
105
 
99
- async def health_check_async(self):
106
+ async def health_check_async(self, timeout=2.0):
100
107
  """Perform an asynchronous health check using gRPC status check.
101
108
 
102
109
  This method uses the channel's channel_ready() for a more robust
103
110
  connectivity check.
104
111
 
112
+ :param timeout: Maximum time to wait for the channel to become ready
113
+ :type timeout: float, optional
105
114
  :returns: True if the endpoint is healthy
106
115
  :rtype: bool
107
116
  """
108
117
  try:
109
- await asyncio.wait_for(self.channel.channel_ready(), timeout=2.0)
118
+ # asyncio.timeout (3.11+) is used instead of asyncio.wait_for:
119
+ # it applies a deadline to the current task, so it composes
120
+ # correctly with an enclosing timeout instead of cancelling a
121
+ # nested task.
122
+ async with asyncio.timeout(timeout):
123
+ await self.channel.channel_ready()
110
124
  return True
111
125
  except asyncio.TimeoutError:
112
126
  return False
etcd3/async_managers.py CHANGED
@@ -39,6 +39,12 @@ class AsyncWatchManager:
39
39
  )
40
40
  return self._watcher
41
41
 
42
+ async def close(self):
43
+ """Close the watcher and release its background task."""
44
+ if self._watcher is not None:
45
+ await self._watcher.close()
46
+ self._watcher = None
47
+
42
48
  async def add_watch_callback(self, *args, **kwargs):
43
49
  """
44
50
  Watch a key or range of keys and call a callback on every response.
@@ -90,23 +96,42 @@ class AsyncWatchManager:
90
96
  Use ``cancel`` to cancel the watch request.
91
97
  """
92
98
  watcher = await self._get_watcher()
93
- event_iter = watcher.watch(key, **kwargs)
99
+
100
+ # The watch runs on the shared watcher (one background task and one
101
+ # gRPC stream for the whole client), so the real watch id is known
102
+ # and the watch can actually be cancelled server-side.
103
+ response_queue = asyncio.Queue()
104
+
105
+ async def callback(response):
106
+ await response_queue.put(response)
107
+
108
+ watch_id = await watcher.add_callback(key, callback, **kwargs)
94
109
 
95
110
  cancel_event = asyncio.Event()
96
111
 
97
112
  async def cancel():
113
+ if cancel_event.is_set():
114
+ return
98
115
  cancel_event.set()
99
- await watcher.cancel(0)
116
+ # Wake up a pending iteration so it can finish.
117
+ await response_queue.put(None)
118
+ await watcher.cancel(watch_id)
100
119
 
101
120
  async def event_iterator():
102
121
  try:
103
- async for event in event_iter:
104
- if cancel_event.is_set():
122
+ while not cancel_event.is_set():
123
+ response = await response_queue.get()
124
+ if response is None:
105
125
  break
106
- yield event
126
+ if isinstance(response, Exception):
127
+ cancel_event.set()
128
+ raise response
129
+ yield response
107
130
  except grpc.RpcError as exc:
108
131
  if not cancel_event.is_set():
109
132
  self.etcd_client._manage_grpc_errors(exc)
133
+ finally:
134
+ await cancel()
110
135
 
111
136
  return event_iterator(), cancel
112
137