cloudbridge 4.2.0__tar.gz → 4.3.0__tar.gz
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.
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/PKG-INFO +5 -2
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/__init__.py +1 -1
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/resources.py +92 -8
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/resources.py +63 -17
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/services.py +27 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/resources.py +28 -8
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/services.py +10 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/azure_client.py +12 -2
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/resources.py +18 -4
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/services.py +8 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/resources.py +15 -4
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/services.py +11 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/resources.py +23 -5
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/services.py +10 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/PKG-INFO +5 -2
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/SOURCES.txt +1 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/pyproject.toml +7 -2
- cloudbridge-4.3.0/tests/test_download_driver.py +236 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_multipart_driver.py +2 -2
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_object_store_service.py +160 -8
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/LICENSE +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/README.rst +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/__init__.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/helpers.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/middleware.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/provider.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/services.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/subservices.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/factory.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/__init__.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/exceptions.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/provider.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/subservices.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/__init__.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/__init__.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/helpers.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/provider.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/subservices.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/__init__.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/helpers.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/provider.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/subservices.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/__init__.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/helpers.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/provider.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/subservices.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/mock/__init__.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/mock/provider.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/__init__.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/helpers.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/provider.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/subservices.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/py.typed +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/dependency_links.txt +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/requires.txt +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/top_level.txt +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/setup.cfg +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_base_helpers.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_block_store_service.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_cloud_factory.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_cloud_helpers.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_compute_service.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_dns_service.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_image_service.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_interface.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_middleware_system.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_network_service.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_object_life_cycle.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_region_service.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_security_service.py +0 -0
- {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_vm_types_service.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudbridge
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.3.0
|
|
4
4
|
Summary: A simple layer of abstraction over multiple cloud providers.
|
|
5
5
|
Author-email: Galaxy and GVL Projects <help@genome.edu.au>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,10 +15,13 @@ Classifier: Intended Audience :: System Administrators
|
|
|
15
15
|
Classifier: Operating System :: OS Independent
|
|
16
16
|
Classifier: Programming Language :: Python
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
22
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
20
23
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
-
Requires-Python: >=3.
|
|
24
|
+
Requires-Python: >=3.10
|
|
22
25
|
Description-Content-Type: text/x-rst
|
|
23
26
|
License-File: LICENSE
|
|
24
27
|
Requires-Dist: tenacity>=6.0
|
|
@@ -59,7 +59,7 @@ from cloudbridge.interfaces.resources import Snapshot
|
|
|
59
59
|
from cloudbridge.interfaces.resources import SnapshotState
|
|
60
60
|
from cloudbridge.interfaces.resources import Subnet
|
|
61
61
|
from cloudbridge.interfaces.resources import SubnetState
|
|
62
|
-
from cloudbridge.interfaces.resources import
|
|
62
|
+
from cloudbridge.interfaces.resources import TransferConfig
|
|
63
63
|
from cloudbridge.interfaces.resources import UploadPart
|
|
64
64
|
from cloudbridge.interfaces.resources import VMFirewall
|
|
65
65
|
from cloudbridge.interfaces.resources import VMFirewallRule
|
|
@@ -884,23 +884,107 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
884
884
|
shutil.copyfileobj(
|
|
885
885
|
cast("SupportsRead[bytes]", self.iter_content()), target_stream)
|
|
886
886
|
|
|
887
|
+
def download_to_file(self, path: str,
|
|
888
|
+
config: TransferConfig | None = None) -> None:
|
|
889
|
+
size = self.size
|
|
890
|
+
if size <= self._multipart_threshold(config):
|
|
891
|
+
with open(path, 'wb') as f:
|
|
892
|
+
self.save_content(f)
|
|
893
|
+
return
|
|
894
|
+
self._download_ranged(path, size, config)
|
|
895
|
+
|
|
896
|
+
def _download_ranged(self, path: str, size: int,
|
|
897
|
+
config: TransferConfig | None = None) -> None:
|
|
898
|
+
"""
|
|
899
|
+
Fetch the object as ranged reads across a bounded thread pool,
|
|
900
|
+
writing each range at its offset into a preallocated file.
|
|
901
|
+
|
|
902
|
+
To stay safe even on providers whose SDK client/connection is not
|
|
903
|
+
thread-safe, each worker reads through its own cloned provider (see
|
|
904
|
+
:meth:`.CloudProvider.clone`), so no provider state is shared between
|
|
905
|
+
threads. Memory is bounded to ~concurrency * part_size. On any
|
|
906
|
+
failure the partial file is removed and the error re-raised.
|
|
907
|
+
|
|
908
|
+
Providers with an efficient, thread-safe native downloader (e.g. AWS
|
|
909
|
+
via boto3's ``download_file``, Azure via ``download_blob``) override
|
|
910
|
+
``download_to_file`` to use it directly.
|
|
911
|
+
"""
|
|
912
|
+
part_size = self._multipart_part_size(config)
|
|
913
|
+
if part_size < 1:
|
|
914
|
+
raise InvalidValueException('part_size', part_size)
|
|
915
|
+
concurrency = max(1, self._multipart_max_concurrency(config))
|
|
916
|
+
ranges = [(offset, min(part_size, size - offset))
|
|
917
|
+
for offset in range(0, size, part_size)]
|
|
918
|
+
try:
|
|
919
|
+
with open(path, 'wb') as f:
|
|
920
|
+
f.truncate(size)
|
|
921
|
+
if concurrency == 1:
|
|
922
|
+
bucket_objects = self._bucket_objects
|
|
923
|
+
with open(path, 'r+b') as f:
|
|
924
|
+
for offset, length in ranges:
|
|
925
|
+
f.seek(offset)
|
|
926
|
+
f.write(bucket_objects.download_range(
|
|
927
|
+
self.bucket, self.name, offset, length))
|
|
928
|
+
else:
|
|
929
|
+
self._download_ranges_concurrently(path, ranges, concurrency)
|
|
930
|
+
except Exception:
|
|
931
|
+
try:
|
|
932
|
+
os.remove(path)
|
|
933
|
+
except OSError:
|
|
934
|
+
pass
|
|
935
|
+
raise
|
|
936
|
+
|
|
937
|
+
def _download_ranges_concurrently(
|
|
938
|
+
self, path: str, ranges: list[tuple[int, int]],
|
|
939
|
+
concurrency: int) -> None:
|
|
940
|
+
# A pool of cloned bucket-object services, one per worker, so each
|
|
941
|
+
# thread touches an isolated provider/connection.
|
|
942
|
+
clones: "queue.Queue[BucketObjectService]" = queue.Queue()
|
|
943
|
+
for _ in range(concurrency):
|
|
944
|
+
storage = cast("BaseStorageService",
|
|
945
|
+
self._provider.clone().storage)
|
|
946
|
+
clones.put(storage._bucket_objects)
|
|
947
|
+
|
|
948
|
+
bucket = self.bucket
|
|
949
|
+
name = self.name
|
|
950
|
+
|
|
951
|
+
def fetch_one(offset: int, length: int) -> None:
|
|
952
|
+
service = clones.get()
|
|
953
|
+
try:
|
|
954
|
+
data = service.download_range(bucket, name, offset, length)
|
|
955
|
+
finally:
|
|
956
|
+
clones.put(service)
|
|
957
|
+
# Each worker writes through its own handle at its own offset;
|
|
958
|
+
# ranges never overlap, so no locking is needed. Data is released
|
|
959
|
+
# as soon as it is written, bounding memory to
|
|
960
|
+
# ~concurrency * part_size.
|
|
961
|
+
with open(path, 'r+b') as f:
|
|
962
|
+
f.seek(offset)
|
|
963
|
+
f.write(data)
|
|
964
|
+
|
|
965
|
+
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
966
|
+
futures = [executor.submit(fetch_one, offset, length)
|
|
967
|
+
for offset, length in ranges]
|
|
968
|
+
for future in futures:
|
|
969
|
+
future.result()
|
|
970
|
+
|
|
887
971
|
# The three resolvers below pick, in order of precedence: an explicit
|
|
888
|
-
# per-call
|
|
972
|
+
# per-call TransferConfig field, the provider/global config, then the class
|
|
889
973
|
# default constant.
|
|
890
|
-
def _multipart_threshold(self, config:
|
|
974
|
+
def _multipart_threshold(self, config: TransferConfig | None = None) -> int:
|
|
891
975
|
if config is not None and config.threshold is not None:
|
|
892
976
|
return int(config.threshold)
|
|
893
977
|
return int(self._provider._get_config_value(
|
|
894
978
|
'multipart_threshold', self.CB_MULTIPART_THRESHOLD))
|
|
895
979
|
|
|
896
|
-
def _multipart_part_size(self, config:
|
|
980
|
+
def _multipart_part_size(self, config: TransferConfig | None = None) -> int:
|
|
897
981
|
if config is not None and config.part_size is not None:
|
|
898
982
|
return int(config.part_size)
|
|
899
983
|
return int(self._provider._get_config_value(
|
|
900
984
|
'multipart_part_size', self.CB_MULTIPART_PART_SIZE))
|
|
901
985
|
|
|
902
986
|
def _multipart_max_concurrency(
|
|
903
|
-
self, config:
|
|
987
|
+
self, config: TransferConfig | None = None) -> int:
|
|
904
988
|
if config is not None and config.max_concurrency is not None:
|
|
905
989
|
return int(config.max_concurrency)
|
|
906
990
|
return int(self._provider._get_config_value(
|
|
@@ -936,7 +1020,7 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
936
1020
|
return data
|
|
937
1021
|
|
|
938
1022
|
def upload(self, data: str | bytes | IO[bytes],
|
|
939
|
-
config:
|
|
1023
|
+
config: TransferConfig | None = None) -> BucketObject:
|
|
940
1024
|
size = self._data_size(data)
|
|
941
1025
|
if size is not None and size > self._multipart_threshold(config):
|
|
942
1026
|
return self._upload_multipart(self._as_stream(data), config)
|
|
@@ -944,14 +1028,14 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
944
1028
|
|
|
945
1029
|
def upload_from_file(
|
|
946
1030
|
self, path: str,
|
|
947
|
-
config:
|
|
1031
|
+
config: TransferConfig | None = None) -> BucketObject:
|
|
948
1032
|
if os.path.getsize(path) > self._multipart_threshold(config):
|
|
949
1033
|
with open(path, 'rb') as f:
|
|
950
1034
|
return self._upload_multipart(f, config)
|
|
951
1035
|
return self._upload_from_file_single_shot(path)
|
|
952
1036
|
|
|
953
1037
|
def _upload_multipart(self, stream: IO[bytes],
|
|
954
|
-
config:
|
|
1038
|
+
config: TransferConfig | None = None) -> BucketObject:
|
|
955
1039
|
"""
|
|
956
1040
|
Drive the explicit multipart lifecycle over a stream, reading it one
|
|
957
1041
|
part at a time so the whole payload is never held in memory.
|
|
@@ -2161,14 +2161,18 @@ class VMFirewallRule(CloudResource):
|
|
|
2161
2161
|
pass
|
|
2162
2162
|
|
|
2163
2163
|
|
|
2164
|
-
class
|
|
2164
|
+
class TransferConfig(object):
|
|
2165
2165
|
"""
|
|
2166
|
-
Provider-agnostic, per-call tuning for an object
|
|
2166
|
+
Provider-agnostic, per-call tuning for an object transfer in either
|
|
2167
|
+
direction.
|
|
2167
2168
|
|
|
2168
|
-
Passed optionally to :meth:`.BucketObject.upload
|
|
2169
|
-
:meth:`.BucketObject.upload_from_file
|
|
2170
|
-
|
|
2171
|
-
|
|
2169
|
+
Passed optionally to :meth:`.BucketObject.upload`,
|
|
2170
|
+
:meth:`.BucketObject.upload_from_file` and
|
|
2171
|
+
:meth:`.BucketObject.download_to_file`. Any field left as ``None`` falls
|
|
2172
|
+
back to the provider/global configuration (the ``CB_MULTIPART_*``
|
|
2173
|
+
settings). Each provider maps these fields onto its native transfer
|
|
2174
|
+
mechanism. To tune uploads and downloads differently, pass a different
|
|
2175
|
+
instance to each call.
|
|
2172
2176
|
"""
|
|
2173
2177
|
|
|
2174
2178
|
def __init__(self, threshold: int | None = None,
|
|
@@ -2176,22 +2180,24 @@ class UploadConfig(object):
|
|
|
2176
2180
|
max_concurrency: int | None = None) -> None:
|
|
2177
2181
|
"""
|
|
2178
2182
|
:type threshold: ``int``
|
|
2179
|
-
:param threshold: Size in bytes above which the
|
|
2180
|
-
multiple parts.
|
|
2183
|
+
:param threshold: Size in bytes above which the transfer is split
|
|
2184
|
+
into multiple parts.
|
|
2181
2185
|
|
|
2182
2186
|
:type part_size: ``int``
|
|
2183
|
-
:param part_size: Size in bytes of each part.
|
|
2184
|
-
provider minimum (5 MiB on S3) for all but the
|
|
2187
|
+
:param part_size: Size in bytes of each part. For uploads this must
|
|
2188
|
+
be at least the provider minimum (5 MiB on S3) for all but the
|
|
2189
|
+
final part; downloads have no minimum.
|
|
2185
2190
|
|
|
2186
2191
|
:type max_concurrency: ``int``
|
|
2187
|
-
:param max_concurrency: Maximum number of parts to
|
|
2192
|
+
:param max_concurrency: Maximum number of parts to transfer in
|
|
2193
|
+
parallel.
|
|
2188
2194
|
"""
|
|
2189
2195
|
self.threshold = threshold
|
|
2190
2196
|
self.part_size = part_size
|
|
2191
2197
|
self.max_concurrency = max_concurrency
|
|
2192
2198
|
|
|
2193
2199
|
def __repr__(self) -> str:
|
|
2194
|
-
return ("<CB-
|
|
2200
|
+
return ("<CB-TransferConfig: threshold={0}, part_size={1}, "
|
|
2195
2201
|
"max_concurrency={2}>".format(
|
|
2196
2202
|
self.threshold, self.part_size, self.max_concurrency))
|
|
2197
2203
|
|
|
@@ -2368,13 +2374,37 @@ class BucketObject(CloudResource):
|
|
|
2368
2374
|
"""
|
|
2369
2375
|
pass
|
|
2370
2376
|
|
|
2377
|
+
@abstractmethod
|
|
2378
|
+
def download_to_file(self, path: str,
|
|
2379
|
+
config: TransferConfig | None = None) -> None:
|
|
2380
|
+
"""
|
|
2381
|
+
Download this object's content to a local file.
|
|
2382
|
+
|
|
2383
|
+
Objects larger than the configured transfer threshold are fetched as
|
|
2384
|
+
ranged reads of ``part_size`` bytes, up to ``max_concurrency`` parts
|
|
2385
|
+
in parallel, so large downloads are not bound to a single connection
|
|
2386
|
+
(and the whole object is never held in memory). Smaller objects are
|
|
2387
|
+
streamed in a single request. ``iter_content``/``save_content``
|
|
2388
|
+
remain single-stream alternatives for arbitrary target streams.
|
|
2389
|
+
|
|
2390
|
+
:type path: ``str``
|
|
2391
|
+
:param path: Local path to write the object's content to. An existing
|
|
2392
|
+
file is overwritten; on failure no partial file is left behind.
|
|
2393
|
+
|
|
2394
|
+
:type config: :class:`.TransferConfig`
|
|
2395
|
+
:param config: Optional per-call transfer tuning (threshold, part
|
|
2396
|
+
size, concurrency). Any field left unset falls back to the
|
|
2397
|
+
provider/global configuration.
|
|
2398
|
+
"""
|
|
2399
|
+
pass
|
|
2400
|
+
|
|
2371
2401
|
@abstractmethod
|
|
2372
2402
|
def upload(self, source_stream: IO[bytes],
|
|
2373
|
-
config:
|
|
2403
|
+
config: TransferConfig | None = None) -> BucketObject | None:
|
|
2374
2404
|
"""
|
|
2375
2405
|
Set the contents of the object to the data read from the source stream.
|
|
2376
2406
|
|
|
2377
|
-
:type config: :class:`.
|
|
2407
|
+
:type config: :class:`.TransferConfig`
|
|
2378
2408
|
:param config: Optional per-call upload tuning (multipart threshold,
|
|
2379
2409
|
part size, concurrency). Any field left unset falls back to the
|
|
2380
2410
|
provider/global configuration.
|
|
@@ -2386,7 +2416,7 @@ class BucketObject(CloudResource):
|
|
|
2386
2416
|
|
|
2387
2417
|
@abstractmethod
|
|
2388
2418
|
def upload_from_file(self, path: str,
|
|
2389
|
-
config:
|
|
2419
|
+
config: TransferConfig | None = None) -> BucketObject | None:
|
|
2390
2420
|
"""
|
|
2391
2421
|
Store the contents of the file pointed by the "path" variable.
|
|
2392
2422
|
|
|
@@ -2396,7 +2426,7 @@ class BucketObject(CloudResource):
|
|
|
2396
2426
|
:type path: ``str``
|
|
2397
2427
|
:param path: Absolute path to the file to be uploaded to S3.
|
|
2398
2428
|
|
|
2399
|
-
:type config: :class:`.
|
|
2429
|
+
:type config: :class:`.TransferConfig`
|
|
2400
2430
|
:param config: Optional per-call upload tuning (multipart threshold,
|
|
2401
2431
|
part size, concurrency). Any field left unset falls back to the
|
|
2402
2432
|
provider/global configuration.
|
|
@@ -2435,7 +2465,9 @@ class BucketObject(CloudResource):
|
|
|
2435
2465
|
pass
|
|
2436
2466
|
|
|
2437
2467
|
@abstractmethod
|
|
2438
|
-
def generate_url(self, expires_in: int, writable: bool = False
|
|
2468
|
+
def generate_url(self, expires_in: int, writable: bool = False,
|
|
2469
|
+
content_disposition: str | None = None,
|
|
2470
|
+
content_type: str | None = None) -> str:
|
|
2439
2471
|
"""
|
|
2440
2472
|
Generate a signed URL to this object.
|
|
2441
2473
|
|
|
@@ -2449,6 +2481,20 @@ class BucketObject(CloudResource):
|
|
|
2449
2481
|
:param writable: Write permission for this signed URL. Users with the URL
|
|
2450
2482
|
will be able to upload to this object, but they will NOT be able to
|
|
2451
2483
|
read from it.
|
|
2484
|
+
:type content_disposition: ``str``
|
|
2485
|
+
:param content_disposition: When set, ask the backing store to serve
|
|
2486
|
+
the object with this ``Content-Disposition`` response header on
|
|
2487
|
+
GET (e.g. ``attachment; filename="data.txt"``). This is a
|
|
2488
|
+
response-serving hint: every provider accepts it and honors it
|
|
2489
|
+
where the backing store supports response-header overrides (AWS,
|
|
2490
|
+
Azure and GCP fully; OpenStack Swift honors the filename portion
|
|
2491
|
+
via its tempurl ``filename`` parameter). Ignored when
|
|
2492
|
+
``writable`` is ``True``.
|
|
2493
|
+
:type content_type: ``str``
|
|
2494
|
+
:param content_type: When set, ask the backing store to serve the
|
|
2495
|
+
object with this ``Content-Type`` response header on GET. Honored
|
|
2496
|
+
by AWS, Azure and GCP; OpenStack Swift cannot override the
|
|
2497
|
+
content type. Ignored when ``writable`` is ``True``.
|
|
2452
2498
|
|
|
2453
2499
|
:rtype: ``str``
|
|
2454
2500
|
:return: A URL to access the object.
|
|
@@ -1383,6 +1383,33 @@ class BucketObjectService(CloudService):
|
|
|
1383
1383
|
"""
|
|
1384
1384
|
pass
|
|
1385
1385
|
|
|
1386
|
+
@abstractmethod
|
|
1387
|
+
def download_range(self, bucket: Bucket | str, object_name: str,
|
|
1388
|
+
offset: int, length: int) -> bytes:
|
|
1389
|
+
"""
|
|
1390
|
+
Read a byte range of an object.
|
|
1391
|
+
|
|
1392
|
+
This is the primitive behind
|
|
1393
|
+
:meth:`.BucketObject.download_to_file`'s parallel ranged downloads;
|
|
1394
|
+
it is also usable directly for partial reads.
|
|
1395
|
+
|
|
1396
|
+
:type bucket: :class:`.Bucket`
|
|
1397
|
+
:param bucket: The bucket containing the object.
|
|
1398
|
+
|
|
1399
|
+
:type object_name: ``str``
|
|
1400
|
+
:param object_name: The key of the object to read.
|
|
1401
|
+
|
|
1402
|
+
:type offset: ``int``
|
|
1403
|
+
:param offset: Zero-based byte offset to start reading at.
|
|
1404
|
+
|
|
1405
|
+
:type length: ``int``
|
|
1406
|
+
:param length: Number of bytes to read.
|
|
1407
|
+
|
|
1408
|
+
:rtype: ``bytes``
|
|
1409
|
+
:return: The requested byte range of the object's content.
|
|
1410
|
+
"""
|
|
1411
|
+
pass
|
|
1412
|
+
|
|
1386
1413
|
|
|
1387
1414
|
class SecurityService(CloudService):
|
|
1388
1415
|
|
|
@@ -13,7 +13,7 @@ from typing import Iterator
|
|
|
13
13
|
from typing import TYPE_CHECKING
|
|
14
14
|
from typing import cast
|
|
15
15
|
|
|
16
|
-
from boto3.s3.transfer import TransferConfig
|
|
16
|
+
from boto3.s3.transfer import TransferConfig as S3TransferConfig
|
|
17
17
|
|
|
18
18
|
from botocore.exceptions import ClientError
|
|
19
19
|
|
|
@@ -58,7 +58,7 @@ from cloudbridge.interfaces.resources import SnapshotState
|
|
|
58
58
|
from cloudbridge.interfaces.resources import Subnet
|
|
59
59
|
from cloudbridge.interfaces.resources import SubnetState
|
|
60
60
|
from cloudbridge.interfaces.resources import TrafficDirection
|
|
61
|
-
from cloudbridge.interfaces.resources import
|
|
61
|
+
from cloudbridge.interfaces.resources import TransferConfig
|
|
62
62
|
from cloudbridge.interfaces.resources import VMFirewall
|
|
63
63
|
from cloudbridge.interfaces.resources import VMType
|
|
64
64
|
from cloudbridge.interfaces.resources import Volume
|
|
@@ -920,11 +920,11 @@ class AWSBucketObject(BaseBucketObject):
|
|
|
920
920
|
|
|
921
921
|
def _upload_multipart(
|
|
922
922
|
self, stream: IO[bytes],
|
|
923
|
-
config:
|
|
923
|
+
config: TransferConfig | None = None) -> BucketObject:
|
|
924
924
|
# boto3's TransferManager uploads parts concurrently with a thread-safe
|
|
925
925
|
# client, so the transparent multipart path delegates to it rather than
|
|
926
926
|
# CloudBridge's generic clone-pool driver.
|
|
927
|
-
transfer_config =
|
|
927
|
+
transfer_config = S3TransferConfig(
|
|
928
928
|
multipart_threshold=self._multipart_part_size(config),
|
|
929
929
|
multipart_chunksize=self._multipart_part_size(config),
|
|
930
930
|
max_concurrency=self._multipart_max_concurrency(config))
|
|
@@ -932,26 +932,46 @@ class AWSBucketObject(BaseBucketObject):
|
|
|
932
932
|
return self
|
|
933
933
|
|
|
934
934
|
def upload_from_file(self, path: str,
|
|
935
|
-
config:
|
|
935
|
+
config: TransferConfig | None = None) -> BucketObject:
|
|
936
936
|
# boto3's upload_file streams large files in parts via its
|
|
937
937
|
# TransferManager. Drive it with CloudBridge's multipart knobs so that
|
|
938
938
|
# upload_from_file and upload() honour the same configuration rather
|
|
939
939
|
# than boto3's own defaults.
|
|
940
|
-
transfer_config =
|
|
940
|
+
transfer_config = S3TransferConfig(
|
|
941
941
|
multipart_threshold=self._multipart_threshold(config),
|
|
942
942
|
multipart_chunksize=self._multipart_part_size(config),
|
|
943
943
|
max_concurrency=self._multipart_max_concurrency(config))
|
|
944
944
|
self._obj.upload_file(path, Config=transfer_config)
|
|
945
945
|
return self
|
|
946
946
|
|
|
947
|
+
def download_to_file(self, path: str,
|
|
948
|
+
config: TransferConfig | None = None) -> None:
|
|
949
|
+
# boto3's TransferManager downloads large objects as parallel ranged
|
|
950
|
+
# GETs with a thread-safe client, so the transparent ranged path
|
|
951
|
+
# delegates to it rather than CloudBridge's generic clone-pool driver.
|
|
952
|
+
transfer_config = S3TransferConfig(
|
|
953
|
+
multipart_threshold=self._multipart_threshold(config),
|
|
954
|
+
multipart_chunksize=self._multipart_part_size(config),
|
|
955
|
+
max_concurrency=self._multipart_max_concurrency(config))
|
|
956
|
+
self._obj.meta.client.download_file(
|
|
957
|
+
self._obj.bucket_name, self.id, path, Config=transfer_config)
|
|
958
|
+
|
|
947
959
|
def delete(self) -> None:
|
|
948
960
|
self._obj.delete()
|
|
949
961
|
|
|
950
|
-
def generate_url(self, expires_in: int, writable: bool = False
|
|
962
|
+
def generate_url(self, expires_in: int, writable: bool = False,
|
|
963
|
+
content_disposition: str | None = None,
|
|
964
|
+
content_type: str | None = None) -> str:
|
|
965
|
+
params = {'Bucket': self._obj.bucket_name, 'Key': self.id}
|
|
966
|
+
if not writable:
|
|
967
|
+
if content_disposition:
|
|
968
|
+
params['ResponseContentDisposition'] = content_disposition
|
|
969
|
+
if content_type:
|
|
970
|
+
params['ResponseContentType'] = content_type
|
|
951
971
|
return cast("AWSCloudProvider", self._provider).s3_conn.meta.client \
|
|
952
972
|
.generate_presigned_url(
|
|
953
973
|
'put_object' if writable else 'get_object',
|
|
954
|
-
Params=
|
|
974
|
+
Params=params,
|
|
955
975
|
ExpiresIn=expires_in)
|
|
956
976
|
|
|
957
977
|
def refresh(self) -> None:
|
|
@@ -733,6 +733,16 @@ class AWSBucketObjectService(BaseBucketObjectService):
|
|
|
733
733
|
Bucket=cast(Any, bucket).name, Key=upload.object_name,
|
|
734
734
|
UploadId=upload.id)
|
|
735
735
|
|
|
736
|
+
@dispatch(event="provider.storage._bucket_objects.download_range",
|
|
737
|
+
priority=BaseBucketObjectService.STANDARD_EVENT_PRIORITY)
|
|
738
|
+
def download_range(self, bucket: Bucket | str, object_name: str,
|
|
739
|
+
offset: int, length: int) -> bytes:
|
|
740
|
+
client = cast("AWSCloudProvider", self.provider).s3_conn.meta.client
|
|
741
|
+
response = client.get_object(
|
|
742
|
+
Bucket=cast(Any, bucket).name, Key=object_name,
|
|
743
|
+
Range='bytes=%d-%d' % (offset, offset + length - 1))
|
|
744
|
+
return cast(bytes, response['Body'].read())
|
|
745
|
+
|
|
736
746
|
|
|
737
747
|
class AWSComputeService(BaseComputeService):
|
|
738
748
|
|
|
@@ -507,8 +507,16 @@ class AzureClient(object):
|
|
|
507
507
|
blob_client = self.blob_client(container_name, blob_name)
|
|
508
508
|
blob_client.delete_blob(delete_snapshots)
|
|
509
509
|
|
|
510
|
+
def get_blob_range(self, container_name: str, blob_name: str,
|
|
511
|
+
offset: int, length: int) -> bytes:
|
|
512
|
+
blob_client = self.blob_client(container_name, blob_name)
|
|
513
|
+
downloader = blob_client.download_blob(offset=offset, length=length)
|
|
514
|
+
return downloader.readall()
|
|
515
|
+
|
|
510
516
|
def get_blob_url(self, container_name: Any, blob_name: str,
|
|
511
|
-
expiry_time: int, writable: bool
|
|
517
|
+
expiry_time: int, writable: bool,
|
|
518
|
+
content_disposition: str | None = None,
|
|
519
|
+
content_type: str | None = None) -> str:
|
|
512
520
|
now = datetime.datetime.utcnow()
|
|
513
521
|
expiry = now + datetime.timedelta(
|
|
514
522
|
seconds=expiry_time)
|
|
@@ -520,7 +528,9 @@ class AzureClient(object):
|
|
|
520
528
|
sas = generate_blob_sas(
|
|
521
529
|
self.storage_account, container_name, blob_name,
|
|
522
530
|
permission=BlobSasPermissions(read=True, write=writable), expiry=expiry,
|
|
523
|
-
user_delegation_key=delegation_key
|
|
531
|
+
user_delegation_key=delegation_key,
|
|
532
|
+
content_disposition=content_disposition,
|
|
533
|
+
content_type=content_type
|
|
524
534
|
)
|
|
525
535
|
url = f"https://{self.storage_account}.blob.core.windows.net/{container_name}/{blob_name}?{sas}"
|
|
526
536
|
return url
|
|
@@ -62,7 +62,7 @@ from cloudbridge.interfaces.resources import SnapshotState
|
|
|
62
62
|
from cloudbridge.interfaces.resources import Subnet
|
|
63
63
|
from cloudbridge.interfaces.resources import SubnetState
|
|
64
64
|
from cloudbridge.interfaces.resources import TrafficDirection
|
|
65
|
-
from cloudbridge.interfaces.resources import
|
|
65
|
+
from cloudbridge.interfaces.resources import TransferConfig
|
|
66
66
|
from cloudbridge.interfaces.resources import VMFirewall
|
|
67
67
|
from cloudbridge.interfaces.resources import VMType
|
|
68
68
|
from cloudbridge.interfaces.resources import Volume
|
|
@@ -293,7 +293,7 @@ class AzureBucketObject(BaseBucketObject):
|
|
|
293
293
|
return self
|
|
294
294
|
|
|
295
295
|
def _upload_multipart(self, stream: IO[bytes],
|
|
296
|
-
config:
|
|
296
|
+
config: TransferConfig | None = None) -> BucketObject:
|
|
297
297
|
# The Azure SDK's upload_blob stages blocks concurrently (max_concurrency
|
|
298
298
|
# workers) over a thread-safe client, so the transparent multipart path
|
|
299
299
|
# delegates to it rather than CloudBridge's generic clone-pool driver.
|
|
@@ -302,6 +302,16 @@ class AzureBucketObject(BaseBucketObject):
|
|
|
302
302
|
max_concurrency=self._multipart_max_concurrency(config))
|
|
303
303
|
return self
|
|
304
304
|
|
|
305
|
+
def download_to_file(self, path: str,
|
|
306
|
+
config: TransferConfig | None = None) -> None:
|
|
307
|
+
# azure-storage-blob's downloader fetches block ranges concurrently
|
|
308
|
+
# with a thread-safe client, so delegate to it rather than
|
|
309
|
+
# CloudBridge's generic clone-pool driver.
|
|
310
|
+
with open(path, 'wb') as f:
|
|
311
|
+
self._blob_client.download_blob(
|
|
312
|
+
max_concurrency=self._multipart_max_concurrency(config)
|
|
313
|
+
).readinto(f)
|
|
314
|
+
|
|
305
315
|
def delete(self) -> None:
|
|
306
316
|
"""
|
|
307
317
|
Delete this object.
|
|
@@ -311,13 +321,17 @@ class AzureBucketObject(BaseBucketObject):
|
|
|
311
321
|
"""
|
|
312
322
|
self._blob_client.delete_blob()
|
|
313
323
|
|
|
314
|
-
def generate_url(self, expires_in: int, writable: bool = False
|
|
324
|
+
def generate_url(self, expires_in: int, writable: bool = False,
|
|
325
|
+
content_disposition: str | None = None,
|
|
326
|
+
content_type: str | None = None) -> str:
|
|
315
327
|
"""
|
|
316
328
|
Generate a URL to this object.
|
|
317
329
|
"""
|
|
318
330
|
return cast(
|
|
319
331
|
"AzureCloudProvider", self._provider).azure_client.get_blob_url(
|
|
320
|
-
self._container, self.name, expires_in, writable
|
|
332
|
+
self._container, self.name, expires_in, writable,
|
|
333
|
+
content_disposition=None if writable else content_disposition,
|
|
334
|
+
content_type=None if writable else content_type)
|
|
321
335
|
|
|
322
336
|
def refresh(self) -> None:
|
|
323
337
|
pass
|
|
@@ -766,6 +766,14 @@ class AzureBucketObjectService(BaseBucketObjectService):
|
|
|
766
766
|
"%s/%s will expire automatically.",
|
|
767
767
|
cast("Bucket", bucket).name, upload.object_name)
|
|
768
768
|
|
|
769
|
+
@dispatch(event="provider.storage._bucket_objects.download_range",
|
|
770
|
+
priority=BaseBucketObjectService.STANDARD_EVENT_PRIORITY)
|
|
771
|
+
def download_range(self, bucket: Bucket | str, object_name: str,
|
|
772
|
+
offset: int, length: int) -> bytes:
|
|
773
|
+
azure_client = cast("AzureCloudProvider", self.provider).azure_client
|
|
774
|
+
return cast(bytes, azure_client.get_blob_range(
|
|
775
|
+
cast("Bucket", bucket).name, object_name, offset, length))
|
|
776
|
+
|
|
769
777
|
|
|
770
778
|
class AzureComputeService(BaseComputeService):
|
|
771
779
|
def __init__(self, provider: CloudProvider) -> None:
|
|
@@ -81,7 +81,7 @@ if TYPE_CHECKING:
|
|
|
81
81
|
from cloudbridge.interfaces.resources import Snapshot
|
|
82
82
|
from cloudbridge.interfaces.resources import Subnet
|
|
83
83
|
from cloudbridge.interfaces.resources import SubnetSubService
|
|
84
|
-
from cloudbridge.interfaces.resources import
|
|
84
|
+
from cloudbridge.interfaces.resources import TransferConfig
|
|
85
85
|
from cloudbridge.interfaces.resources import VMFirewall
|
|
86
86
|
from cloudbridge.interfaces.resources import VMFirewallRuleSubService
|
|
87
87
|
from cloudbridge.interfaces.resources import VMType
|
|
@@ -2169,7 +2169,7 @@ class GCPBucketObject(BaseBucketObject):
|
|
|
2169
2169
|
return self
|
|
2170
2170
|
|
|
2171
2171
|
def upload_from_file(self, path: str,
|
|
2172
|
-
config:
|
|
2172
|
+
config: TransferConfig | None = None) -> BucketObject:
|
|
2173
2173
|
"""
|
|
2174
2174
|
Upload a binary file.
|
|
2175
2175
|
|
|
@@ -2198,7 +2198,9 @@ class GCPBucketObject(BaseBucketObject):
|
|
|
2198
2198
|
.delete(bucket=self._obj['bucket'], object=self.name)
|
|
2199
2199
|
.execute())
|
|
2200
2200
|
|
|
2201
|
-
def generate_url(self, expires_in: int, writable: bool = False
|
|
2201
|
+
def generate_url(self, expires_in: int, writable: bool = False,
|
|
2202
|
+
content_disposition: str | None = None,
|
|
2203
|
+
content_type: str | None = None) -> str:
|
|
2202
2204
|
"""
|
|
2203
2205
|
Generates a signed URL accessible to everyone.
|
|
2204
2206
|
|
|
@@ -2210,10 +2212,19 @@ class GCPBucketObject(BaseBucketObject):
|
|
|
2210
2212
|
provider = cast("GCPCloudProvider", self._provider)
|
|
2211
2213
|
http_method = "PUT" if writable else "GET"
|
|
2212
2214
|
|
|
2215
|
+
query_parameters: dict[str, Any] = {}
|
|
2216
|
+
if not writable:
|
|
2217
|
+
if content_disposition:
|
|
2218
|
+
query_parameters[
|
|
2219
|
+
'response-content-disposition'] = content_disposition
|
|
2220
|
+
if content_type:
|
|
2221
|
+
query_parameters['response-content-type'] = content_type
|
|
2222
|
+
|
|
2213
2223
|
# pylint:disable=protected-access
|
|
2214
2224
|
return helpers.generate_signed_url(
|
|
2215
2225
|
provider._credentials, self._obj['bucket'], self.name,
|
|
2216
|
-
expiration=expires_in, http_method=http_method
|
|
2226
|
+
expiration=expires_in, http_method=http_method,
|
|
2227
|
+
query_parameters=query_parameters or None)
|
|
2217
2228
|
|
|
2218
2229
|
def refresh(self) -> None:
|
|
2219
2230
|
# pylint:disable=protected-access
|
|
@@ -1769,6 +1769,17 @@ class GCPBucketObjectService(BaseBucketObjectService):
|
|
|
1769
1769
|
self._delete_objects(bucket, self._list_temp_objects(bucket, upload),
|
|
1770
1770
|
ignore_missing=True)
|
|
1771
1771
|
|
|
1772
|
+
@dispatch(event="provider.storage._bucket_objects.download_range",
|
|
1773
|
+
priority=BaseBucketObjectService.STANDARD_EVENT_PRIORITY)
|
|
1774
|
+
def download_range(self, bucket: Bucket | str, object_name: str,
|
|
1775
|
+
offset: int, length: int) -> bytes:
|
|
1776
|
+
provider = cast("GCPCloudProvider", self.provider)
|
|
1777
|
+
request = provider.gcp_storage.objects().get_media(
|
|
1778
|
+
bucket=cast(Any, bucket).name, object=object_name)
|
|
1779
|
+
request.headers['range'] = 'bytes=%d-%d' % (
|
|
1780
|
+
offset, offset + length - 1)
|
|
1781
|
+
return cast(bytes, request.execute())
|
|
1782
|
+
|
|
1772
1783
|
def _compose(self, bucket: Bucket | str, upload: MultipartUpload,
|
|
1773
1784
|
destination: str, sources: builtins.list[str]) -> builtins.list[str]:
|
|
1774
1785
|
"""
|