python-etcd3-async 0.1.6__py3-none-any.whl → 0.2.0__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.0"
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.
@@ -139,13 +187,19 @@ class AsyncKVOperations:
139
187
  """
140
188
  range_request = self.client._build_get_range_request(key, **kwargs)
141
189
 
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
- )
190
+ async def _range():
191
+ # The stub is created per attempt so that a retry picks up a
192
+ # freshly built channel.
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
+ # Reads are idempotent, so a transient failure can be retried.
202
+ response = await self.client._retry_with_backoff(_range)
149
203
 
150
204
  # Set trace attributes from response
151
205
  endpoint = (
@@ -329,8 +383,18 @@ class AsyncKVOperations:
329
383
  return delete_response.deleted
330
384
 
331
385
  @_async_handle_errors
332
- async def delete_prefix(self, prefix):
333
- """Delete a range of keys with a prefix."""
386
+ async def delete_prefix(self, prefix, return_response=False):
387
+ """
388
+ Delete a range of keys with a prefix.
389
+
390
+ :param prefix: prefix of the keys to delete
391
+ :type prefix: str or bytes
392
+ :param return_response: return the full response
393
+ :type return_response: bool
394
+ :returns: the number of deleted keys when ``return_response`` is
395
+ False, and the full response when ``return_response`` is True
396
+ :rtype: int or :class:`.rpc_pb2.DeleteRangeResponse`
397
+ """
334
398
  delete_request = RequestBuilder.build_delete_request(
335
399
  key=prefix,
336
400
  range_end=utils.prefix_range_end(utils.to_bytes(prefix)),
@@ -345,8 +409,24 @@ class AsyncKVOperations:
345
409
  metadata=self.client.metadata,
346
410
  )
347
411
 
412
+ if return_response:
413
+ return delete_response
348
414
  return delete_response.deleted
349
415
 
416
+ async def transaction(self, compare, success=None, failure=None):
417
+ """Perform a transaction.
418
+
419
+ :param compare: A list of comparisons to make
420
+ :param success: A list of operations to perform if all the comparisons
421
+ are true
422
+ :param failure: A list of operations to perform if any of the
423
+ comparisons are false
424
+ :return: A tuple of (operation status, responses)
425
+ """
426
+ return await self.client.transaction(
427
+ compare=compare, success=success, failure=failure
428
+ )
429
+
350
430
  async def batch_put(self, items, concurrency=10, atomic=True):
351
431
  """Batch put multiple key-value pairs.
352
432
 
@@ -354,8 +434,8 @@ class AsyncKVOperations:
354
434
  ensuring either all operations succeed or none do. If atomic=False,
355
435
  operations are executed in parallel with concurrency control.
356
436
 
357
- :param items: Iterable of (key, value) tuples to put
358
- :type items: Iterable[tuple[str | bytes, str | bytes]]
437
+ :param items: Iterable of (key, value) tuples or a mapping to put
438
+ :type items: Iterable[tuple[str | bytes, str | bytes]] or dict
359
439
  :param concurrency: Maximum number of concurrent put operations (only used if atomic=False)
360
440
  :type concurrency: int
361
441
  :param atomic: If True (default), execute as atomic transaction. If False, parallel execution.
@@ -364,6 +444,9 @@ class AsyncKVOperations:
364
444
  :rtype: int
365
445
  :raises BatchOperationError: If atomic=True and some operations fail
366
446
  """
447
+ # Accept a mapping as well as an iterable of pairs
448
+ if isinstance(items, dict):
449
+ items = list(items.items())
367
450
  # Convert to list to allow multiple iterations
368
451
  items_list = list(items)
369
452
  if not items_list:
@@ -397,23 +480,14 @@ class AsyncKVOperations:
397
480
  ) from exc
398
481
  else:
399
482
  # Non-atomic: parallel execution with concurrency control
400
- semaphore = asyncio.Semaphore(concurrency)
401
- results = []
402
-
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))
483
+ async def put_item(item):
484
+ key, value = item
485
+ return await self.put(key, value)
410
486
 
411
- async with asyncio.TaskGroup() as tg:
412
- for k, v in items_list:
413
- tg.create_task(put_with_semaphore(k, v))
487
+ results = await self._run_bounded(items_list, put_item, concurrency)
414
488
 
415
489
  # Count successful operations
416
- return sum(1 for _, success in results if success == 1)
490
+ return sum(1 for _, _, error in results if error is None)
417
491
 
418
492
  async def batch_delete(self, keys, concurrency=10, atomic=True):
419
493
  """Batch delete multiple keys.
@@ -464,54 +538,54 @@ class AsyncKVOperations:
464
538
  ) from exc
465
539
  else:
466
540
  # 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))
541
+ async def delete_item(key):
542
+ # delete returns the number of deleted keys
543
+ return await self.delete(key)
544
+
545
+ results = await self._run_bounded(keys_list, delete_item, concurrency)
485
546
 
486
547
  # Count successful operations
487
- return sum(success for _, success in results)
548
+ return sum(count for _, count, error in results if error is None)
488
549
 
489
- async def put_many(self, items, timeout=None):
550
+ async def put_many(self, items, timeout=None, concurrency=10):
490
551
  """Put multiple key-value pairs with controlled concurrency.
491
552
 
492
553
  This is a simpler interface for batch puts that handles all
493
- the asyncio complexity internally.
554
+ the asyncio complexity internally. It shares the concurrency and
555
+ failure policy of :meth:`batch_put`: items are processed in
556
+ parallel up to ``concurrency`` at a time, and a failing item never
557
+ aborts the rest of the batch.
494
558
 
495
559
  :param items: Dict of {key: value} pairs or iterable of (key, value) tuples
496
560
  :type items: dict or Iterable[tuple]
497
561
  :param timeout: Optional timeout for each individual put operation
498
562
  :type timeout: float
563
+ :param concurrency: Maximum number of concurrent put operations
564
+ :type concurrency: int
499
565
  :returns: Tuple of (success_count, failed_keys)
500
566
  :rtype: tuple[int, list]
501
567
  """
502
568
  if isinstance(items, dict):
503
569
  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))
570
+ items_list = list(items)
571
+ if not items_list:
572
+ return 0, []
573
+
574
+ async def put_item(item):
575
+ key, value = item
576
+ if timeout is None:
577
+ return await self.put(key, value)
578
+ # asyncio.timeout applies the deadline to the current task, so
579
+ # it composes with an enclosing timeout.
580
+ async with asyncio.timeout(timeout):
581
+ return await self.put(key, value)
582
+
583
+ results = await self._run_bounded(items_list, put_item, concurrency)
584
+
585
+ success_count = sum(1 for _, _, error in results if error is None)
586
+ failed_keys = [
587
+ (item[0], error) for item, _, error in results if error is not None
588
+ ]
515
589
 
516
590
  return success_count, failed_keys
517
591
 
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