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,326 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import parse_qs, parse_qsl, urlencode, urlparse, urlunparse
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from requests.models import CaseInsensitiveDict, Response
|
|
9
|
+
|
|
10
|
+
from localstack_cli import config
|
|
11
|
+
|
|
12
|
+
from .strings import to_str
|
|
13
|
+
|
|
14
|
+
# chunk size for file downloads
|
|
15
|
+
DOWNLOAD_CHUNK_SIZE = 1024 * 1024
|
|
16
|
+
|
|
17
|
+
ACCEPT = "accept"
|
|
18
|
+
LOG = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def uses_chunked_encoding(response):
|
|
22
|
+
return response.headers.get("Transfer-Encoding", "").lower() == "chunked"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_chunked_data(data):
|
|
26
|
+
"""Parse the body of an HTTP message transmitted with chunked transfer encoding."""
|
|
27
|
+
data = (data or "").strip()
|
|
28
|
+
chunks = []
|
|
29
|
+
while data:
|
|
30
|
+
length = re.match(r"^([0-9a-zA-Z]+)\r\n.*", data)
|
|
31
|
+
if not length:
|
|
32
|
+
break
|
|
33
|
+
length = length.group(1).lower()
|
|
34
|
+
length = int(length, 16)
|
|
35
|
+
data = data.partition("\r\n")[2]
|
|
36
|
+
chunks.append(data[:length])
|
|
37
|
+
data = data[length:].strip()
|
|
38
|
+
return "".join(chunks)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_chunked_data(data, chunk_size: int = 80):
|
|
42
|
+
dl = len(data)
|
|
43
|
+
ret = ""
|
|
44
|
+
for i in range(dl // chunk_size):
|
|
45
|
+
ret += f"{hex(chunk_size)[2:]}\r\n"
|
|
46
|
+
ret += f"{data[i * chunk_size : (i + 1) * chunk_size]}\r\n\r\n"
|
|
47
|
+
|
|
48
|
+
if len(data) % chunk_size != 0:
|
|
49
|
+
ret += f"{hex(len(data) % chunk_size)[2:]}\r\n"
|
|
50
|
+
ret += f"{data[-(len(data) % chunk_size) :]}\r\n"
|
|
51
|
+
|
|
52
|
+
ret += "0\r\n\r\n"
|
|
53
|
+
return ret
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def canonicalize_headers(headers: dict | CaseInsensitiveDict) -> dict:
|
|
57
|
+
if not headers:
|
|
58
|
+
return headers
|
|
59
|
+
|
|
60
|
+
def _normalize(name):
|
|
61
|
+
if name.lower().startswith(ACCEPT):
|
|
62
|
+
return name.lower()
|
|
63
|
+
return name
|
|
64
|
+
|
|
65
|
+
result = {_normalize(k): v for k, v in headers.items()}
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def add_path_parameters_to_url(uri: str, path_params: list):
|
|
70
|
+
url = urlparse(uri)
|
|
71
|
+
last_character = (
|
|
72
|
+
"/" if (len(url.path) == 0 or url.path[-1] != "/") and len(path_params) > 0 else ""
|
|
73
|
+
)
|
|
74
|
+
new_path = url.path + last_character + "/".join(path_params)
|
|
75
|
+
return urlunparse(url._replace(path=new_path))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def add_query_params_to_url(uri: str, query_params: dict) -> str:
|
|
79
|
+
"""
|
|
80
|
+
Add query parameters to the uri.
|
|
81
|
+
:param uri: the base uri it can contains path arguments and query parameters
|
|
82
|
+
:param query_params: new query parameters to be added
|
|
83
|
+
:return: the resulting URL
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# parse the incoming uri
|
|
87
|
+
url = urlparse(uri)
|
|
88
|
+
|
|
89
|
+
# parses the query part, if exists, into a dict
|
|
90
|
+
query_dict = dict(parse_qsl(url.query))
|
|
91
|
+
|
|
92
|
+
# updates the dict with new query parameters
|
|
93
|
+
query_dict.update(query_params)
|
|
94
|
+
|
|
95
|
+
# encodes query parameters
|
|
96
|
+
url_query = urlencode(query_dict)
|
|
97
|
+
|
|
98
|
+
# replaces the existing query
|
|
99
|
+
url_parse = url._replace(query=url_query)
|
|
100
|
+
|
|
101
|
+
return urlunparse(url_parse)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def make_http_request(
|
|
105
|
+
url: str, data: bytes | str = None, headers: dict[str, str] = None, method: str = "GET"
|
|
106
|
+
) -> Response:
|
|
107
|
+
return requests.request(
|
|
108
|
+
url=url, method=method, headers=headers, data=data, auth=NetrcBypassAuth(), verify=False
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class NetrcBypassAuth(requests.auth.AuthBase):
|
|
113
|
+
def __call__(self, r):
|
|
114
|
+
return r
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _RequestsSafe:
|
|
118
|
+
"""Wrapper around requests library, which can prevent it from verifying
|
|
119
|
+
SSL certificates or reading credentials from ~/.netrc file"""
|
|
120
|
+
|
|
121
|
+
verify_ssl = True
|
|
122
|
+
|
|
123
|
+
def __getattr__(self, name):
|
|
124
|
+
method = requests.__dict__.get(name.lower())
|
|
125
|
+
if not method:
|
|
126
|
+
return method
|
|
127
|
+
|
|
128
|
+
def _wrapper(*args, **kwargs):
|
|
129
|
+
if "auth" not in kwargs:
|
|
130
|
+
kwargs["auth"] = NetrcBypassAuth()
|
|
131
|
+
url = kwargs.get("url") or (args[1] if name == "request" else args[0])
|
|
132
|
+
if not self.verify_ssl and url.startswith("https://") and "verify" not in kwargs:
|
|
133
|
+
kwargs["verify"] = False
|
|
134
|
+
return method(*args, **kwargs)
|
|
135
|
+
|
|
136
|
+
return _wrapper
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# create safe_requests instance
|
|
140
|
+
safe_requests = _RequestsSafe()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_request_data(method: str, path: str, data=None, headers=None) -> dict:
|
|
144
|
+
"""Extract request data either from query string as well as request body (e.g., for POST)."""
|
|
145
|
+
result = {}
|
|
146
|
+
headers = headers or {}
|
|
147
|
+
content_type = headers.get("Content-Type", "")
|
|
148
|
+
|
|
149
|
+
# add query params to result
|
|
150
|
+
parsed_path = urlparse(path)
|
|
151
|
+
result.update(parse_qs(parsed_path.query))
|
|
152
|
+
|
|
153
|
+
# add params from url-encoded payload
|
|
154
|
+
if method in ["POST", "PUT", "PATCH"] and (not content_type or "form-" in content_type):
|
|
155
|
+
# content-type could be either "application/x-www-form-urlencoded" or "multipart/form-data"
|
|
156
|
+
try:
|
|
157
|
+
params = parse_qs(to_str(data or ""))
|
|
158
|
+
result.update(params)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass # probably binary / JSON / non-URL encoded payload - ignore
|
|
161
|
+
|
|
162
|
+
# select first elements from result lists (this is assuming we are not using parameter lists!)
|
|
163
|
+
result = {k: v[0] for k, v in result.items()}
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_proxies() -> dict[str, str]:
|
|
168
|
+
proxy_map = {}
|
|
169
|
+
if config.OUTBOUND_HTTP_PROXY:
|
|
170
|
+
proxy_map["http"] = config.OUTBOUND_HTTP_PROXY
|
|
171
|
+
if config.OUTBOUND_HTTPS_PROXY:
|
|
172
|
+
proxy_map["https"] = config.OUTBOUND_HTTPS_PROXY
|
|
173
|
+
return proxy_map
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def download(
|
|
177
|
+
url: str,
|
|
178
|
+
path: str,
|
|
179
|
+
verify_ssl: bool = True,
|
|
180
|
+
timeout: float = None,
|
|
181
|
+
request_headers: dict | None = None,
|
|
182
|
+
quiet: bool = False,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Downloads file at url to the given path. Raises TimeoutError if the optional timeout (in secs) is reached.
|
|
185
|
+
|
|
186
|
+
If `quiet` is passed, do not log any status messages. Error messages are still logged.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
# make sure we're creating a new session here to enable parallel file downloads
|
|
190
|
+
s = requests.Session()
|
|
191
|
+
proxies = get_proxies()
|
|
192
|
+
if proxies:
|
|
193
|
+
s.proxies.update(proxies)
|
|
194
|
+
|
|
195
|
+
# Use REQUESTS_CA_BUNDLE path. If it doesn't exist, use the method provided settings.
|
|
196
|
+
# Note that a value that is not False, will result to True and will get the bundle file.
|
|
197
|
+
_verify = os.getenv("REQUESTS_CA_BUNDLE", verify_ssl)
|
|
198
|
+
|
|
199
|
+
r = None
|
|
200
|
+
try:
|
|
201
|
+
r = s.get(url, stream=True, verify=_verify, timeout=timeout, headers=request_headers)
|
|
202
|
+
# check status code before attempting to read body
|
|
203
|
+
if not r.ok:
|
|
204
|
+
raise Exception(f"Failed to download {url}, response code {r.status_code}")
|
|
205
|
+
|
|
206
|
+
total_size = 0
|
|
207
|
+
if r.headers.get("Content-Length"):
|
|
208
|
+
total_size = int(r.headers.get("Content-Length"))
|
|
209
|
+
|
|
210
|
+
total_downloaded = 0
|
|
211
|
+
if not os.path.exists(os.path.dirname(path)):
|
|
212
|
+
os.makedirs(os.path.dirname(path))
|
|
213
|
+
if not quiet:
|
|
214
|
+
LOG.debug("Starting download from %s to %s", url, path)
|
|
215
|
+
with open(path, "wb") as f:
|
|
216
|
+
iter_length = 0
|
|
217
|
+
percentage_limit = next_percentage_record = 10 # print a log line for every 10%
|
|
218
|
+
iter_limit = (
|
|
219
|
+
1000000 # if we can't tell the percentage, print a log line for every 1MB chunk
|
|
220
|
+
)
|
|
221
|
+
for chunk in r.iter_content(DOWNLOAD_CHUNK_SIZE):
|
|
222
|
+
# explicitly check the raw stream, since the size from the chunk can be bigger than the amount of
|
|
223
|
+
# bytes transferred over the wire due to transparent decompression (f.e. GZIP)
|
|
224
|
+
new_total_downloaded = r.raw.tell()
|
|
225
|
+
iter_length += new_total_downloaded - total_downloaded
|
|
226
|
+
total_downloaded = new_total_downloaded
|
|
227
|
+
if chunk: # filter out keep-alive new chunks
|
|
228
|
+
f.write(chunk)
|
|
229
|
+
elif not quiet:
|
|
230
|
+
LOG.debug(
|
|
231
|
+
"Empty chunk %s (total %dK of %dK) from %s",
|
|
232
|
+
chunk,
|
|
233
|
+
total_downloaded / 1024,
|
|
234
|
+
total_size / 1024,
|
|
235
|
+
url,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if total_size > 0 and (
|
|
239
|
+
(current_percent := total_downloaded / total_size * 100)
|
|
240
|
+
>= next_percentage_record
|
|
241
|
+
):
|
|
242
|
+
# increment the limit for the next log output (ensure that there is max 1 log message per block)
|
|
243
|
+
# f.e. percentage_limit is 10, current percentage is 71: next log is earliest at 80%
|
|
244
|
+
next_percentage_record = (
|
|
245
|
+
math.floor(current_percent / percentage_limit) * percentage_limit
|
|
246
|
+
+ percentage_limit
|
|
247
|
+
)
|
|
248
|
+
if not quiet:
|
|
249
|
+
LOG.debug(
|
|
250
|
+
"Downloaded %d%% (total %dK of %dK) to %s",
|
|
251
|
+
current_percent,
|
|
252
|
+
total_downloaded / 1024,
|
|
253
|
+
total_size / 1024,
|
|
254
|
+
path,
|
|
255
|
+
)
|
|
256
|
+
iter_length = 0
|
|
257
|
+
elif total_size <= 0 and iter_length >= iter_limit:
|
|
258
|
+
if not quiet:
|
|
259
|
+
# print log message every x K if the total size is not known
|
|
260
|
+
LOG.debug(
|
|
261
|
+
"Downloaded %dK (total %dK) to %s",
|
|
262
|
+
iter_length / 1024,
|
|
263
|
+
total_downloaded / 1024,
|
|
264
|
+
path,
|
|
265
|
+
)
|
|
266
|
+
iter_length = 0
|
|
267
|
+
f.flush()
|
|
268
|
+
os.fsync(f)
|
|
269
|
+
if os.path.getsize(path) == 0:
|
|
270
|
+
LOG.warning("Zero bytes downloaded from %s, retrying", url)
|
|
271
|
+
download(url, path, verify_ssl)
|
|
272
|
+
return
|
|
273
|
+
if not quiet:
|
|
274
|
+
LOG.debug(
|
|
275
|
+
"Done downloading %s, response code %s, total %dK",
|
|
276
|
+
url,
|
|
277
|
+
r.status_code,
|
|
278
|
+
total_downloaded / 1024,
|
|
279
|
+
)
|
|
280
|
+
except requests.exceptions.ReadTimeout as e:
|
|
281
|
+
raise TimeoutError(f"Timeout ({timeout}) reached on download: {url} - {e}")
|
|
282
|
+
finally:
|
|
283
|
+
if r is not None:
|
|
284
|
+
r.close()
|
|
285
|
+
s.close()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def download_github_artifact(url: str, target_file: str, timeout: int = None):
|
|
289
|
+
"""Download file from main URL or fallback URL (to avoid firewall errors if github.com is blocked).
|
|
290
|
+
Optionally allows to define a timeout in seconds."""
|
|
291
|
+
|
|
292
|
+
def do_download(
|
|
293
|
+
download_url: str, request_headers: dict | None = None, print_error: bool = False
|
|
294
|
+
):
|
|
295
|
+
try:
|
|
296
|
+
download(download_url, target_file, timeout=timeout, request_headers=request_headers)
|
|
297
|
+
return True
|
|
298
|
+
except Exception as e:
|
|
299
|
+
if print_error:
|
|
300
|
+
LOG.error(
|
|
301
|
+
"Unable to download Github artifact from %s to %s: %s %s",
|
|
302
|
+
url,
|
|
303
|
+
target_file,
|
|
304
|
+
e,
|
|
305
|
+
exc_info=LOG.isEnabledFor(logging.DEBUG),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# if a GitHub API token is set, use it to avoid rate limiting issues
|
|
309
|
+
gh_token = os.environ.get("GITHUB_API_TOKEN")
|
|
310
|
+
gh_auth_headers = None
|
|
311
|
+
if gh_token:
|
|
312
|
+
gh_auth_headers = {"authorization": f"Bearer {gh_token}"}
|
|
313
|
+
result = do_download(url, request_headers=gh_auth_headers)
|
|
314
|
+
if not result:
|
|
315
|
+
# TODO: use regex below to allow different branch names than "master"
|
|
316
|
+
url = url.replace("https://github.com", "https://cdn.jsdelivr.net/gh")
|
|
317
|
+
# The URL structure is https://cdn.jsdelivr.net/gh/user/repo@branch/file.js
|
|
318
|
+
url = url.replace("/raw/master/", "@master/")
|
|
319
|
+
# Do not send the GitHub auth token to the CDN
|
|
320
|
+
do_download(url, print_error=True)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# TODO move to aws_responses.py?
|
|
324
|
+
def replace_response_content(response, pattern, replacement):
|
|
325
|
+
content = to_str(response.content or "")
|
|
326
|
+
response._content = re.sub(pattern, replacement, content)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import decimal
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from json import JSONDecodeError
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from localstack_cli.config import HostAndPort
|
|
11
|
+
|
|
12
|
+
from .numbers import is_number
|
|
13
|
+
from .strings import to_str
|
|
14
|
+
from .time import timestamp_millis
|
|
15
|
+
|
|
16
|
+
LOG = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CustomEncoder(json.JSONEncoder):
|
|
20
|
+
"""Helper class to convert JSON documents with datetime, decimals, or bytes."""
|
|
21
|
+
|
|
22
|
+
def default(self, o):
|
|
23
|
+
import yaml # leave import here, to avoid breaking our Lambda tests!
|
|
24
|
+
|
|
25
|
+
if isinstance(o, HostAndPort):
|
|
26
|
+
return str(o)
|
|
27
|
+
if isinstance(o, decimal.Decimal):
|
|
28
|
+
if o % 1 > 0:
|
|
29
|
+
return float(o)
|
|
30
|
+
else:
|
|
31
|
+
return int(o)
|
|
32
|
+
if isinstance(o, (datetime, date)):
|
|
33
|
+
return timestamp_millis(o)
|
|
34
|
+
if isinstance(o, yaml.ScalarNode):
|
|
35
|
+
if o.tag == "tag:yaml.org,2002:int":
|
|
36
|
+
return int(o.value)
|
|
37
|
+
if o.tag == "tag:yaml.org,2002:float":
|
|
38
|
+
return float(o.value)
|
|
39
|
+
if o.tag == "tag:yaml.org,2002:bool":
|
|
40
|
+
return bool(o.value)
|
|
41
|
+
return str(o.value)
|
|
42
|
+
try:
|
|
43
|
+
if isinstance(o, bytes):
|
|
44
|
+
return to_str(o)
|
|
45
|
+
return super().default(o)
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BytesEncoder(json.JSONEncoder):
|
|
51
|
+
"""Specialized JSON encoder that encode bytes into Base64 strings."""
|
|
52
|
+
|
|
53
|
+
def default(self, obj):
|
|
54
|
+
if isinstance(obj, bytes):
|
|
55
|
+
return to_str(base64.b64encode(obj))
|
|
56
|
+
return super().default(obj)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FileMappedDocument(dict):
|
|
60
|
+
"""A dictionary that is mapped to a json document on disk.
|
|
61
|
+
|
|
62
|
+
When the document is created, an attempt is made to load existing contents from disk. To load changes from
|
|
63
|
+
concurrent writes, run load(). To save and overwrite the current document on disk, run save().
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
path: str | os.PathLike
|
|
67
|
+
|
|
68
|
+
def __init__(self, path: str | os.PathLike, mode=0o664):
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.path = path
|
|
71
|
+
self.mode = mode
|
|
72
|
+
self.load()
|
|
73
|
+
|
|
74
|
+
def load(self):
|
|
75
|
+
if not os.path.exists(self.path):
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if os.path.isdir(self.path):
|
|
79
|
+
raise IsADirectoryError
|
|
80
|
+
|
|
81
|
+
with open(self.path) as fd:
|
|
82
|
+
self.update(json.load(fd))
|
|
83
|
+
|
|
84
|
+
def save(self):
|
|
85
|
+
if os.path.isdir(self.path):
|
|
86
|
+
raise IsADirectoryError
|
|
87
|
+
|
|
88
|
+
if not os.path.exists(self.path):
|
|
89
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
90
|
+
|
|
91
|
+
def opener(path, flags):
|
|
92
|
+
_fd = os.open(path, flags, self.mode)
|
|
93
|
+
os.chmod(path, mode=self.mode, follow_symlinks=True)
|
|
94
|
+
return _fd
|
|
95
|
+
|
|
96
|
+
with open(self.path, "w", opener=opener) as fd:
|
|
97
|
+
json.dump(self, fd)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def clone(item):
|
|
101
|
+
return json.loads(json.dumps(item))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def clone_safe(item):
|
|
105
|
+
return clone(json_safe(item))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_json_or_yaml(markup: str) -> Any:
|
|
109
|
+
import yaml # leave import here, to avoid breaking our Lambda tests!
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
return json.loads(markup)
|
|
113
|
+
except Exception:
|
|
114
|
+
try:
|
|
115
|
+
return clone_safe(yaml.safe_load(markup))
|
|
116
|
+
except Exception:
|
|
117
|
+
try:
|
|
118
|
+
return clone_safe(yaml.load(markup, Loader=yaml.SafeLoader))
|
|
119
|
+
except Exception:
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def try_json(data: str):
|
|
124
|
+
"""
|
|
125
|
+
Tries to deserialize the passed json input to an object if possible, otherwise returns the original input.
|
|
126
|
+
:param data: string
|
|
127
|
+
:return: deserialize version of input
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
return json.loads(to_str(data or "{}"))
|
|
131
|
+
except JSONDecodeError:
|
|
132
|
+
LOG.warning("failed serialize to json, fallback to original")
|
|
133
|
+
return data
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def json_safe(item: Any) -> Any:
|
|
137
|
+
"""Return a copy of the given object (e.g., dict) that is safe for JSON dumping"""
|
|
138
|
+
try:
|
|
139
|
+
return json.loads(json.dumps(item, cls=CustomEncoder))
|
|
140
|
+
except Exception:
|
|
141
|
+
item = fix_json_keys(item)
|
|
142
|
+
return json.loads(json.dumps(item, cls=CustomEncoder))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fix_json_keys(item: Any):
|
|
146
|
+
"""make sure the keys of a JSON are strings (not binary type or other)"""
|
|
147
|
+
item_copy = item
|
|
148
|
+
if isinstance(item, list):
|
|
149
|
+
item_copy = []
|
|
150
|
+
for i in item:
|
|
151
|
+
item_copy.append(fix_json_keys(i))
|
|
152
|
+
if isinstance(item, dict):
|
|
153
|
+
item_copy = {}
|
|
154
|
+
for k, v in item.items():
|
|
155
|
+
item_copy[to_str(k)] = fix_json_keys(v)
|
|
156
|
+
return item_copy
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def canonical_json(obj):
|
|
160
|
+
return json.dumps(obj, sort_keys=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def extract_jsonpath(value, path):
|
|
164
|
+
from jsonpath_rw import parse
|
|
165
|
+
|
|
166
|
+
jsonpath_expr = parse(path)
|
|
167
|
+
result = [match.value for match in jsonpath_expr.find(value)]
|
|
168
|
+
result = result[0] if len(result) == 1 else result
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def assign_to_path(target: dict, path: str, value: any, delimiter: str = ".") -> dict:
|
|
173
|
+
"""Assign the given value to a dict. If the path doesn't exist in the target dict, it will be created.
|
|
174
|
+
The delimiter can be used to provide a path with a different delimiter.
|
|
175
|
+
|
|
176
|
+
Examples:
|
|
177
|
+
- assign_to_path({}, "a", "b") => {"a": "b"}
|
|
178
|
+
- assign_to_path({}, "a.b.c", "d") => {"a": {"b": {"c": "d"}}}
|
|
179
|
+
- assign_to_path({}, "a.b/c", "d", delimiter="/") => {"a.b": {"c": "d"}}
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
parts = path.strip(delimiter).split(delimiter)
|
|
183
|
+
|
|
184
|
+
if len(parts) == 1:
|
|
185
|
+
target[parts[0]] = value
|
|
186
|
+
return target
|
|
187
|
+
|
|
188
|
+
path_to_parent = delimiter.join(parts[:-1])
|
|
189
|
+
parent = extract_from_jsonpointer_path(target, path_to_parent, delimiter, auto_create=True)
|
|
190
|
+
if not isinstance(parent, dict):
|
|
191
|
+
LOG.debug(
|
|
192
|
+
'Unable to find parent (type %s) for path "%s" in object: %s',
|
|
193
|
+
type(parent),
|
|
194
|
+
path,
|
|
195
|
+
target,
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
path_end = int(parts[-1]) if is_number(parts[-1]) else parts[-1].replace("~1", "/")
|
|
199
|
+
parent[path_end] = value
|
|
200
|
+
return target
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def extract_from_jsonpointer_path(target, path: str, delimiter: str = "/", auto_create=False):
|
|
204
|
+
parts = path.strip(delimiter).split(delimiter)
|
|
205
|
+
for part in parts:
|
|
206
|
+
path_part = int(part) if is_number(part) else part
|
|
207
|
+
if isinstance(target, list) and not is_number(path_part):
|
|
208
|
+
if path_part == "-":
|
|
209
|
+
# special case where path is like /path/to/list/- where "/-" means "append to list"
|
|
210
|
+
continue
|
|
211
|
+
LOG.warning('Attempting to extract non-int index "%s" from list: %s', path_part, target)
|
|
212
|
+
return None
|
|
213
|
+
target_new = target[path_part] if isinstance(target, list) else target.get(path_part)
|
|
214
|
+
if target_new is None:
|
|
215
|
+
if not auto_create:
|
|
216
|
+
return
|
|
217
|
+
target[path_part] = target_new = {}
|
|
218
|
+
target = target_new
|
|
219
|
+
return target
|