playground-ls-cli 4.14.1.dev8__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.
Files changed (112) hide show
  1. localstack_cli/__init__.py +0 -0
  2. localstack_cli/cli/__init__.py +10 -0
  3. localstack_cli/cli/console.py +11 -0
  4. localstack_cli/cli/core_plugin.py +12 -0
  5. localstack_cli/cli/exceptions.py +19 -0
  6. localstack_cli/cli/localstack.py +951 -0
  7. localstack_cli/cli/lpm.py +138 -0
  8. localstack_cli/cli/main.py +22 -0
  9. localstack_cli/cli/plugin.py +39 -0
  10. localstack_cli/cli/plugins.py +134 -0
  11. localstack_cli/cli/profiles.py +65 -0
  12. localstack_cli/config.py +1689 -0
  13. localstack_cli/constants.py +165 -0
  14. localstack_cli/logging/__init__.py +0 -0
  15. localstack_cli/logging/format.py +194 -0
  16. localstack_cli/logging/setup.py +142 -0
  17. localstack_cli/packages/__init__.py +25 -0
  18. localstack_cli/packages/api.py +418 -0
  19. localstack_cli/packages/core.py +416 -0
  20. localstack_cli/pro/__init__.py +0 -0
  21. localstack_cli/pro/core/__init__.py +0 -0
  22. localstack_cli/pro/core/bootstrap/__init__.py +1 -0
  23. localstack_cli/pro/core/bootstrap/auth.py +213 -0
  24. localstack_cli/pro/core/bootstrap/dns_utils.py +55 -0
  25. localstack_cli/pro/core/bootstrap/entitlements.py +117 -0
  26. localstack_cli/pro/core/bootstrap/extensions/__init__.py +3 -0
  27. localstack_cli/pro/core/bootstrap/extensions/__main__.py +106 -0
  28. localstack_cli/pro/core/bootstrap/extensions/autoinstall.py +63 -0
  29. localstack_cli/pro/core/bootstrap/extensions/bootstrap.py +97 -0
  30. localstack_cli/pro/core/bootstrap/extensions/repository.py +374 -0
  31. localstack_cli/pro/core/bootstrap/licensingv2.py +1259 -0
  32. localstack_cli/pro/core/bootstrap/pods/__init__.py +0 -0
  33. localstack_cli/pro/core/bootstrap/pods/api_types.py +17 -0
  34. localstack_cli/pro/core/bootstrap/pods/constants.py +26 -0
  35. localstack_cli/pro/core/bootstrap/pods/remotes/__init__.py +0 -0
  36. localstack_cli/pro/core/bootstrap/pods/remotes/api.py +75 -0
  37. localstack_cli/pro/core/bootstrap/pods/remotes/configs.py +69 -0
  38. localstack_cli/pro/core/bootstrap/pods/remotes/params.py +86 -0
  39. localstack_cli/pro/core/bootstrap/pods_client.py +834 -0
  40. localstack_cli/pro/core/cli/__init__.py +0 -0
  41. localstack_cli/pro/core/cli/auth.py +226 -0
  42. localstack_cli/pro/core/cli/aws.py +16 -0
  43. localstack_cli/pro/core/cli/cli.py +99 -0
  44. localstack_cli/pro/core/cli/click_utils.py +21 -0
  45. localstack_cli/pro/core/cli/cloud_pods.py +465 -0
  46. localstack_cli/pro/core/cli/diff_view.py +41 -0
  47. localstack_cli/pro/core/cli/ephemeral.py +199 -0
  48. localstack_cli/pro/core/cli/extensions.py +492 -0
  49. localstack_cli/pro/core/cli/iam.py +180 -0
  50. localstack_cli/pro/core/cli/license.py +90 -0
  51. localstack_cli/pro/core/cli/localstack.py +118 -0
  52. localstack_cli/pro/core/cli/replicator.py +378 -0
  53. localstack_cli/pro/core/cli/state.py +183 -0
  54. localstack_cli/pro/core/cli/tree_view.py +235 -0
  55. localstack_cli/pro/core/config.py +556 -0
  56. localstack_cli/pro/core/constants.py +54 -0
  57. localstack_cli/pro/core/plugins.py +169 -0
  58. localstack_cli/runtime/__init__.py +6 -0
  59. localstack_cli/runtime/exceptions.py +7 -0
  60. localstack_cli/runtime/hooks.py +73 -0
  61. localstack_cli/testing/__init__.py +1 -0
  62. localstack_cli/testing/config.py +4 -0
  63. localstack_cli/utils/__init__.py +0 -0
  64. localstack_cli/utils/analytics/__init__.py +12 -0
  65. localstack_cli/utils/analytics/cli.py +67 -0
  66. localstack_cli/utils/analytics/client.py +111 -0
  67. localstack_cli/utils/analytics/events.py +30 -0
  68. localstack_cli/utils/analytics/logger.py +48 -0
  69. localstack_cli/utils/analytics/metadata.py +250 -0
  70. localstack_cli/utils/analytics/publisher.py +160 -0
  71. localstack_cli/utils/analytics/service_request_aggregator.py +133 -0
  72. localstack_cli/utils/archives.py +271 -0
  73. localstack_cli/utils/batching.py +258 -0
  74. localstack_cli/utils/bootstrap.py +1418 -0
  75. localstack_cli/utils/checksum.py +313 -0
  76. localstack_cli/utils/collections.py +554 -0
  77. localstack_cli/utils/common.py +229 -0
  78. localstack_cli/utils/container_networking.py +142 -0
  79. localstack_cli/utils/container_utils/__init__.py +0 -0
  80. localstack_cli/utils/container_utils/container_client.py +1585 -0
  81. localstack_cli/utils/container_utils/docker_cmd_client.py +987 -0
  82. localstack_cli/utils/container_utils/docker_sdk_client.py +1018 -0
  83. localstack_cli/utils/crypto.py +294 -0
  84. localstack_cli/utils/docker_utils.py +272 -0
  85. localstack_cli/utils/files.py +327 -0
  86. localstack_cli/utils/functions.py +92 -0
  87. localstack_cli/utils/http.py +326 -0
  88. localstack_cli/utils/json.py +219 -0
  89. localstack_cli/utils/net.py +516 -0
  90. localstack_cli/utils/no_exit_argument_parser.py +19 -0
  91. localstack_cli/utils/numbers.py +49 -0
  92. localstack_cli/utils/objects.py +235 -0
  93. localstack_cli/utils/patch.py +260 -0
  94. localstack_cli/utils/platform.py +77 -0
  95. localstack_cli/utils/run.py +514 -0
  96. localstack_cli/utils/server/__init__.py +0 -0
  97. localstack_cli/utils/server/tcp_proxy.py +108 -0
  98. localstack_cli/utils/serving.py +187 -0
  99. localstack_cli/utils/ssl.py +71 -0
  100. localstack_cli/utils/strings.py +245 -0
  101. localstack_cli/utils/sync.py +267 -0
  102. localstack_cli/utils/threads.py +163 -0
  103. localstack_cli/utils/time.py +81 -0
  104. localstack_cli/utils/urls.py +21 -0
  105. localstack_cli/utils/venv.py +100 -0
  106. localstack_cli/utils/xml.py +41 -0
  107. localstack_cli/version.py +34 -0
  108. playground_ls_cli-4.14.1.dev8.dist-info/METADATA +95 -0
  109. playground_ls_cli-4.14.1.dev8.dist-info/RECORD +112 -0
  110. playground_ls_cli-4.14.1.dev8.dist-info/WHEEL +5 -0
  111. playground_ls_cli-4.14.1.dev8.dist-info/entry_points.txt +17 -0
  112. playground_ls_cli-4.14.1.dev8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,271 @@
1
+ import glob
2
+ import io
3
+ import logging
4
+ import os
5
+ import re
6
+ import tarfile
7
+ import tempfile
8
+ import time
9
+ import zipfile
10
+ from subprocess import Popen
11
+ from typing import IO, Literal
12
+
13
+ from localstack_cli.constants import MAVEN_REPO_URL
14
+ from localstack_cli.utils.files import load_file, mkdir, new_tmp_file, rm_rf, save_file
15
+ from localstack_cli.utils.http import download
16
+ from localstack_cli.utils.run import run
17
+
18
+ from .checksum import verify_local_file_with_checksum_url
19
+ from .run import is_command_available
20
+ from .strings import truncate
21
+
22
+ LOG = logging.getLogger(__name__)
23
+
24
+
25
+ StrPath = str | os.PathLike
26
+
27
+
28
+ def is_zip_file(content):
29
+ stream = io.BytesIO(content)
30
+ return zipfile.is_zipfile(stream)
31
+
32
+
33
+ def get_unzipped_size(zip_file: str | IO[bytes]):
34
+ """Returns the size of the unzipped file."""
35
+ with zipfile.ZipFile(zip_file, "r") as zip_ref:
36
+ return sum(f.file_size for f in zip_ref.infolist())
37
+
38
+
39
+ def unzip(path: str, target_dir: str, overwrite: bool = True) -> str | Popen | None:
40
+ from localstack_cli.utils.platform import is_debian
41
+
42
+ use_native_cmd = is_debian() or is_command_available("unzip")
43
+ if use_native_cmd:
44
+ # Running the native command can be an order of magnitude faster in the container. Also, `unzip`
45
+ # is capable of extracting zip files with incorrect CRC codes (sometimes happens, e.g., with some
46
+ # Node.js/Serverless versions), which can fail with Python's `zipfile` (extracting empty files).
47
+ flags = ["-o"] if overwrite else []
48
+ flags += ["-q"]
49
+ try:
50
+ cmd = ["unzip"] + flags + [path]
51
+ return run(cmd, cwd=target_dir, print_error=False)
52
+ except Exception as e:
53
+ error_str = truncate(str(e), max_length=200)
54
+ LOG.info(
55
+ 'Unable to use native "unzip" command (using fallback mechanism): %s', error_str
56
+ )
57
+
58
+ try:
59
+ zip_ref = zipfile.ZipFile(path, "r")
60
+ except Exception as e:
61
+ LOG.warning("Unable to open zip file: %s: %s", path, e)
62
+ raise e
63
+
64
+ def _unzip_file_entry(zip_ref, file_entry, target_dir):
65
+ """Extracts a Zipfile entry and preserves permissions"""
66
+ out_path = os.path.join(target_dir, file_entry.filename)
67
+ if use_native_cmd and os.path.exists(out_path) and os.path.getsize(out_path) > 0:
68
+ # this can happen under certain circumstances if the native "unzip" command
69
+ # fails with a non-zero exit code, yet manages to extract parts of the zip file
70
+ return
71
+ zip_ref.extract(file_entry.filename, path=target_dir)
72
+ perm = file_entry.external_attr >> 16
73
+ # Make sure to preserve file permissions in the zip file
74
+ # https://www.burgundywall.com/post/preserving-file-perms-with-python-zipfile-module
75
+ os.chmod(out_path, perm or 0o777)
76
+
77
+ try:
78
+ for file_entry in zip_ref.infolist():
79
+ _unzip_file_entry(zip_ref, file_entry, target_dir)
80
+ finally:
81
+ zip_ref.close()
82
+
83
+
84
+ def untar(path: str, target_dir: str):
85
+ mode = "r:gz" if path.endswith("gz") else "r"
86
+ with tarfile.open(path, mode) as tar:
87
+ tar.extractall(path=target_dir)
88
+
89
+
90
+ def create_zip_file_cli(source_path: StrPath, base_dir: StrPath, zip_file: StrPath):
91
+ """
92
+ Creates a zip archive by using the native zip command. The native command can be an order of magnitude faster in CI
93
+ """
94
+ source = "." if source_path == base_dir else os.path.basename(source_path)
95
+ run(["zip", "-r", zip_file, source], cwd=base_dir)
96
+
97
+
98
+ def create_zip_file_python(
99
+ base_dir: StrPath,
100
+ zip_file: StrPath,
101
+ mode: Literal["r", "w", "x", "a"] = "w",
102
+ content_root: str | None = None,
103
+ ):
104
+ with zipfile.ZipFile(zip_file, mode) as zip_file:
105
+ for root, dirs, files in os.walk(base_dir):
106
+ for name in files:
107
+ full_name = os.path.join(root, name)
108
+ relative = os.path.relpath(root, start=base_dir)
109
+ if content_root:
110
+ dest = os.path.join(content_root, relative, name)
111
+ else:
112
+ dest = os.path.join(relative, name)
113
+ zip_file.write(full_name, dest)
114
+
115
+
116
+ def add_file_to_jar(class_file, class_url, target_jar, base_dir=None):
117
+ base_dir = base_dir or os.path.dirname(target_jar)
118
+ patch_class_file = os.path.join(base_dir, class_file)
119
+ if not os.path.exists(patch_class_file):
120
+ download(class_url, patch_class_file)
121
+ run(["zip", target_jar, class_file], cwd=base_dir)
122
+
123
+
124
+ def update_jar_manifest(
125
+ jar_file_name: str, parent_dir: str, search: str | re.Pattern, replace: str
126
+ ):
127
+ manifest_file_path = "META-INF/MANIFEST.MF"
128
+ jar_path = os.path.join(parent_dir, jar_file_name)
129
+ with tempfile.TemporaryDirectory() as tmp_dir:
130
+ tmp_manifest_file = os.path.join(tmp_dir, manifest_file_path)
131
+ run(["unzip", "-o", jar_path, manifest_file_path], cwd=tmp_dir)
132
+ manifest = load_file(tmp_manifest_file)
133
+
134
+ # return if the search pattern does not match (for idempotence, to avoid file permission issues further below)
135
+ if isinstance(search, re.Pattern):
136
+ if not search.search(manifest):
137
+ return
138
+ manifest = search.sub(replace, manifest, 1)
139
+ else:
140
+ if search not in manifest:
141
+ return
142
+ manifest = manifest.replace(search, replace, 1)
143
+
144
+ manifest_file = os.path.join(parent_dir, manifest_file_path)
145
+ save_file(manifest_file, manifest)
146
+ run(["zip", jar_file_name, manifest_file_path], cwd=parent_dir)
147
+
148
+
149
+ def upgrade_jar_file(base_dir: str, file_glob: str, maven_asset: str):
150
+ """
151
+ Upgrade the matching Java JAR file in a local directory with the given Maven asset
152
+ :param base_dir: base directory to search the JAR file to replace in
153
+ :param file_glob: glob pattern for the JAR file to replace
154
+ :param maven_asset: name of Maven asset to download, in the form "<qualified_name>:<version>"
155
+ """
156
+
157
+ local_path = os.path.join(base_dir, file_glob)
158
+ parent_dir = os.path.dirname(local_path)
159
+ maven_asset = maven_asset.replace(":", "/")
160
+ parts = maven_asset.split("/")
161
+ maven_asset_url = f"{MAVEN_REPO_URL}/{maven_asset}/{parts[-2]}-{parts[-1]}.jar"
162
+ target_file = os.path.join(parent_dir, os.path.basename(maven_asset_url))
163
+ if os.path.exists(target_file):
164
+ # avoid re-downloading the newer JAR version if it already exists locally
165
+ return
166
+ matches = glob.glob(local_path)
167
+ if not matches:
168
+ return
169
+ for match in matches:
170
+ os.remove(match)
171
+ download(maven_asset_url, target_file)
172
+
173
+
174
+ def download_and_extract(
175
+ archive_url: str,
176
+ target_dir: str,
177
+ retries: int | None = 0,
178
+ sleep: int | None = 3,
179
+ tmp_archive: str | None = None,
180
+ checksum_url: str | None = None,
181
+ ) -> None:
182
+ """
183
+ Download and extract an archive to a target directory with optional checksum verification.
184
+
185
+ Checksum verification is only performed if a `checksum_url` is provided.
186
+ Else, the archive is downloaded and extracted without verification.
187
+
188
+ :param archive_url: URL of the archive to download
189
+ :param target_dir: Directory to extract the archive contents to
190
+ :param retries: Number of download retries (default: 0)
191
+ :param sleep: Sleep time between retries in seconds (default: 3)
192
+ :param tmp_archive: Optional path for the temporary archive file
193
+ :param checksum_url: Optional URL of the checksum file for verification
194
+ :return: None
195
+ """
196
+ mkdir(target_dir)
197
+
198
+ _, ext = os.path.splitext(tmp_archive or archive_url)
199
+ tmp_archive = tmp_archive or new_tmp_file()
200
+ if not os.path.exists(tmp_archive) or os.path.getsize(tmp_archive) <= 0:
201
+ # create temporary placeholder file, to avoid duplicate parallel downloads
202
+ save_file(tmp_archive, "")
203
+
204
+ for i in range(retries + 1):
205
+ try:
206
+ download(archive_url, tmp_archive)
207
+ break
208
+ except Exception as e:
209
+ LOG.warning(
210
+ "Attempt %d. Failed to download archive from %s: %s",
211
+ i + 1,
212
+ archive_url,
213
+ e,
214
+ )
215
+ # only sleep between retries, not after the last one
216
+ if i < retries:
217
+ time.sleep(sleep)
218
+
219
+ # if the temporary file we created above hasn't been replaced, we assume failure
220
+ if os.path.getsize(tmp_archive) <= 0:
221
+ raise Exception("Failed to download archive from %s: . Retries exhausted", archive_url)
222
+
223
+ # Verify checksum if provided
224
+ if checksum_url:
225
+ LOG.info("Verifying archive integrity...")
226
+ try:
227
+ verify_local_file_with_checksum_url(
228
+ file_path=tmp_archive,
229
+ checksum_url=checksum_url,
230
+ )
231
+ except Exception as e:
232
+ # clean up the corrupted download
233
+ rm_rf(tmp_archive)
234
+ raise e
235
+
236
+ if ext in (".zip", ".whl"):
237
+ unzip(tmp_archive, target_dir)
238
+ elif ext in (
239
+ ".bz2",
240
+ ".gz",
241
+ ".tgz",
242
+ ".xz",
243
+ ):
244
+ untar(tmp_archive, target_dir)
245
+ else:
246
+ raise Exception(f"Unsupported archive format: {ext}")
247
+
248
+
249
+ def download_and_extract_with_retry(
250
+ archive_url,
251
+ tmp_archive,
252
+ target_dir,
253
+ checksum_url: str | None = None,
254
+ ):
255
+ try:
256
+ download_and_extract(
257
+ archive_url,
258
+ target_dir,
259
+ tmp_archive=tmp_archive,
260
+ checksum_url=checksum_url,
261
+ )
262
+ except Exception as e:
263
+ # try deleting and re-downloading the zip file
264
+ LOG.info("Unable to extract file, re-downloading ZIP archive %s: %s", tmp_archive, e)
265
+ rm_rf(tmp_archive)
266
+ download_and_extract(
267
+ archive_url,
268
+ target_dir,
269
+ tmp_archive=tmp_archive,
270
+ checksum_url=checksum_url,
271
+ )
@@ -0,0 +1,258 @@
1
+ import copy
2
+ import logging
3
+ import threading
4
+ import time
5
+ from typing import Generic, Protocol, TypeVar, overload
6
+
7
+ LOG = logging.getLogger(__name__)
8
+
9
+ T = TypeVar("T")
10
+
11
+ # alias to signify whether a batch policy has been triggered
12
+ BatchPolicyTriggered = bool
13
+
14
+
15
+ # TODO: Add batching on bytes as well.
16
+ class Batcher(Generic[T]):
17
+ """
18
+ A utility for collecting items into batches and flushing them when one or more batch policy conditions are met.
19
+
20
+ The batch policy can be created to trigger on:
21
+ - max_count: Maximum number of items added
22
+ - max_window: Maximum time window (in seconds)
23
+
24
+ If no limits are specified, the batcher is always in triggered state.
25
+
26
+ Example usage:
27
+
28
+ import time
29
+
30
+ # Triggers when 2 (or more) items are added
31
+ batcher = Batcher(max_count=2)
32
+ assert batcher.add(["item1", "item2", "item3"])
33
+ assert batcher.flush() == ["item1", "item2", "item3"]
34
+
35
+ # Triggers partially when 2 (or more) items are added
36
+ batcher = Batcher(max_count=2)
37
+ assert batcher.add(["item1", "item2", "item3"])
38
+ assert batcher.flush(partial=True) == ["item1", "item2"]
39
+ assert batcher.add("item4")
40
+ assert batcher.flush(partial=True) == ["item3", "item4"]
41
+
42
+ # Trigger 2 seconds after the first add
43
+ batcher = Batcher(max_window=2.0)
44
+ assert not batcher.add(["item1", "item2", "item3"])
45
+ time.sleep(2.1)
46
+ assert not batcher.add(["item4"])
47
+ assert batcher.flush() == ["item1", "item2", "item3", "item4"]
48
+ """
49
+
50
+ max_count: int | None
51
+ """
52
+ Maximum number of items, must be None or positive.
53
+ """
54
+
55
+ max_window: float | None
56
+ """
57
+ Maximum time window in seconds, must be None or positive.
58
+ """
59
+
60
+ _triggered: bool
61
+ _last_batch_time: float
62
+ _batch: list[T]
63
+
64
+ def __init__(self, max_count: int | None = None, max_window: float | None = None):
65
+ """
66
+ Initialize a new Batcher instance.
67
+
68
+ :param max_count: Maximum number of items that be None or positive.
69
+ :param max_window: Maximum time window in seconds that must be None or positive.
70
+ """
71
+ self.max_count = max_count
72
+ self.max_window = max_window
73
+
74
+ self._triggered = False
75
+ self._last_batch_time = time.monotonic()
76
+ self._batch = []
77
+
78
+ @property
79
+ def period(self) -> float:
80
+ return time.monotonic() - self._last_batch_time
81
+
82
+ def _check_batch_policy(self) -> bool:
83
+ """Check if any batch policy conditions are met"""
84
+ if self.max_count is not None and len(self._batch) >= self.max_count:
85
+ self._triggered = True
86
+ elif self.max_window is not None and self.period >= self.max_window:
87
+ self._triggered = True
88
+ elif not self.max_count and not self.max_window:
89
+ # always return true
90
+ self._triggered = True
91
+
92
+ return self._triggered
93
+
94
+ @overload
95
+ def add(self, item: T, *, deep_copy: bool = False) -> BatchPolicyTriggered: ...
96
+
97
+ @overload
98
+ def add(self, items: list[T], *, deep_copy: bool = False) -> BatchPolicyTriggered: ...
99
+
100
+ def add(self, item_or_items: T | list[T], *, deep_copy: bool = False) -> BatchPolicyTriggered:
101
+ """
102
+ Add an item or list of items to the collected batch.
103
+
104
+ Returns:
105
+ BatchPolicyTriggered: True if the batch policy was triggered during addition, False otherwise.
106
+ """
107
+ if deep_copy:
108
+ item_or_items = copy.deepcopy(item_or_items)
109
+
110
+ if isinstance(item_or_items, list):
111
+ self._batch.extend(item_or_items)
112
+ else:
113
+ self._batch.append(item_or_items)
114
+
115
+ # Check if the last addition triggered the batch policy
116
+ return self.is_triggered()
117
+
118
+ def flush(self, *, partial=False) -> list[T]:
119
+ result = []
120
+ if not partial or not self.max_count:
121
+ result = self._batch.copy()
122
+ self._batch.clear()
123
+ else:
124
+ batch_size = min(self.max_count, len(self._batch))
125
+ result = self._batch[:batch_size].copy()
126
+ self._batch = self._batch[batch_size:]
127
+
128
+ self._last_batch_time = time.monotonic()
129
+ self._triggered = False
130
+ self._check_batch_policy()
131
+
132
+ return result
133
+
134
+ def duration_until_next_batch(self) -> float:
135
+ if not self.max_window:
136
+ return -1
137
+ return max(self.max_window - self.period, -1)
138
+
139
+ def get_current_size(self) -> int:
140
+ return len(self._batch)
141
+
142
+ def is_triggered(self):
143
+ return self._triggered or self._check_batch_policy()
144
+
145
+
146
+ class BatchHandler(Protocol[T]):
147
+ """
148
+ A BatchHandler is a callable that processes a list of items handed down by the AsyncBatcher.
149
+ """
150
+
151
+ def __call__(self, batch: list[T]) -> None: ...
152
+
153
+
154
+ class AsyncBatcher(Generic[T]):
155
+ """
156
+ Class for managing asynchronous batching of items.
157
+
158
+ This class allows for efficient buffering and processing of items in batches by
159
+ periodically flushing the buffer to a given handler, or by automatically flushing
160
+ when the maximum batch size is reached. It is designed to be used in asynchronous
161
+ scenarios where the caller does not execute the flushing IO call itself, like with ``Batcher``.
162
+
163
+ :ivar max_flush_interval: Maximum time interval in seconds between
164
+ automatic flushes, regardless of the batch size.
165
+ :ivar max_batch_size: Maximum number of items in a batch. When reached,
166
+ the batch is flushed automatically.
167
+ :ivar handler: Callable handler that processes each flushed batch. The handler must
168
+ be provided during initialization and must accept a list of items as input.
169
+ """
170
+
171
+ max_flush_interval: float
172
+ max_batch_size: int
173
+ handler: BatchHandler[T]
174
+
175
+ _buffer: list[T]
176
+ _flush_lock: threading.Condition
177
+ _closed: bool
178
+
179
+ def __init__(
180
+ self,
181
+ handler: BatchHandler[T],
182
+ max_flush_interval: float = 10,
183
+ max_batch_size: int = 20,
184
+ ):
185
+ self.handler = handler
186
+ self.max_flush_interval = max_flush_interval
187
+ self.max_batch_size = max_batch_size
188
+
189
+ self._buffer = []
190
+ self._flush_lock = threading.Condition()
191
+ self._closed = False
192
+
193
+ def add(self, item: T):
194
+ """
195
+ Adds an item to the buffer.
196
+
197
+ :param item: the item to add
198
+ """
199
+ with self._flush_lock:
200
+ if self._closed:
201
+ raise ValueError("Batcher is stopped, can no longer add items")
202
+
203
+ self._buffer.append(item)
204
+
205
+ if len(self._buffer) >= self.max_batch_size:
206
+ self._flush_lock.notify_all()
207
+
208
+ @property
209
+ def current_batch_size(self) -> int:
210
+ """
211
+ Returns the current number of items in the buffer waiting to be flushed.
212
+ """
213
+ return len(self._buffer)
214
+
215
+ def run(self):
216
+ """
217
+ Runs the event loop that flushes the buffer to the handler based on the configured rules, and blocks until
218
+ ``close()`` is called. This method is meant to be run in a separate thread.
219
+ """
220
+ while not self._closed:
221
+ with self._flush_lock:
222
+ # wait returns once either the condition is notified (in which case wait returns True, indicating that
223
+ # something has triggered a flush manually), or the timeout expires (in which case wait returns False)
224
+ self._flush_lock.wait(self.max_flush_interval)
225
+
226
+ # if _flush_condition was notified because close() was called, we should still make sure we flush the
227
+ # last batch
228
+
229
+ # perform the flush, if there are any items in the buffer
230
+ if not self._buffer:
231
+ continue
232
+
233
+ batch = self._buffer.copy()
234
+ self._buffer.clear()
235
+
236
+ # we can call the processor outside the lock so we can continue adding items into the next batch without
237
+ # waiting on the processor to return.
238
+ try:
239
+ self.handler(batch)
240
+ except Exception as e:
241
+ LOG.error(
242
+ "Unhandled exception while processing a batch: %s",
243
+ e,
244
+ exc_info=LOG.isEnabledFor(logging.DEBUG),
245
+ )
246
+
247
+ # this marks that the main control loop is done
248
+ return
249
+
250
+ def close(self):
251
+ """
252
+ Triggers a close of the batcher, which will cause one last flush, and then end the main event loop.
253
+ """
254
+ with self._flush_lock:
255
+ if self._closed:
256
+ return
257
+ self._closed = True
258
+ self._flush_lock.notify_all()