plexus-python-pulse-common 1.1.75__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.
- plexus/pulse/common/__init__.py +6 -0
- plexus/pulse/common/carto/OSMFile.py +259 -0
- plexus/pulse/common/carto/OSMNode.py +25 -0
- plexus/pulse/common/carto/OSMTags.py +101 -0
- plexus/pulse/common/carto/OSMWay.py +24 -0
- plexus/pulse/common/carto/__init__.py +11 -0
- plexus/pulse/common/resources/__init__.py +0 -0
- plexus/pulse/common/resources/tags/__init__.py +35 -0
- plexus/pulse/common/resources/tags/unittest-1.0.0.tagset.yaml +98 -0
- plexus/pulse/common/resources/tags/universal-1.0.0.tagset.yaml +1390 -0
- plexus/pulse/common/utils/__init__.py +0 -0
- plexus/pulse/common/utils/apiutils.py +31 -0
- plexus/pulse/common/utils/bagutils.py +331 -0
- plexus/pulse/common/utils/config.py +63 -0
- plexus/pulse/common/utils/datautils.py +233 -0
- plexus/pulse/common/utils/dockerutils.py +181 -0
- plexus/pulse/common/utils/gisutils.py +406 -0
- plexus/pulse/common/utils/jsonutils.py +96 -0
- plexus/pulse/common/utils/ormutils.py +1638 -0
- plexus/pulse/common/utils/pathutils.py +234 -0
- plexus/pulse/common/utils/s3utils.py +939 -0
- plexus/pulse/common/utils/sqlutils.py +9 -0
- plexus/pulse/common/utils/strutils.py +401 -0
- plexus/pulse/common/utils/tagutils.py +1476 -0
- plexus/pulse/common/utils/testutils.py +172 -0
- plexus_python_pulse_common-1.1.75.dist-info/METADATA +42 -0
- plexus_python_pulse_common-1.1.75.dist-info/RECORD +29 -0
- plexus_python_pulse_common-1.1.75.dist-info/WHEEL +5 -0
- plexus_python_pulse_common-1.1.75.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
import contextlib
|
|
3
|
+
import dataclasses
|
|
4
|
+
import datetime
|
|
5
|
+
import functools
|
|
6
|
+
import io
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
import typing
|
|
13
|
+
import zipfile
|
|
14
|
+
import zlib
|
|
15
|
+
from collections.abc import Callable, Generator
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
import boto3
|
|
19
|
+
import fsspec
|
|
20
|
+
import fsspec.utils
|
|
21
|
+
from mypy_boto3_s3 import S3Client
|
|
22
|
+
from plexus.common.utils.iterutils import chunk_between, head, last
|
|
23
|
+
from plexus.common.utils.jsonutils import JsonObject
|
|
24
|
+
from plexus.common.utils.pathutils import glob_match, make_path, path_depth, scan_files
|
|
25
|
+
from plexus.common.utils.strutils import is_empty, trim_to_none
|
|
26
|
+
from rich.progress import BarColumn, DownloadColumn, Progress, TextColumn, TransferSpeedColumn
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"S3ObjectMeta",
|
|
30
|
+
"s3_make_client",
|
|
31
|
+
"s3_head_object",
|
|
32
|
+
"s3_list_objects",
|
|
33
|
+
"s3_list_files",
|
|
34
|
+
"s3_cp_download",
|
|
35
|
+
"s3_cp_upload",
|
|
36
|
+
"s3_sync_download",
|
|
37
|
+
"s3_sync_upload",
|
|
38
|
+
"s3_pull_text",
|
|
39
|
+
"s3_push_text",
|
|
40
|
+
"s3_make_progressed_client",
|
|
41
|
+
"ArchiveMemberChunk",
|
|
42
|
+
"s3_archive_member_tree",
|
|
43
|
+
"s3_archive_list_files",
|
|
44
|
+
"s3_archive_open_member",
|
|
45
|
+
"s3_archive_use_ranged_requests",
|
|
46
|
+
"s3_archive_use_chunked_reads",
|
|
47
|
+
"s3_archive_open_members",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclasses.dataclass(frozen=True, eq=True)
|
|
52
|
+
class S3ObjectMeta(object):
|
|
53
|
+
key: str
|
|
54
|
+
last_modified: datetime.datetime
|
|
55
|
+
size: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if typing.TYPE_CHECKING:
|
|
59
|
+
def s3_make_client(
|
|
60
|
+
access_key_id: str = None,
|
|
61
|
+
secret_access_key: str = None,
|
|
62
|
+
region_name: str = None,
|
|
63
|
+
endpoint_url: str = None,
|
|
64
|
+
) -> contextlib.AbstractContextManager[S3Client]: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def s3_make_client(
|
|
69
|
+
access_key_id: str = None,
|
|
70
|
+
secret_access_key: str = None,
|
|
71
|
+
region_name: str = None,
|
|
72
|
+
endpoint_url: str = None,
|
|
73
|
+
) -> Generator[S3Client, None, None]:
|
|
74
|
+
"""
|
|
75
|
+
Creates an S3 client as a context manager for safe resource handling.
|
|
76
|
+
|
|
77
|
+
:param access_key_id: AWS access key ID.
|
|
78
|
+
:param secret_access_key: AWS secret access key.
|
|
79
|
+
:param region_name: AWS service region name.
|
|
80
|
+
:param endpoint_url: AWS service endpoint URL.
|
|
81
|
+
:return: An instance of ``S3Client``.
|
|
82
|
+
"""
|
|
83
|
+
session = boto3.Session(aws_access_key_id=trim_to_none(access_key_id),
|
|
84
|
+
aws_secret_access_key=trim_to_none(secret_access_key),
|
|
85
|
+
region_name=trim_to_none(region_name))
|
|
86
|
+
client = session.client("s3", endpoint_url=trim_to_none(endpoint_url))
|
|
87
|
+
client.meta.credentials = session.get_credentials()
|
|
88
|
+
try:
|
|
89
|
+
yield client
|
|
90
|
+
finally:
|
|
91
|
+
client.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def s3_head_object(
|
|
95
|
+
client: S3Client,
|
|
96
|
+
bucket: str,
|
|
97
|
+
key: str,
|
|
98
|
+
) -> S3ObjectMeta:
|
|
99
|
+
"""
|
|
100
|
+
Retrieves metadata of an object from the given S3 ``bucket`` and ``key``.
|
|
101
|
+
|
|
102
|
+
:param client: An instance of ``S3Client``.
|
|
103
|
+
:param bucket: Bucket name.
|
|
104
|
+
:param key: Object key.
|
|
105
|
+
:return: An ``S3ObjectMeta`` object representing the S3 object.
|
|
106
|
+
"""
|
|
107
|
+
response = client.head_object(Bucket=bucket, Key=key)
|
|
108
|
+
return S3ObjectMeta(key=key, last_modified=response["LastModified"], size=response["ContentLength"])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def s3_list_objects(
|
|
112
|
+
client: S3Client,
|
|
113
|
+
bucket: str,
|
|
114
|
+
prefix: str,
|
|
115
|
+
limit: int = None,
|
|
116
|
+
) -> Generator[S3ObjectMeta, None, None]:
|
|
117
|
+
"""
|
|
118
|
+
Lists all objects from the given S3 ``bucket`` and ``prefix``.
|
|
119
|
+
|
|
120
|
+
:param client: An instance of ``S3Client``.
|
|
121
|
+
:param bucket: Bucket name.
|
|
122
|
+
:param prefix: Object keys prefix.
|
|
123
|
+
:param limit: Maximum number of objects to return (``None`` for all).
|
|
124
|
+
:return: An iterable of ``S3ObjectMeta`` objects representing the S3 objects.
|
|
125
|
+
"""
|
|
126
|
+
continuation_token = None
|
|
127
|
+
count = 0
|
|
128
|
+
while True:
|
|
129
|
+
if is_empty(continuation_token):
|
|
130
|
+
response = client.list_objects_v2(MaxKeys=1000, Bucket=bucket, Prefix=prefix)
|
|
131
|
+
else:
|
|
132
|
+
response = client.list_objects_v2(MaxKeys=1000,
|
|
133
|
+
Bucket=bucket,
|
|
134
|
+
Prefix=prefix,
|
|
135
|
+
ContinuationToken=continuation_token)
|
|
136
|
+
|
|
137
|
+
contents = response.get("Contents", [])
|
|
138
|
+
count += len(contents)
|
|
139
|
+
if limit is not None and count > limit:
|
|
140
|
+
contents = contents[:limit - count]
|
|
141
|
+
|
|
142
|
+
yield from (S3ObjectMeta(key=e["Key"], last_modified=e["LastModified"], size=e["Size"]) for e in contents)
|
|
143
|
+
|
|
144
|
+
if not response.get("IsTruncated") or (limit is not None and count >= limit):
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
continuation_token = response.get("NextContinuationToken")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def s3_list_files(
|
|
151
|
+
client: S3Client,
|
|
152
|
+
bucket: str,
|
|
153
|
+
prefix: str,
|
|
154
|
+
*,
|
|
155
|
+
include_patterns: list[str] | None = None,
|
|
156
|
+
exclude_patterns: list[str] | None = None,
|
|
157
|
+
depth: int = 0,
|
|
158
|
+
) -> Generator[S3ObjectMeta, None, None]:
|
|
159
|
+
"""
|
|
160
|
+
Lists all objects from the given S3 ``bucket`` and ``prefix``, filtered by patterns and directory depth.
|
|
161
|
+
|
|
162
|
+
:param client: An instance of ``S3Client``.
|
|
163
|
+
:param bucket: Bucket name.
|
|
164
|
+
:param prefix: Object keys prefix.
|
|
165
|
+
:param include_patterns: Inclusive glob patterns applied to filenames.
|
|
166
|
+
:param exclude_patterns: Exclusive glob patterns applied to filenames.
|
|
167
|
+
:param depth: Maximum depth of subdirectories to include in the scan (``0`` for unlimited depth).
|
|
168
|
+
:return: An iterable of ``S3ObjectMeta`` objects representing the filtered S3 objects.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
# We add trailing slash "/" to the prefix if it is absent
|
|
172
|
+
if not prefix.endswith("/"):
|
|
173
|
+
prefix = prefix + "/"
|
|
174
|
+
|
|
175
|
+
def filter_object_meta(object_meta: S3ObjectMeta) -> bool:
|
|
176
|
+
object_path = make_path(object_meta.key)
|
|
177
|
+
if 0 < depth <= path_depth(prefix, object_path.parent):
|
|
178
|
+
return False
|
|
179
|
+
if len(glob_match([object_path.name], include_patterns, exclude_patterns)) == 0:
|
|
180
|
+
return False
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
yield from filter(filter_object_meta, s3_list_objects(client, bucket, prefix))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def s3_cp_download(client: S3Client, bucket: str, key: str, file_path: str | os.PathLike[str]):
|
|
187
|
+
"""
|
|
188
|
+
Downloads an object from the given S3 ``bucket`` and ``key`` to a local file path.
|
|
189
|
+
|
|
190
|
+
:param client: An instance of ``S3Client``.
|
|
191
|
+
:param bucket: Bucket name.
|
|
192
|
+
:param key: Object key.
|
|
193
|
+
:param file_path: Local file path to save the object.
|
|
194
|
+
"""
|
|
195
|
+
client.download_file(bucket, key, file_path)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def s3_cp_upload(client: S3Client, file_path: str | os.PathLike[str], bucket: str, key: str):
|
|
199
|
+
"""
|
|
200
|
+
Uploads a local file to the given S3 ``bucket`` and ``key``.
|
|
201
|
+
|
|
202
|
+
:param client: An instance of ``S3Client``.
|
|
203
|
+
:param file_path: Local file path to upload.
|
|
204
|
+
:param bucket: Bucket name.
|
|
205
|
+
:param key: Object key for the uploaded file.
|
|
206
|
+
"""
|
|
207
|
+
t, _ = mimetypes.MimeTypes().guess_type(file_path)
|
|
208
|
+
client.upload_file(file_path,
|
|
209
|
+
bucket,
|
|
210
|
+
key,
|
|
211
|
+
ExtraArgs={"ContentType": "binary/octet-stream" if t is None else t})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def s3_sync_download(
|
|
215
|
+
client: S3Client,
|
|
216
|
+
bucket: str,
|
|
217
|
+
prefix: str,
|
|
218
|
+
dir_path: str | os.PathLike[str],
|
|
219
|
+
*,
|
|
220
|
+
max_workers: int = None,
|
|
221
|
+
include_patterns: list[str] = None,
|
|
222
|
+
exclude_patterns: list[str] = None,
|
|
223
|
+
depth: int = 0,
|
|
224
|
+
):
|
|
225
|
+
"""
|
|
226
|
+
Recursively downloads all objects from the given S3 ``bucket`` and ``prefix`` to a local directory path, using a thread pool.
|
|
227
|
+
|
|
228
|
+
:param client: An instance of ``S3Client``.
|
|
229
|
+
:param bucket: Bucket name.
|
|
230
|
+
:param prefix: Object keys prefix.
|
|
231
|
+
:param dir_path: Local directory path to save objects.
|
|
232
|
+
:param max_workers: Maximum number of worker threads.
|
|
233
|
+
:param include_patterns: Inclusive glob patterns applied to filenames.
|
|
234
|
+
:param exclude_patterns: Exclusive glob patterns applied to filenames.
|
|
235
|
+
:param depth: Maximum depth of subdirectories to include in the scan (``0`` for unlimited depth).
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
# We add trailing slash "/" to the prefix if it is absent
|
|
239
|
+
if not prefix.endswith("/"):
|
|
240
|
+
prefix = prefix + "/"
|
|
241
|
+
|
|
242
|
+
dir_path = make_path(dir_path)
|
|
243
|
+
|
|
244
|
+
objects = s3_list_files(client,
|
|
245
|
+
bucket,
|
|
246
|
+
prefix,
|
|
247
|
+
include_patterns=include_patterns,
|
|
248
|
+
exclude_patterns=exclude_patterns,
|
|
249
|
+
depth=depth)
|
|
250
|
+
|
|
251
|
+
def download_file(key: str):
|
|
252
|
+
file_path = dir_path / key[len(prefix):]
|
|
253
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
s3_cp_download(client, bucket, key, file_path)
|
|
255
|
+
|
|
256
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
257
|
+
futures = [executor.submit(download_file, obj.key) for obj in objects]
|
|
258
|
+
done_futures, not_done_futures = concurrent.futures.wait(futures,
|
|
259
|
+
return_when=concurrent.futures.FIRST_EXCEPTION)
|
|
260
|
+
if len(not_done_futures) > 0:
|
|
261
|
+
for future in not_done_futures:
|
|
262
|
+
future.cancel()
|
|
263
|
+
for future in done_futures:
|
|
264
|
+
exc = future.exception()
|
|
265
|
+
if exc is not None:
|
|
266
|
+
raise exc
|
|
267
|
+
if len(not_done_futures) > 0:
|
|
268
|
+
raise RuntimeError("download did not complete due to errors in some threads")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def s3_sync_upload(
|
|
272
|
+
client: S3Client,
|
|
273
|
+
dir_path: str | os.PathLike[str],
|
|
274
|
+
bucket: str,
|
|
275
|
+
prefix: str,
|
|
276
|
+
*,
|
|
277
|
+
max_workers: int = None,
|
|
278
|
+
include_patterns: list[str] = None,
|
|
279
|
+
exclude_patterns: list[str] = None,
|
|
280
|
+
depth: int = 0,
|
|
281
|
+
):
|
|
282
|
+
"""
|
|
283
|
+
Recursively uploads all files from a local directory to the given S3 ``bucket`` and ``prefix``, using a thread pool.
|
|
284
|
+
|
|
285
|
+
:param client: An instance of ``S3Client``.
|
|
286
|
+
:param dir_path: Local directory path to upload from.
|
|
287
|
+
:param bucket: Bucket name.
|
|
288
|
+
:param prefix: Object keys prefix for uploaded files.
|
|
289
|
+
:param max_workers: Maximum number of worker threads.
|
|
290
|
+
:param include_patterns: Inclusive glob patterns applied to filenames.
|
|
291
|
+
:param exclude_patterns: Exclusive glob patterns applied to filenames.
|
|
292
|
+
:param depth: Maximum depth of subdirectories to include in the scan (``0`` for unlimited depth).
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
# We add trailing slash "/" to the prefix if it is absent
|
|
296
|
+
if not prefix.endswith("/"):
|
|
297
|
+
prefix = prefix + "/"
|
|
298
|
+
|
|
299
|
+
dir_path = make_path(dir_path)
|
|
300
|
+
|
|
301
|
+
file_paths = scan_files(dir_path,
|
|
302
|
+
include_patterns=include_patterns,
|
|
303
|
+
exclude_patterns=exclude_patterns,
|
|
304
|
+
depth=depth)
|
|
305
|
+
|
|
306
|
+
def upload_file(file_path: pathlib.Path):
|
|
307
|
+
s3_cp_upload(client, file_path, bucket, prefix + str(file_path.relative_to(dir_path)))
|
|
308
|
+
|
|
309
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
310
|
+
futures = [executor.submit(upload_file, file_path) for file_path in file_paths]
|
|
311
|
+
done_futures, not_done_futures = concurrent.futures.wait(futures,
|
|
312
|
+
return_when=concurrent.futures.FIRST_EXCEPTION)
|
|
313
|
+
if len(not_done_futures) > 0:
|
|
314
|
+
for future in not_done_futures:
|
|
315
|
+
future.cancel()
|
|
316
|
+
for future in done_futures:
|
|
317
|
+
exc = future.exception()
|
|
318
|
+
if exc is not None:
|
|
319
|
+
raise exc
|
|
320
|
+
if len(not_done_futures) > 0:
|
|
321
|
+
raise RuntimeError("upload did not complete due to errors in some threads")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def s3_pull_text(client: S3Client, bucket: str, key: str, encoding: str = None) -> str:
|
|
325
|
+
"""
|
|
326
|
+
Downloads and decodes text content stored as an object in the given S3 ``bucket`` and ``key``.
|
|
327
|
+
|
|
328
|
+
:param client: An instance of ``S3Client``.
|
|
329
|
+
:param bucket: Bucket name.
|
|
330
|
+
:param key: Object key storing the text.
|
|
331
|
+
:param encoding: String encoding to use (defaults to UTF-8).
|
|
332
|
+
:return: The decoded text content.
|
|
333
|
+
"""
|
|
334
|
+
with tempfile.TemporaryFile() as fp:
|
|
335
|
+
client.download_fileobj(bucket, key, fp)
|
|
336
|
+
fp.seek(0)
|
|
337
|
+
return fp.read().decode(encoding or "utf-8")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def s3_push_text(client: S3Client, text: str, bucket: str, key: str, encoding: str = None):
|
|
341
|
+
"""
|
|
342
|
+
Uploads the given text as an object to the specified S3 ``bucket`` and ``key``.
|
|
343
|
+
|
|
344
|
+
:param client: An instance of ``S3Client``.
|
|
345
|
+
:param text: Text content to upload.
|
|
346
|
+
:param bucket: Bucket name.
|
|
347
|
+
:param key: Object key to store the text.
|
|
348
|
+
:param encoding: String encoding to use (defaults to UTF-8).
|
|
349
|
+
"""
|
|
350
|
+
with tempfile.TemporaryFile() as fp:
|
|
351
|
+
fp.write(text.encode(encoding or "utf-8"))
|
|
352
|
+
fp.seek(0)
|
|
353
|
+
client.upload_fileobj(fp, bucket, key)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class S3ClientProgressProxy(object):
|
|
357
|
+
def __init__(self, client: S3Client, progress: Progress):
|
|
358
|
+
self.client = client
|
|
359
|
+
self.progress = progress
|
|
360
|
+
|
|
361
|
+
@contextlib.contextmanager
|
|
362
|
+
def make_transfer_callback(self, key: str, bytes_total: int, direction: Literal["download", "upload"]):
|
|
363
|
+
task_id = self.progress.add_task(direction, total=bytes_total, key=key)
|
|
364
|
+
try:
|
|
365
|
+
yield lambda bytes_sent: self.progress.update(task_id, advance=bytes_sent)
|
|
366
|
+
finally:
|
|
367
|
+
self.progress.remove_task(task_id)
|
|
368
|
+
|
|
369
|
+
def __getattr__(self, name):
|
|
370
|
+
return getattr(self.client, name)
|
|
371
|
+
|
|
372
|
+
def download_file(
|
|
373
|
+
self,
|
|
374
|
+
Bucket,
|
|
375
|
+
Key,
|
|
376
|
+
Filename,
|
|
377
|
+
ExtraArgs=None,
|
|
378
|
+
Callback=None,
|
|
379
|
+
Config=None,
|
|
380
|
+
):
|
|
381
|
+
object_meta = s3_head_object(self.client, Bucket, Key)
|
|
382
|
+
with (
|
|
383
|
+
contextlib.nullcontext(Callback) if Callback is not None
|
|
384
|
+
else self.make_transfer_callback(Key, object_meta.size, "download")
|
|
385
|
+
) as callback:
|
|
386
|
+
return self.client.download_file(Bucket,
|
|
387
|
+
Key,
|
|
388
|
+
Filename,
|
|
389
|
+
ExtraArgs=ExtraArgs,
|
|
390
|
+
Callback=callback,
|
|
391
|
+
Config=Config)
|
|
392
|
+
|
|
393
|
+
def download_fileobj(
|
|
394
|
+
self,
|
|
395
|
+
Bucket,
|
|
396
|
+
Key,
|
|
397
|
+
Fileobj,
|
|
398
|
+
ExtraArgs=None,
|
|
399
|
+
Callback=None,
|
|
400
|
+
Config=None,
|
|
401
|
+
):
|
|
402
|
+
object_meta = s3_head_object(self.client, Bucket, Key)
|
|
403
|
+
with (
|
|
404
|
+
contextlib.nullcontext(Callback) if Callback is not None
|
|
405
|
+
else self.make_transfer_callback(Key, object_meta.size, "download")
|
|
406
|
+
) as callback:
|
|
407
|
+
return self.client.download_fileobj(Bucket,
|
|
408
|
+
Key,
|
|
409
|
+
Fileobj,
|
|
410
|
+
ExtraArgs=ExtraArgs,
|
|
411
|
+
Callback=callback,
|
|
412
|
+
Config=Config)
|
|
413
|
+
|
|
414
|
+
def upload_file(
|
|
415
|
+
self,
|
|
416
|
+
Filename,
|
|
417
|
+
Bucket,
|
|
418
|
+
Key,
|
|
419
|
+
ExtraArgs=None,
|
|
420
|
+
Callback=None,
|
|
421
|
+
Config=None,
|
|
422
|
+
):
|
|
423
|
+
bytes_total = make_path(Filename).stat().st_size
|
|
424
|
+
with (
|
|
425
|
+
contextlib.nullcontext(Callback) if Callback is not None
|
|
426
|
+
else self.make_transfer_callback(Key, bytes_total, "upload")
|
|
427
|
+
) as callback:
|
|
428
|
+
return self.client.upload_file(Filename,
|
|
429
|
+
Bucket,
|
|
430
|
+
Key,
|
|
431
|
+
ExtraArgs=ExtraArgs,
|
|
432
|
+
Callback=callback,
|
|
433
|
+
Config=Config)
|
|
434
|
+
|
|
435
|
+
def upload_fileobj(
|
|
436
|
+
self,
|
|
437
|
+
Fileobj,
|
|
438
|
+
Bucket,
|
|
439
|
+
Key,
|
|
440
|
+
ExtraArgs=None,
|
|
441
|
+
Callback=None,
|
|
442
|
+
Config=None,
|
|
443
|
+
):
|
|
444
|
+
current_pos = Fileobj.tell()
|
|
445
|
+
Fileobj.seek(0, os.SEEK_END)
|
|
446
|
+
bytes_total = Fileobj.tell() - current_pos
|
|
447
|
+
Fileobj.seek(current_pos)
|
|
448
|
+
with (
|
|
449
|
+
contextlib.nullcontext(Callback) if Callback is not None
|
|
450
|
+
else self.make_transfer_callback(Key, bytes_total, "upload")
|
|
451
|
+
) as callback:
|
|
452
|
+
return self.client.upload_fileobj(Fileobj,
|
|
453
|
+
Bucket,
|
|
454
|
+
Key,
|
|
455
|
+
ExtraArgs=ExtraArgs,
|
|
456
|
+
Callback=callback,
|
|
457
|
+
Config=Config)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if typing.TYPE_CHECKING:
|
|
461
|
+
def s3_make_progressed_client(
|
|
462
|
+
access_key_id: str = None,
|
|
463
|
+
secret_access_key: str = None,
|
|
464
|
+
region_name: str = None,
|
|
465
|
+
endpoint_url: str = None,
|
|
466
|
+
) -> contextlib.AbstractContextManager[S3Client]: ...
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@contextlib.contextmanager
|
|
470
|
+
def s3_make_progressed_client(
|
|
471
|
+
access_key_id: str = None,
|
|
472
|
+
secret_access_key: str = None,
|
|
473
|
+
region_name: str = None,
|
|
474
|
+
endpoint_url: str = None,
|
|
475
|
+
) -> Generator[S3ClientProgressProxy, None, None]:
|
|
476
|
+
"""
|
|
477
|
+
Creates an S3 client with progress reporting as a context manager.
|
|
478
|
+
|
|
479
|
+
:param access_key_id: AWS access key ID.
|
|
480
|
+
:param secret_access_key: AWS secret access key.
|
|
481
|
+
:param region_name: AWS service region name.
|
|
482
|
+
:param endpoint_url: AWS service endpoint URL.
|
|
483
|
+
:return: An instance of ``S3ClientProgressProxy`` that wraps an ``S3Client`` with progress reporting.
|
|
484
|
+
"""
|
|
485
|
+
with Progress(
|
|
486
|
+
TextColumn("[blue]{task.fields[key]}"),
|
|
487
|
+
BarColumn(),
|
|
488
|
+
DownloadColumn(),
|
|
489
|
+
TransferSpeedColumn(),
|
|
490
|
+
) as progress:
|
|
491
|
+
with s3_make_client(access_key_id, secret_access_key, region_name, endpoint_url) as client:
|
|
492
|
+
yield S3ClientProgressProxy(client, progress)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def s3_options_from_s3_client(client: S3Client) -> JsonObject:
|
|
496
|
+
"""
|
|
497
|
+
Extracts S3 connection options from an existing S3Client instance for use with ``fsspec``.
|
|
498
|
+
|
|
499
|
+
:param client: An instance of ``S3Client``.
|
|
500
|
+
:return: A dictionary of S3 connection options.
|
|
501
|
+
"""
|
|
502
|
+
if client is None:
|
|
503
|
+
return {}
|
|
504
|
+
|
|
505
|
+
s3_options: JsonObject = {}
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
# Extract credentials from the client
|
|
509
|
+
# First try the public meta attribute, which is injected by our `s3_make_client` function, if available
|
|
510
|
+
credentials = client.meta.credentials
|
|
511
|
+
except AttributeError:
|
|
512
|
+
try:
|
|
513
|
+
# Fallback to private attribute (may not be stable across boto3 versions)
|
|
514
|
+
credentials = client._request_signer._credentials
|
|
515
|
+
except AttributeError:
|
|
516
|
+
# Unable to extract credentials
|
|
517
|
+
raise AttributeError("cannot extract credentials from the provided S3Client instance")
|
|
518
|
+
if credentials is not None:
|
|
519
|
+
if credentials.access_key:
|
|
520
|
+
s3_options["key"] = credentials.access_key
|
|
521
|
+
if credentials.secret_key:
|
|
522
|
+
s3_options["secret"] = credentials.secret_key
|
|
523
|
+
if credentials.token:
|
|
524
|
+
s3_options["token"] = credentials.token
|
|
525
|
+
|
|
526
|
+
client_kwargs = {}
|
|
527
|
+
if client.meta.region_name:
|
|
528
|
+
client_kwargs["region_name"] = client.meta.region_name
|
|
529
|
+
if client.meta.endpoint_url:
|
|
530
|
+
client_kwargs["endpoint_url"] = client.meta.endpoint_url
|
|
531
|
+
|
|
532
|
+
if client_kwargs:
|
|
533
|
+
s3_options["client_kwargs"] = client_kwargs
|
|
534
|
+
|
|
535
|
+
return s3_options
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
type ArchiveMemberTree = dict[str, tuple[zipfile.ZipInfo, ArchiveMemberTree | None]]
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def s3_archive_member_tree(
|
|
542
|
+
client: S3Client,
|
|
543
|
+
bucket: str,
|
|
544
|
+
key: str,
|
|
545
|
+
) -> ArchiveMemberTree:
|
|
546
|
+
"""
|
|
547
|
+
Builds a tree structure of members in a ZIP archive stored in S3 for efficient lookup.
|
|
548
|
+
Directories have ZipInfo and a nested dict; files have ZipInfo and None.
|
|
549
|
+
Directory members are recognized by names ending with a trailing slash ("/").
|
|
550
|
+
|
|
551
|
+
Example:
|
|
552
|
+
|
|
553
|
+
>>> {
|
|
554
|
+
... "dir1/": (ZipInfo, {
|
|
555
|
+
... "file1.txt": (ZipInfo, None),
|
|
556
|
+
... "subdir/": (ZipInfo, {
|
|
557
|
+
... "file2.txt": (ZipInfo, None)
|
|
558
|
+
... })
|
|
559
|
+
... }),
|
|
560
|
+
... "file3.txt": (ZipInfo, None)
|
|
561
|
+
... }
|
|
562
|
+
|
|
563
|
+
:param client: An instance of ``S3Client``.
|
|
564
|
+
:param bucket: Bucket name.
|
|
565
|
+
:param key: Object key of the ZIP archive.
|
|
566
|
+
:return: A tree structure of members in the ZIP archive.
|
|
567
|
+
"""
|
|
568
|
+
s3_options = s3_options_from_s3_client(client)
|
|
569
|
+
|
|
570
|
+
archive_url = f"s3://{bucket}/{key}"
|
|
571
|
+
|
|
572
|
+
with fsspec.open(archive_url, "rb", s3=s3_options) as s3_fh, zipfile.ZipFile(s3_fh) as archive:
|
|
573
|
+
member_zip_infos = archive.infolist()
|
|
574
|
+
|
|
575
|
+
root_member_tree: ArchiveMemberTree = {}
|
|
576
|
+
|
|
577
|
+
def build_member_tree(info: zipfile.ZipInfo):
|
|
578
|
+
*parts, last_part = info.filename.rstrip("/").split("/")
|
|
579
|
+
current_member_tree = root_member_tree
|
|
580
|
+
for part in parts:
|
|
581
|
+
_, current_member_tree = current_member_tree.setdefault(part + "/", (None, {}))
|
|
582
|
+
if info.is_dir():
|
|
583
|
+
current_member_tree[last_part + "/"] = info, {}
|
|
584
|
+
else:
|
|
585
|
+
current_member_tree[last_part] = info, None
|
|
586
|
+
|
|
587
|
+
# Sort by filename to ensure directories are created before their contents
|
|
588
|
+
for info in sorted(member_zip_infos, key=lambda x: x.filename):
|
|
589
|
+
build_member_tree(info)
|
|
590
|
+
|
|
591
|
+
return root_member_tree
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def s3_archive_list_files(
|
|
595
|
+
client: S3Client,
|
|
596
|
+
bucket: str,
|
|
597
|
+
key: str,
|
|
598
|
+
members: list[str] | None = None,
|
|
599
|
+
) -> tuple[int, list[zipfile.ZipInfo], list[str]]:
|
|
600
|
+
"""
|
|
601
|
+
Lists members of a ZIP archive stored in S3, optionally filtering by specific member names. When filtering,
|
|
602
|
+
if a member is a directory, it must end with a trailing slash ("/") to be recognized as such, and all files
|
|
603
|
+
under that directory will be included in the results.
|
|
604
|
+
|
|
605
|
+
Example usage:
|
|
606
|
+
|
|
607
|
+
>>> archive_size, member_zip_infos, missed_members = s3_archive_list_files(
|
|
608
|
+
... client, bucket, key, members=["file1.txt", "dir1/"])
|
|
609
|
+
>>> for info in member_zip_infos:
|
|
610
|
+
... print(info.filename, info.file_size)
|
|
611
|
+
>>> if missed_members:
|
|
612
|
+
... print("Members not found:", missed_members)
|
|
613
|
+
|
|
614
|
+
:param client: An instance of ``S3Client``.
|
|
615
|
+
:param bucket: Bucket name.
|
|
616
|
+
:param key: Object key of the ZIP archive.
|
|
617
|
+
:param members: Optional list of member names to filter; if ``None``, all members are returned.
|
|
618
|
+
:return: A tuple containing:
|
|
619
|
+
- The size of the archive in bytes.
|
|
620
|
+
- A list of ``zipfile.ZipInfo`` objects for the included members.
|
|
621
|
+
- A list of member names that were not found in the archive.
|
|
622
|
+
"""
|
|
623
|
+
s3_options = s3_options_from_s3_client(client)
|
|
624
|
+
|
|
625
|
+
archive_url = f"s3://{bucket}/{key}"
|
|
626
|
+
|
|
627
|
+
fs = fsspec.filesystem("s3", **s3_options)
|
|
628
|
+
archive_size = fs.size(archive_url)
|
|
629
|
+
|
|
630
|
+
root_member_tree = s3_archive_member_tree(client, bucket, key)
|
|
631
|
+
|
|
632
|
+
def search_members_tree(member: str) -> tuple[zipfile.ZipInfo | None, dict | None]:
|
|
633
|
+
*parts, last_part = member.rstrip("/").split("/")
|
|
634
|
+
current_member_tree = root_member_tree
|
|
635
|
+
for part in parts:
|
|
636
|
+
_, current_member_tree = current_member_tree.get(part + "/", (None, None))
|
|
637
|
+
if current_member_tree is None:
|
|
638
|
+
return None, None
|
|
639
|
+
if member.endswith("/"): # Directory member recognized by trailing slash
|
|
640
|
+
return current_member_tree.get(last_part + "/", (None, None))
|
|
641
|
+
else:
|
|
642
|
+
return current_member_tree.get(last_part, (None, None))
|
|
643
|
+
|
|
644
|
+
def collect_member_zip_infos(tree: ArchiveMemberTree) -> Generator[zipfile.ZipInfo, None, None]:
|
|
645
|
+
for member_zip_info, member_tree in tree.values():
|
|
646
|
+
if member_zip_info is None:
|
|
647
|
+
continue
|
|
648
|
+
if member_zip_info.is_dir():
|
|
649
|
+
yield from collect_member_zip_infos(member_tree)
|
|
650
|
+
else:
|
|
651
|
+
yield member_zip_info
|
|
652
|
+
|
|
653
|
+
if members is None:
|
|
654
|
+
return archive_size, list(collect_member_zip_infos(root_member_tree)), []
|
|
655
|
+
|
|
656
|
+
included_member_zip_infos = []
|
|
657
|
+
missed_members = []
|
|
658
|
+
|
|
659
|
+
for member in members:
|
|
660
|
+
member_zip_info, member_tree = search_members_tree(member)
|
|
661
|
+
if member_zip_info is None:
|
|
662
|
+
missed_members.append(member)
|
|
663
|
+
continue
|
|
664
|
+
if not member_zip_info.is_dir():
|
|
665
|
+
included_member_zip_infos.append(member_zip_info)
|
|
666
|
+
else:
|
|
667
|
+
included_member_zip_infos.extend(collect_member_zip_infos(member_tree or {}))
|
|
668
|
+
|
|
669
|
+
return archive_size, included_member_zip_infos, missed_members
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
if typing.TYPE_CHECKING:
|
|
673
|
+
def s3_archive_open_member(
|
|
674
|
+
client: S3Client,
|
|
675
|
+
bucket: str,
|
|
676
|
+
key: str,
|
|
677
|
+
member: str,
|
|
678
|
+
mode: Literal["r", "rb"] = "r",
|
|
679
|
+
) -> contextlib.AbstractContextManager[typing.IO]: ...
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@contextlib.contextmanager
|
|
683
|
+
def s3_archive_open_member(
|
|
684
|
+
client: S3Client,
|
|
685
|
+
bucket: str,
|
|
686
|
+
key: str,
|
|
687
|
+
member: str,
|
|
688
|
+
mode: Literal["r", "rb"] = "r",
|
|
689
|
+
) -> Generator[typing.IO, None, None]:
|
|
690
|
+
"""
|
|
691
|
+
Opens a specific member file from a ZIP archive stored in S3.
|
|
692
|
+
|
|
693
|
+
:param client: An instance of ``S3Client``.
|
|
694
|
+
:param bucket: Bucket name.
|
|
695
|
+
:param key: Object key of the ZIP archive.
|
|
696
|
+
:param member: The member file name to open from the archive.
|
|
697
|
+
:param mode: File mode for opening the member ("r" for text, "rb" for binary).
|
|
698
|
+
|
|
699
|
+
:return: A file-like object for the specified member within the ZIP archive.
|
|
700
|
+
"""
|
|
701
|
+
if mode not in ("r", "rb"):
|
|
702
|
+
raise ValueError("mode must be either 'r' or 'rb'")
|
|
703
|
+
|
|
704
|
+
s3_options = s3_options_from_s3_client(client)
|
|
705
|
+
|
|
706
|
+
with fsspec.open(f"zip://{member}::s3://{bucket}/{key}", mode, s3=s3_options) as s3_fh:
|
|
707
|
+
yield s3_fh
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
ZIP_CENTRAL_DIR_ESTIMATED_SIZE = 64 * 1024
|
|
711
|
+
ZIP_INFO_HDR_MIN_SIZE = 30
|
|
712
|
+
ZIP_INFO_HDR_ESTIMATED_SIZE = 128
|
|
713
|
+
ZIP_INFO_HDR_FN_LEN_OFFSET = 26
|
|
714
|
+
ZIP_INFO_HDR_EX_LEN_OFFSET = 28
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@dataclasses.dataclass(frozen=True)
|
|
718
|
+
class ArchiveMemberChunk(object):
|
|
719
|
+
name: str
|
|
720
|
+
header_offset: int
|
|
721
|
+
compress_size: int
|
|
722
|
+
compress_type: int
|
|
723
|
+
header_overhead: int = ZIP_INFO_HDR_ESTIMATED_SIZE
|
|
724
|
+
|
|
725
|
+
@property
|
|
726
|
+
def begin(self) -> int:
|
|
727
|
+
return self.header_offset
|
|
728
|
+
|
|
729
|
+
@property
|
|
730
|
+
def end(self) -> int:
|
|
731
|
+
return self.header_offset + self.header_overhead + self.compress_size
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def s3_archive_use_ranged_requests(
|
|
735
|
+
threshold: float = 0.5,
|
|
736
|
+
central_directory_overhead: int = 64 * ZIP_CENTRAL_DIR_ESTIMATED_SIZE,
|
|
737
|
+
zip_info_header_overhead: int = ZIP_INFO_HDR_ESTIMATED_SIZE,
|
|
738
|
+
) -> Callable[[int, list[zipfile.ZipInfo]], bool]:
|
|
739
|
+
"""
|
|
740
|
+
Decide whether to use ranged requests for accessing members of a ZIP archive in S3
|
|
741
|
+
based on estimated transfer size ratio.
|
|
742
|
+
|
|
743
|
+
:param threshold: If (estimated ranged transfer bytes / archive bytes) <= threshold, use ranged per-member access;
|
|
744
|
+
otherwise download the whole archive.
|
|
745
|
+
:param central_directory_overhead: Estimated overhead size for the central directory in bytes.
|
|
746
|
+
:param zip_info_header_overhead: Estimated overhead size for each member's ``ZipInfo`` header in bytes.
|
|
747
|
+
:return: A callable that takes (``archive_size``, ``member_zip_infos``) and returns a boolean
|
|
748
|
+
indicating whether to use ranged requests.
|
|
749
|
+
"""
|
|
750
|
+
|
|
751
|
+
if zip_info_header_overhead < ZIP_INFO_HDR_MIN_SIZE:
|
|
752
|
+
raise ValueError(f"zip_info_header_overhead must be at least {ZIP_INFO_HDR_MIN_SIZE} bytes")
|
|
753
|
+
|
|
754
|
+
def use_ranged_requests(
|
|
755
|
+
archive_size: int,
|
|
756
|
+
member_zip_infos: list[zipfile.ZipInfo],
|
|
757
|
+
) -> bool:
|
|
758
|
+
estimated_ranged_total_size = central_directory_overhead + sum(info.compress_size + zip_info_header_overhead
|
|
759
|
+
for info in member_zip_infos)
|
|
760
|
+
|
|
761
|
+
# Avoid division by zero; prefer ranged if archive size is zero (degenerate case)
|
|
762
|
+
return (estimated_ranged_total_size / archive_size) <= threshold if archive_size > 0 else True
|
|
763
|
+
|
|
764
|
+
return use_ranged_requests
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def s3_archive_use_chunked_reads(
|
|
768
|
+
zip_info_header_overhead: int = ZIP_INFO_HDR_ESTIMATED_SIZE,
|
|
769
|
+
) -> Callable[[zipfile.ZipInfo], ArchiveMemberChunk]:
|
|
770
|
+
"""
|
|
771
|
+
Map each ``ZipInfo`` to an ``ArchiveMemberChunk`` for grouping adjacent members into single ranged reads.
|
|
772
|
+
|
|
773
|
+
:param zip_info_header_overhead: Estimated overhead size for each member's ``ZipInfo`` header in bytes.
|
|
774
|
+
:return: A callable that takes a ``ZipInfo`` and returns an ``ArchiveMemberChunk``.
|
|
775
|
+
"""
|
|
776
|
+
|
|
777
|
+
if zip_info_header_overhead < ZIP_INFO_HDR_MIN_SIZE:
|
|
778
|
+
raise ValueError(f"zip_info_header_overhead must be at least {ZIP_INFO_HDR_MIN_SIZE} bytes")
|
|
779
|
+
|
|
780
|
+
def use_chunked_reads(
|
|
781
|
+
zip_info: zipfile.ZipInfo,
|
|
782
|
+
) -> ArchiveMemberChunk:
|
|
783
|
+
return ArchiveMemberChunk(zip_info.filename,
|
|
784
|
+
zip_info.header_offset,
|
|
785
|
+
zip_info.compress_size,
|
|
786
|
+
zip_info.compress_type,
|
|
787
|
+
header_overhead=zip_info_header_overhead)
|
|
788
|
+
|
|
789
|
+
return use_chunked_reads
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def s3_archive_open_members(
|
|
793
|
+
client: S3Client,
|
|
794
|
+
bucket: str,
|
|
795
|
+
key: str,
|
|
796
|
+
members: list[str] | None = None,
|
|
797
|
+
mode: Literal["r", "rb"] = "r",
|
|
798
|
+
*,
|
|
799
|
+
use_ranged_requests: bool | Callable[[int, list[zipfile.ZipInfo]], bool] = True,
|
|
800
|
+
use_chunked_reads: bool | Callable[[zipfile.ZipInfo], ArchiveMemberChunk] = False,
|
|
801
|
+
) -> Generator[tuple[str, Callable[[], typing.IO]], None, None]:
|
|
802
|
+
"""
|
|
803
|
+
Choose the best transfer strategy (ranged requests per-member vs full archive transfer)
|
|
804
|
+
based on estimated transfer size ratio and yield callables to open each requested member.
|
|
805
|
+
|
|
806
|
+
The callables return file-like objects for each member when invoked. Due to lazy evaluation,
|
|
807
|
+
the actual data transfer occurs when the member is opened by the corresponding callable.
|
|
808
|
+
Thus, the callables must be used immediately after being yielded, to avoid issues with temporary
|
|
809
|
+
file lifetimes.
|
|
810
|
+
|
|
811
|
+
Example usage:
|
|
812
|
+
|
|
813
|
+
>>> for member, opener in s3_archive_open_members(client, bucket, key, members):
|
|
814
|
+
... with opener() as fh:
|
|
815
|
+
... data = fh.read()
|
|
816
|
+
|
|
817
|
+
Incorrect usage that may lead to errors due to temporary file cleanup:
|
|
818
|
+
|
|
819
|
+
>>> openers = []
|
|
820
|
+
>>> for member, opener in s3_archive_open_members(client, bucket, key, members):
|
|
821
|
+
... openers.append((member, opener))
|
|
822
|
+
>>> for member, opener in openers:
|
|
823
|
+
... with opener() as fh: # May fail if temporary files have been cleaned up
|
|
824
|
+
... data = fh.read()
|
|
825
|
+
|
|
826
|
+
:param client: An instance of ``S3Client``.
|
|
827
|
+
:param bucket: Bucket name.
|
|
828
|
+
:param key: Object key of the ZIP archive.
|
|
829
|
+
:param members: List of member names to stream.
|
|
830
|
+
:param mode: File mode for opening members ("r" for text, "rb" for binary).
|
|
831
|
+
:param use_ranged_requests: If ``True``, always use ranged requests to access the archive; if callable, use it as
|
|
832
|
+
custom logic to decide based on the archive size and member infos; if ``False``, always
|
|
833
|
+
download the whole archive.
|
|
834
|
+
:param use_chunked_reads: If ``True``, group adjacent members into single ranged reads using default ``ZipInfo``
|
|
835
|
+
to ``ArchiveMemberChunk`` mapping; if callable, use it as custom mapping from
|
|
836
|
+
``ZipInfo`` to ``ArchiveMemberChunk`` for grouping adjacent members; if ``False``,
|
|
837
|
+
read each member with individual ranged requests. If ``use_ranged_requests`` is ``False``,
|
|
838
|
+
this parameter is ignored.
|
|
839
|
+
|
|
840
|
+
:return: An iterable of callables that return file-like objects for each requested member.
|
|
841
|
+
"""
|
|
842
|
+
if mode not in ("r", "rb"):
|
|
843
|
+
raise ValueError("mode must be either 'r' or 'rb'")
|
|
844
|
+
|
|
845
|
+
s3_options = s3_options_from_s3_client(client)
|
|
846
|
+
|
|
847
|
+
archive_size, member_zip_infos, missed_members = s3_archive_list_files(client, bucket, key, members)
|
|
848
|
+
if missed_members:
|
|
849
|
+
raise FileNotFoundError(f"archive members not found: {', '.join(missed_members)}")
|
|
850
|
+
|
|
851
|
+
if callable(use_ranged_requests):
|
|
852
|
+
use_ranged_requests = use_ranged_requests(archive_size, member_zip_infos)
|
|
853
|
+
|
|
854
|
+
if use_ranged_requests and not use_chunked_reads:
|
|
855
|
+
for info in member_zip_infos:
|
|
856
|
+
opener = functools.partial(s3_archive_open_member, client, bucket, key, info.filename, mode)
|
|
857
|
+
yield info.filename, opener
|
|
858
|
+
return
|
|
859
|
+
|
|
860
|
+
archive_url = f"s3://{bucket}/{key}"
|
|
861
|
+
|
|
862
|
+
if use_ranged_requests and use_chunked_reads:
|
|
863
|
+
|
|
864
|
+
fn_len_slice = lambda index: slice(index + ZIP_INFO_HDR_FN_LEN_OFFSET, index + ZIP_INFO_HDR_FN_LEN_OFFSET + 2)
|
|
865
|
+
ex_len_slice = lambda index: slice(index + ZIP_INFO_HDR_EX_LEN_OFFSET, index + ZIP_INFO_HDR_EX_LEN_OFFSET + 2)
|
|
866
|
+
|
|
867
|
+
# Open archive once to read central directory and gather ZipInfo for requested members.
|
|
868
|
+
# We will group adjacent members (by local header offsets) and issue one ranged read per group,
|
|
869
|
+
# then extract each member from the group's bytes to avoid many small ranged requests.
|
|
870
|
+
with fsspec.open(archive_url, "rb", s3=s3_options) as s3_fh, zipfile.ZipFile(s3_fh) as archive:
|
|
871
|
+
if callable(use_chunked_reads):
|
|
872
|
+
chunks = [use_chunked_reads(info) for info in member_zip_infos]
|
|
873
|
+
else:
|
|
874
|
+
chunks = [ArchiveMemberChunk(info.filename, info.header_offset, info.compress_size, info.compress_type)
|
|
875
|
+
for info in member_zip_infos]
|
|
876
|
+
|
|
877
|
+
chunks_groups = chunk_between(sorted(chunks, key=lambda x: x.begin),
|
|
878
|
+
chunk_func=lambda x, y: y.begin > x.end)
|
|
879
|
+
|
|
880
|
+
# For each group, create openers for members inside the group.
|
|
881
|
+
for group in chunks_groups:
|
|
882
|
+
group_offset = head(group).header_offset
|
|
883
|
+
group_size = last(group).end - group_offset
|
|
884
|
+
|
|
885
|
+
# Read group's bytes from remote (single ranged read)
|
|
886
|
+
s3_fh.seek(group_offset)
|
|
887
|
+
group_bytes = s3_fh.read(group_size)
|
|
888
|
+
|
|
889
|
+
def make_opener(chunk: ArchiveMemberChunk) -> Callable[[], typing.IO]:
|
|
890
|
+
|
|
891
|
+
def opener() -> typing.IO:
|
|
892
|
+
index = chunk.header_offset - group_offset
|
|
893
|
+
|
|
894
|
+
if index + ZIP_INFO_HDR_MIN_SIZE > len(group_bytes):
|
|
895
|
+
raise IOError("unexpected short read of member header")
|
|
896
|
+
|
|
897
|
+
fn_len = int.from_bytes(group_bytes[fn_len_slice(index)], "little")
|
|
898
|
+
ex_len = int.from_bytes(group_bytes[ex_len_slice(index)], "little")
|
|
899
|
+
|
|
900
|
+
raw_data_begin = index + ZIP_INFO_HDR_MIN_SIZE + fn_len + ex_len
|
|
901
|
+
raw_data_end = raw_data_begin + chunk.compress_size
|
|
902
|
+
|
|
903
|
+
if raw_data_end > len(group_bytes):
|
|
904
|
+
raise IOError("unexpected short read of compressed data")
|
|
905
|
+
|
|
906
|
+
raw_data = group_bytes[raw_data_begin:raw_data_end]
|
|
907
|
+
|
|
908
|
+
if chunk.compress_type == zipfile.ZIP_STORED:
|
|
909
|
+
pass
|
|
910
|
+
elif chunk.compress_type == zipfile.ZIP_DEFLATED:
|
|
911
|
+
raw_data = zlib.decompress(raw_data, -zlib.MAX_WBITS)
|
|
912
|
+
else:
|
|
913
|
+
raise NotImplementedError(f"unsupported compression '{chunk.compress_type}'")
|
|
914
|
+
|
|
915
|
+
if mode == "r":
|
|
916
|
+
return io.TextIOWrapper(io.BytesIO(raw_data), encoding="utf-8")
|
|
917
|
+
else:
|
|
918
|
+
return io.BytesIO(raw_data)
|
|
919
|
+
|
|
920
|
+
return opener
|
|
921
|
+
|
|
922
|
+
yield from ((chunk.name, make_opener(chunk)) for chunk in group)
|
|
923
|
+
return
|
|
924
|
+
|
|
925
|
+
# Download full archive once and serve members from it (read member bytes into memory)
|
|
926
|
+
with fsspec.open(archive_url, "rb", s3=s3_options) as s3_fh, tempfile.TemporaryFile() as temp_fh:
|
|
927
|
+
shutil.copyfileobj(s3_fh, temp_fh)
|
|
928
|
+
temp_fh.seek(0)
|
|
929
|
+
with zipfile.ZipFile(temp_fh) as archive:
|
|
930
|
+
for info in member_zip_infos:
|
|
931
|
+
try:
|
|
932
|
+
if mode == "r":
|
|
933
|
+
opener = lambda fn=info.filename: io.TextIOWrapper(archive.open(fn), encoding="utf-8")
|
|
934
|
+
else:
|
|
935
|
+
opener = lambda fn=info.filename: archive.open(fn)
|
|
936
|
+
yield info.filename, opener
|
|
937
|
+
except KeyError as e:
|
|
938
|
+
# Shouldn't happen due to earlier check, but guard anyway
|
|
939
|
+
raise FileNotFoundError(info.filename) from e
|