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.
- localstack_cli/__init__.py +0 -0
- localstack_cli/cli/__init__.py +10 -0
- localstack_cli/cli/console.py +11 -0
- localstack_cli/cli/core_plugin.py +12 -0
- localstack_cli/cli/exceptions.py +19 -0
- localstack_cli/cli/localstack.py +951 -0
- localstack_cli/cli/lpm.py +138 -0
- localstack_cli/cli/main.py +22 -0
- localstack_cli/cli/plugin.py +39 -0
- localstack_cli/cli/plugins.py +134 -0
- localstack_cli/cli/profiles.py +65 -0
- localstack_cli/config.py +1689 -0
- localstack_cli/constants.py +165 -0
- localstack_cli/logging/__init__.py +0 -0
- localstack_cli/logging/format.py +194 -0
- localstack_cli/logging/setup.py +142 -0
- localstack_cli/packages/__init__.py +25 -0
- localstack_cli/packages/api.py +418 -0
- localstack_cli/packages/core.py +416 -0
- localstack_cli/pro/__init__.py +0 -0
- localstack_cli/pro/core/__init__.py +0 -0
- localstack_cli/pro/core/bootstrap/__init__.py +1 -0
- localstack_cli/pro/core/bootstrap/auth.py +213 -0
- localstack_cli/pro/core/bootstrap/dns_utils.py +55 -0
- localstack_cli/pro/core/bootstrap/entitlements.py +117 -0
- localstack_cli/pro/core/bootstrap/extensions/__init__.py +3 -0
- localstack_cli/pro/core/bootstrap/extensions/__main__.py +106 -0
- localstack_cli/pro/core/bootstrap/extensions/autoinstall.py +63 -0
- localstack_cli/pro/core/bootstrap/extensions/bootstrap.py +97 -0
- localstack_cli/pro/core/bootstrap/extensions/repository.py +374 -0
- localstack_cli/pro/core/bootstrap/licensingv2.py +1259 -0
- localstack_cli/pro/core/bootstrap/pods/__init__.py +0 -0
- localstack_cli/pro/core/bootstrap/pods/api_types.py +17 -0
- localstack_cli/pro/core/bootstrap/pods/constants.py +26 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/__init__.py +0 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/api.py +75 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/configs.py +69 -0
- localstack_cli/pro/core/bootstrap/pods/remotes/params.py +86 -0
- localstack_cli/pro/core/bootstrap/pods_client.py +834 -0
- localstack_cli/pro/core/cli/__init__.py +0 -0
- localstack_cli/pro/core/cli/auth.py +226 -0
- localstack_cli/pro/core/cli/aws.py +16 -0
- localstack_cli/pro/core/cli/cli.py +99 -0
- localstack_cli/pro/core/cli/click_utils.py +21 -0
- localstack_cli/pro/core/cli/cloud_pods.py +465 -0
- localstack_cli/pro/core/cli/diff_view.py +41 -0
- localstack_cli/pro/core/cli/ephemeral.py +199 -0
- localstack_cli/pro/core/cli/extensions.py +492 -0
- localstack_cli/pro/core/cli/iam.py +180 -0
- localstack_cli/pro/core/cli/license.py +90 -0
- localstack_cli/pro/core/cli/localstack.py +118 -0
- localstack_cli/pro/core/cli/replicator.py +378 -0
- localstack_cli/pro/core/cli/state.py +183 -0
- localstack_cli/pro/core/cli/tree_view.py +235 -0
- localstack_cli/pro/core/config.py +556 -0
- localstack_cli/pro/core/constants.py +54 -0
- localstack_cli/pro/core/plugins.py +169 -0
- localstack_cli/runtime/__init__.py +6 -0
- localstack_cli/runtime/exceptions.py +7 -0
- localstack_cli/runtime/hooks.py +73 -0
- localstack_cli/testing/__init__.py +1 -0
- localstack_cli/testing/config.py +4 -0
- localstack_cli/utils/__init__.py +0 -0
- localstack_cli/utils/analytics/__init__.py +12 -0
- localstack_cli/utils/analytics/cli.py +67 -0
- localstack_cli/utils/analytics/client.py +111 -0
- localstack_cli/utils/analytics/events.py +30 -0
- localstack_cli/utils/analytics/logger.py +48 -0
- localstack_cli/utils/analytics/metadata.py +250 -0
- localstack_cli/utils/analytics/publisher.py +160 -0
- localstack_cli/utils/analytics/service_request_aggregator.py +133 -0
- localstack_cli/utils/archives.py +271 -0
- localstack_cli/utils/batching.py +258 -0
- localstack_cli/utils/bootstrap.py +1418 -0
- localstack_cli/utils/checksum.py +313 -0
- localstack_cli/utils/collections.py +554 -0
- localstack_cli/utils/common.py +229 -0
- localstack_cli/utils/container_networking.py +142 -0
- localstack_cli/utils/container_utils/__init__.py +0 -0
- localstack_cli/utils/container_utils/container_client.py +1585 -0
- localstack_cli/utils/container_utils/docker_cmd_client.py +987 -0
- localstack_cli/utils/container_utils/docker_sdk_client.py +1018 -0
- localstack_cli/utils/crypto.py +294 -0
- localstack_cli/utils/docker_utils.py +272 -0
- localstack_cli/utils/files.py +327 -0
- localstack_cli/utils/functions.py +92 -0
- localstack_cli/utils/http.py +326 -0
- localstack_cli/utils/json.py +219 -0
- localstack_cli/utils/net.py +516 -0
- localstack_cli/utils/no_exit_argument_parser.py +19 -0
- localstack_cli/utils/numbers.py +49 -0
- localstack_cli/utils/objects.py +235 -0
- localstack_cli/utils/patch.py +260 -0
- localstack_cli/utils/platform.py +77 -0
- localstack_cli/utils/run.py +514 -0
- localstack_cli/utils/server/__init__.py +0 -0
- localstack_cli/utils/server/tcp_proxy.py +108 -0
- localstack_cli/utils/serving.py +187 -0
- localstack_cli/utils/ssl.py +71 -0
- localstack_cli/utils/strings.py +245 -0
- localstack_cli/utils/sync.py +267 -0
- localstack_cli/utils/threads.py +163 -0
- localstack_cli/utils/time.py +81 -0
- localstack_cli/utils/urls.py +21 -0
- localstack_cli/utils/venv.py +100 -0
- localstack_cli/utils/xml.py +41 -0
- localstack_cli/version.py +34 -0
- playground_ls_cli-4.14.1.dev8.dist-info/METADATA +95 -0
- playground_ls_cli-4.14.1.dev8.dist-info/RECORD +112 -0
- playground_ls_cli-4.14.1.dev8.dist-info/WHEEL +5 -0
- playground_ls_cli-4.14.1.dev8.dist-info/entry_points.txt +17 -0
- 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()
|