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.
@@ -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