cloudbridge 4.2.1__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.1 → cloudbridge-4.3.0}/PKG-INFO +1 -1
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/__init__.py +1 -1
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/resources.py +92 -8
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/resources.py +46 -16
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/services.py +27 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/resources.py +18 -6
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/services.py +10 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/azure_client.py +6 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/resources.py +12 -2
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/services.py +8 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/resources.py +2 -2
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/services.py +11 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/resources.py +2 -2
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/services.py +10 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/PKG-INFO +1 -1
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/SOURCES.txt +1 -0
- cloudbridge-4.3.0/tests/test_download_driver.py +236 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_multipart_driver.py +2 -2
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_object_store_service.py +97 -8
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/LICENSE +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/README.rst +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/__init__.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/helpers.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/middleware.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/provider.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/services.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/subservices.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/factory.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/__init__.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/exceptions.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/provider.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/subservices.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/__init__.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/__init__.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/helpers.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/provider.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/subservices.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/__init__.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/helpers.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/provider.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/subservices.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/__init__.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/helpers.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/provider.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/subservices.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/mock/__init__.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/mock/provider.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/__init__.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/helpers.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/provider.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/subservices.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/py.typed +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/dependency_links.txt +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/requires.txt +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/top_level.txt +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/pyproject.toml +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/setup.cfg +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_base_helpers.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_block_store_service.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_cloud_factory.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_cloud_helpers.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_compute_service.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_dns_service.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_image_service.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_interface.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_middleware_system.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_network_service.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_object_life_cycle.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_region_service.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_security_service.py +0 -0
- {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_vm_types_service.py +0 -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.
|
|
@@ -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,18 +932,30 @@ 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
|
|
|
@@ -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,6 +507,12 @@ 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
517
|
expiry_time: int, writable: bool,
|
|
512
518
|
content_disposition: str | None = None,
|
|
@@ -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.
|
|
@@ -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
|
|
|
@@ -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
|
"""
|
|
@@ -64,7 +64,7 @@ from cloudbridge.interfaces.resources import SnapshotState
|
|
|
64
64
|
from cloudbridge.interfaces.resources import Subnet
|
|
65
65
|
from cloudbridge.interfaces.resources import SubnetState
|
|
66
66
|
from cloudbridge.interfaces.resources import TrafficDirection
|
|
67
|
-
from cloudbridge.interfaces.resources import
|
|
67
|
+
from cloudbridge.interfaces.resources import TransferConfig
|
|
68
68
|
from cloudbridge.interfaces.resources import VMFirewall
|
|
69
69
|
from cloudbridge.interfaces.resources import VMType
|
|
70
70
|
from cloudbridge.interfaces.resources import Volume
|
|
@@ -1413,7 +1413,7 @@ class OpenStackBucketObject(BaseBucketObject):
|
|
|
1413
1413
|
return self
|
|
1414
1414
|
|
|
1415
1415
|
def upload_from_file(self, path: str,
|
|
1416
|
-
config:
|
|
1416
|
+
config: TransferConfig | None = None) -> BucketObject:
|
|
1417
1417
|
"""
|
|
1418
1418
|
Stores the contents of the file pointed by the ``path`` variable.
|
|
1419
1419
|
If the file is bigger than 5 Gig, it will be broken into segments.
|
|
@@ -808,6 +808,16 @@ class OpenStackBucketObjectService(BaseBucketObjectService):
|
|
|
808
808
|
except SwiftClientException:
|
|
809
809
|
pass # idempotent: ignore already-deleted segments
|
|
810
810
|
|
|
811
|
+
@dispatch(event="provider.storage._bucket_objects.download_range",
|
|
812
|
+
priority=BaseBucketObjectService.STANDARD_EVENT_PRIORITY)
|
|
813
|
+
def download_range(self, bucket: Bucket | str, object_name: str,
|
|
814
|
+
offset: int, length: int) -> bytes:
|
|
815
|
+
provider = cast("OpenStackCloudProvider", self.provider)
|
|
816
|
+
_, data = provider.swift.get_object(
|
|
817
|
+
cast(Any, bucket).name, object_name,
|
|
818
|
+
headers={'Range': 'bytes=%d-%d' % (offset, offset + length - 1)})
|
|
819
|
+
return cast(bytes, data)
|
|
820
|
+
|
|
811
821
|
|
|
812
822
|
class OpenStackComputeService(BaseComputeService):
|
|
813
823
|
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider-agnostic unit tests for the base ranged download driver
|
|
3
|
+
(``BaseBucketObject.download_to_file`` / ``_download_ranged``).
|
|
4
|
+
|
|
5
|
+
The driver is the engine behind transparent large downloads on providers that
|
|
6
|
+
do not override it (GCP, OpenStack Swift). Because the mock provider is
|
|
7
|
+
AWS-backed and AWS overrides the driver with boto3's native downloader, the
|
|
8
|
+
driver is exercised here directly against in-memory fakes so it has coverage
|
|
9
|
+
in CI without cloud credentials.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import unittest
|
|
15
|
+
|
|
16
|
+
from cloudbridge.base.resources import BaseBucketObject
|
|
17
|
+
from cloudbridge.interfaces.exceptions import InvalidValueException
|
|
18
|
+
from cloudbridge.interfaces.resources import TransferConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Recorder:
|
|
22
|
+
"""Thread-safe range log shared by the original and cloned fake
|
|
23
|
+
services."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, content):
|
|
26
|
+
self.content = content
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
self.ranges = [] # (offset, length) served
|
|
29
|
+
self.services_used = set() # id() of each service that served a range
|
|
30
|
+
self.clone_count = 0
|
|
31
|
+
self.single_shot = False
|
|
32
|
+
self.active = 0
|
|
33
|
+
self.max_active = 0
|
|
34
|
+
self.fail_on_offset = None # offset that should raise
|
|
35
|
+
|
|
36
|
+
def serve_range(self, service, offset, length):
|
|
37
|
+
with self._lock:
|
|
38
|
+
self.active += 1
|
|
39
|
+
self.max_active = max(self.max_active, self.active)
|
|
40
|
+
try:
|
|
41
|
+
if self.fail_on_offset == offset:
|
|
42
|
+
raise RuntimeError("boom at offset %d" % offset)
|
|
43
|
+
# Hold briefly so concurrent fetches genuinely overlap.
|
|
44
|
+
threading.Event().wait(0.02)
|
|
45
|
+
with self._lock:
|
|
46
|
+
self.ranges.append((offset, length))
|
|
47
|
+
self.services_used.add(id(service))
|
|
48
|
+
return self.content[offset:offset + length]
|
|
49
|
+
finally:
|
|
50
|
+
with self._lock:
|
|
51
|
+
self.active -= 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _FakeService:
|
|
55
|
+
def __init__(self, recorder, provider):
|
|
56
|
+
self._recorder = recorder
|
|
57
|
+
self._provider = provider
|
|
58
|
+
|
|
59
|
+
def download_range(self, bucket, object_name, offset, length):
|
|
60
|
+
return self._recorder.serve_range(self, offset, length)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _FakeStorage:
|
|
64
|
+
def __init__(self, service):
|
|
65
|
+
self._bucket_objects = service
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _FakeProvider:
|
|
69
|
+
def __init__(self, recorder):
|
|
70
|
+
self._recorder = recorder
|
|
71
|
+
self.storage = _FakeStorage(_FakeService(recorder, self))
|
|
72
|
+
|
|
73
|
+
def clone(self, zone=None):
|
|
74
|
+
self._recorder.clone_count += 1
|
|
75
|
+
return _FakeProvider(self._recorder)
|
|
76
|
+
|
|
77
|
+
def _get_config_value(self, key, default_value=None):
|
|
78
|
+
return default_value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _DriverObject(BaseBucketObject):
|
|
82
|
+
"""A BaseBucketObject wired to fakes with tiny transfer sizes."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, provider, threshold, part_size, concurrency):
|
|
85
|
+
super(_DriverObject, self).__init__(provider)
|
|
86
|
+
self._threshold = threshold
|
|
87
|
+
self._part_size = part_size
|
|
88
|
+
self._concurrency = concurrency
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def id(self):
|
|
92
|
+
return "obj"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def name(self):
|
|
96
|
+
return "obj"
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def size(self):
|
|
100
|
+
return len(self._provider._recorder.content)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def bucket(self):
|
|
104
|
+
return "BUCKET"
|
|
105
|
+
|
|
106
|
+
def save_content(self, target_stream):
|
|
107
|
+
self._provider._recorder.single_shot = True
|
|
108
|
+
target_stream.write(self._provider._recorder.content)
|
|
109
|
+
|
|
110
|
+
def _multipart_threshold(self, config=None):
|
|
111
|
+
if config is not None and config.threshold is not None:
|
|
112
|
+
return config.threshold
|
|
113
|
+
return self._threshold
|
|
114
|
+
|
|
115
|
+
def _multipart_part_size(self, config=None):
|
|
116
|
+
if config is not None and config.part_size is not None:
|
|
117
|
+
return config.part_size
|
|
118
|
+
return self._part_size
|
|
119
|
+
|
|
120
|
+
def _multipart_max_concurrency(self, config=None):
|
|
121
|
+
if config is not None and config.max_concurrency is not None:
|
|
122
|
+
return config.max_concurrency
|
|
123
|
+
return self._concurrency
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class DownloadDriverTestCase(unittest.TestCase):
|
|
127
|
+
|
|
128
|
+
def _driver(self, recorder, threshold, part_size, concurrency):
|
|
129
|
+
return _DriverObject(
|
|
130
|
+
_FakeProvider(recorder), threshold, part_size, concurrency)
|
|
131
|
+
|
|
132
|
+
def _download(self, driver, config=None):
|
|
133
|
+
fd, path = tempfile.mkstemp()
|
|
134
|
+
os.close(fd)
|
|
135
|
+
os.remove(path)
|
|
136
|
+
try:
|
|
137
|
+
driver.download_to_file(path, config)
|
|
138
|
+
with open(path, 'rb') as f:
|
|
139
|
+
return f.read()
|
|
140
|
+
finally:
|
|
141
|
+
if os.path.exists(path):
|
|
142
|
+
os.remove(path)
|
|
143
|
+
|
|
144
|
+
def test_reassembles_content_in_order(self):
|
|
145
|
+
content = b"abcdefghijABCDEFGHIJ0123456789x" # 31 bytes -> 8 ranges
|
|
146
|
+
recorder = _Recorder(content)
|
|
147
|
+
driver = self._driver(
|
|
148
|
+
recorder, threshold=10, part_size=4, concurrency=3)
|
|
149
|
+
self.assertEqual(self._download(driver), content)
|
|
150
|
+
self.assertFalse(recorder.single_shot)
|
|
151
|
+
# Ranges tile the object exactly: no gaps, no overlap, short tail.
|
|
152
|
+
self.assertEqual(
|
|
153
|
+
sorted(recorder.ranges),
|
|
154
|
+
[(offset, min(4, 31 - offset)) for offset in range(0, 31, 4)])
|
|
155
|
+
|
|
156
|
+
def test_below_threshold_uses_single_shot(self):
|
|
157
|
+
content = b"tiny content"
|
|
158
|
+
recorder = _Recorder(content)
|
|
159
|
+
driver = self._driver(
|
|
160
|
+
recorder, threshold=100, part_size=4, concurrency=3)
|
|
161
|
+
self.assertEqual(self._download(driver), content)
|
|
162
|
+
self.assertTrue(recorder.single_shot)
|
|
163
|
+
self.assertEqual(recorder.ranges, [])
|
|
164
|
+
|
|
165
|
+
def test_downloads_ranges_concurrently_via_cloned_services(self):
|
|
166
|
+
concurrency = 4
|
|
167
|
+
content = bytes(range(12)) # 12 ranges of one byte each
|
|
168
|
+
recorder = _Recorder(content)
|
|
169
|
+
driver = self._driver(
|
|
170
|
+
recorder, threshold=1, part_size=1, concurrency=concurrency)
|
|
171
|
+
self.assertEqual(self._download(driver), content)
|
|
172
|
+
|
|
173
|
+
# A clone per worker, reused across ranges.
|
|
174
|
+
self.assertEqual(recorder.clone_count, concurrency)
|
|
175
|
+
self.assertEqual(len(recorder.services_used), concurrency)
|
|
176
|
+
# Real parallelism happened, bounded by the configured concurrency.
|
|
177
|
+
self.assertGreater(recorder.max_active, 1)
|
|
178
|
+
self.assertLessEqual(recorder.max_active, concurrency)
|
|
179
|
+
|
|
180
|
+
def test_single_concurrency_does_not_clone(self):
|
|
181
|
+
content = b"abcdefghij"
|
|
182
|
+
recorder = _Recorder(content)
|
|
183
|
+
driver = self._driver(
|
|
184
|
+
recorder, threshold=1, part_size=4, concurrency=1)
|
|
185
|
+
self.assertEqual(self._download(driver), content)
|
|
186
|
+
self.assertEqual(recorder.clone_count, 0)
|
|
187
|
+
self.assertEqual(recorder.max_active, 1)
|
|
188
|
+
|
|
189
|
+
def test_per_call_config_overrides_concurrency(self):
|
|
190
|
+
content = bytes(range(12))
|
|
191
|
+
recorder = _Recorder(content)
|
|
192
|
+
driver = self._driver(
|
|
193
|
+
recorder, threshold=1, part_size=1, concurrency=1)
|
|
194
|
+
result = None
|
|
195
|
+
fd, path = tempfile.mkstemp()
|
|
196
|
+
os.close(fd)
|
|
197
|
+
try:
|
|
198
|
+
driver.download_to_file(path, TransferConfig(max_concurrency=3))
|
|
199
|
+
with open(path, 'rb') as f:
|
|
200
|
+
result = f.read()
|
|
201
|
+
finally:
|
|
202
|
+
os.remove(path)
|
|
203
|
+
self.assertEqual(result, content)
|
|
204
|
+
self.assertEqual(recorder.clone_count, 3)
|
|
205
|
+
self.assertGreater(recorder.max_active, 1)
|
|
206
|
+
self.assertLessEqual(recorder.max_active, 3)
|
|
207
|
+
|
|
208
|
+
def test_removes_partial_file_and_raises_on_range_failure(self):
|
|
209
|
+
content = bytes(range(16))
|
|
210
|
+
recorder = _Recorder(content)
|
|
211
|
+
recorder.fail_on_offset = 8
|
|
212
|
+
driver = self._driver(
|
|
213
|
+
recorder, threshold=1, part_size=4, concurrency=2)
|
|
214
|
+
fd, path = tempfile.mkstemp()
|
|
215
|
+
os.close(fd)
|
|
216
|
+
os.remove(path)
|
|
217
|
+
try:
|
|
218
|
+
with self.assertRaises(Exception):
|
|
219
|
+
driver.download_to_file(path)
|
|
220
|
+
self.assertFalse(os.path.exists(path))
|
|
221
|
+
finally:
|
|
222
|
+
if os.path.exists(path):
|
|
223
|
+
os.remove(path)
|
|
224
|
+
|
|
225
|
+
def test_part_size_must_be_positive(self):
|
|
226
|
+
content = bytes(range(16))
|
|
227
|
+
recorder = _Recorder(content)
|
|
228
|
+
driver = self._driver(
|
|
229
|
+
recorder, threshold=1, part_size=4, concurrency=2)
|
|
230
|
+
with self.assertRaises(InvalidValueException):
|
|
231
|
+
self._download(driver, TransferConfig(part_size=0))
|
|
232
|
+
self.assertEqual(recorder.ranges, [])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
unittest.main()
|
|
@@ -16,7 +16,7 @@ from cloudbridge.base.resources import BaseBucketObject
|
|
|
16
16
|
from cloudbridge.base.resources import BaseMultipartUpload
|
|
17
17
|
from cloudbridge.base.resources import BaseUploadPart
|
|
18
18
|
from cloudbridge.interfaces.exceptions import InvalidValueException
|
|
19
|
-
from cloudbridge.interfaces.resources import
|
|
19
|
+
from cloudbridge.interfaces.resources import TransferConfig
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class _Recorder:
|
|
@@ -193,7 +193,7 @@ class MultipartDriverTestCase(unittest.TestCase):
|
|
|
193
193
|
# bumps it to 3, engaging the clone-per-worker pool.
|
|
194
194
|
driver = self._driver(recorder, part_size=1, concurrency=1)
|
|
195
195
|
driver._upload_multipart(BytesIO(b"0123456789ab"),
|
|
196
|
-
|
|
196
|
+
TransferConfig(max_concurrency=3))
|
|
197
197
|
self.assertEqual(recorder.clone_count, 3)
|
|
198
198
|
self.assertGreater(recorder.max_active, 1)
|
|
199
199
|
self.assertLessEqual(recorder.max_active, 3)
|
|
@@ -14,7 +14,7 @@ from cloudbridge.interfaces.exceptions import DuplicateResourceException
|
|
|
14
14
|
from cloudbridge.interfaces.provider import TestMockHelperMixin
|
|
15
15
|
from cloudbridge.interfaces.resources import Bucket
|
|
16
16
|
from cloudbridge.interfaces.resources import BucketObject
|
|
17
|
-
from cloudbridge.interfaces.resources import
|
|
17
|
+
from cloudbridge.interfaces.resources import TransferConfig
|
|
18
18
|
|
|
19
19
|
from tests import helpers
|
|
20
20
|
from tests.helpers import ProviderTestBase
|
|
@@ -452,7 +452,7 @@ class CloudObjectStoreServiceTestCase(ProviderTestBase):
|
|
|
452
452
|
# knobs (not boto3's own defaults). This wiring is AWS-specific.
|
|
453
453
|
if self.provider.PROVIDER_ID not in ('aws', 'mock'):
|
|
454
454
|
self.skipTest("TransferConfig wiring is specific to AWS")
|
|
455
|
-
from boto3.s3.transfer import TransferConfig
|
|
455
|
+
from boto3.s3.transfer import TransferConfig as S3TransferConfig
|
|
456
456
|
|
|
457
457
|
name = "cbtest-mpu-{0}".format(helpers.get_uuid())
|
|
458
458
|
test_bucket = self.provider.storage.buckets.create(name)
|
|
@@ -476,16 +476,16 @@ class CloudObjectStoreServiceTestCase(ProviderTestBase):
|
|
|
476
476
|
|
|
477
477
|
spy.assert_called_once()
|
|
478
478
|
config = spy.call_args.kwargs['Config']
|
|
479
|
-
self.assertIsInstance(config,
|
|
479
|
+
self.assertIsInstance(config, S3TransferConfig)
|
|
480
480
|
self.assertEqual(config.multipart_chunksize, 7 * 1024 * 1024)
|
|
481
481
|
self.assertEqual(config.max_concurrency, 3)
|
|
482
482
|
|
|
483
483
|
@helpers.skipIfNoService(['storage.buckets'])
|
|
484
484
|
def test_per_call_upload_config_overrides_defaults(self):
|
|
485
|
-
# A per-call
|
|
485
|
+
# A per-call TransferConfig takes precedence over the global knobs.
|
|
486
486
|
if self.provider.PROVIDER_ID not in ('aws', 'mock'):
|
|
487
487
|
self.skipTest("TransferConfig wiring is specific to AWS")
|
|
488
|
-
from boto3.s3.transfer import TransferConfig
|
|
488
|
+
from boto3.s3.transfer import TransferConfig as S3TransferConfig
|
|
489
489
|
|
|
490
490
|
name = "cbtest-mpu-{0}".format(helpers.get_uuid())
|
|
491
491
|
test_bucket = self.provider.storage.buckets.create(name)
|
|
@@ -496,8 +496,8 @@ class CloudObjectStoreServiceTestCase(ProviderTestBase):
|
|
|
496
496
|
with cb_helpers.cleanup_action(lambda: obj.delete()):
|
|
497
497
|
test_file = os.path.join(
|
|
498
498
|
helpers.get_test_fixtures_folder(), 'logo.jpg')
|
|
499
|
-
cfg =
|
|
500
|
-
|
|
499
|
+
cfg = TransferConfig(part_size=8 * 1024 * 1024,
|
|
500
|
+
max_concurrency=2)
|
|
501
501
|
# pylint:disable=protected-access
|
|
502
502
|
with mock.patch.object(
|
|
503
503
|
obj._obj, 'upload_file',
|
|
@@ -505,10 +505,99 @@ class CloudObjectStoreServiceTestCase(ProviderTestBase):
|
|
|
505
505
|
obj.upload_from_file(test_file, config=cfg)
|
|
506
506
|
|
|
507
507
|
config = spy.call_args.kwargs['Config']
|
|
508
|
-
self.assertIsInstance(config,
|
|
508
|
+
self.assertIsInstance(config, S3TransferConfig)
|
|
509
509
|
self.assertEqual(config.multipart_chunksize, 8 * 1024 * 1024)
|
|
510
510
|
self.assertEqual(config.max_concurrency, 2)
|
|
511
511
|
|
|
512
|
+
@helpers.skipIfNoService(['storage.buckets'])
|
|
513
|
+
def test_download_to_file_roundtrip(self):
|
|
514
|
+
name = "cbtest-dl-{0}".format(helpers.get_uuid())
|
|
515
|
+
test_bucket = self.provider.storage.buckets.create(name)
|
|
516
|
+
|
|
517
|
+
with cb_helpers.cleanup_action(lambda: test_bucket.delete()):
|
|
518
|
+
obj = test_bucket.objects.create("roundtrip.txt")
|
|
519
|
+
|
|
520
|
+
with cb_helpers.cleanup_action(lambda: obj.delete()):
|
|
521
|
+
content = b"a small payload below the download threshold"
|
|
522
|
+
obj.upload(content)
|
|
523
|
+
|
|
524
|
+
stored = test_bucket.objects.get("roundtrip.txt")
|
|
525
|
+
fd, path = tempfile.mkstemp()
|
|
526
|
+
os.close(fd)
|
|
527
|
+
with cb_helpers.cleanup_action(lambda: os.remove(path)):
|
|
528
|
+
stored.download_to_file(path)
|
|
529
|
+
with open(path, 'rb') as f:
|
|
530
|
+
self.assertEqual(f.read(), content)
|
|
531
|
+
|
|
532
|
+
@helpers.skipIfNoService(['storage.buckets'])
|
|
533
|
+
def test_transparent_download_large_uses_ranged(self):
|
|
534
|
+
name = "cbtest-dl-{0}".format(helpers.get_uuid())
|
|
535
|
+
test_bucket = self.provider.storage.buckets.create(name)
|
|
536
|
+
|
|
537
|
+
with cb_helpers.cleanup_action(lambda: test_bucket.delete()):
|
|
538
|
+
obj = test_bucket.objects.create("ranged.bin")
|
|
539
|
+
|
|
540
|
+
with cb_helpers.cleanup_action(lambda: obj.delete()):
|
|
541
|
+
# Distinct byte pattern so out-of-order reassembly would show.
|
|
542
|
+
content = bytes(range(256)) * ((MIN_PART_SIZE * 2) // 256)
|
|
543
|
+
obj.upload(BytesIO(content),
|
|
544
|
+
TransferConfig(threshold=MIN_PART_SIZE,
|
|
545
|
+
part_size=MIN_PART_SIZE))
|
|
546
|
+
|
|
547
|
+
stored = test_bucket.objects.get("ranged.bin")
|
|
548
|
+
fd, path = tempfile.mkstemp()
|
|
549
|
+
os.close(fd)
|
|
550
|
+
with cb_helpers.cleanup_action(lambda: os.remove(path)):
|
|
551
|
+
# Lower the threshold so the download crosses it (each
|
|
552
|
+
# provider routes its own way underneath) and assert the
|
|
553
|
+
# content round-trips exactly.
|
|
554
|
+
stored.download_to_file(
|
|
555
|
+
path, TransferConfig(threshold=MIN_PART_SIZE,
|
|
556
|
+
part_size=MIN_PART_SIZE,
|
|
557
|
+
max_concurrency=2))
|
|
558
|
+
with open(path, 'rb') as f:
|
|
559
|
+
self.assertEqual(f.read(), content)
|
|
560
|
+
|
|
561
|
+
@helpers.skipIfNoService(['storage.buckets'])
|
|
562
|
+
def test_download_to_file_uses_transfer_config(self):
|
|
563
|
+
# AWS drives boto3's TransferManager with CloudBridge's transfer
|
|
564
|
+
# knobs (not boto3's own defaults). This wiring is AWS-specific.
|
|
565
|
+
if self.provider.PROVIDER_ID not in ('aws', 'mock'):
|
|
566
|
+
self.skipTest("TransferConfig wiring is specific to AWS")
|
|
567
|
+
from boto3.s3.transfer import TransferConfig as S3TransferConfig
|
|
568
|
+
|
|
569
|
+
name = "cbtest-dl-{0}".format(helpers.get_uuid())
|
|
570
|
+
test_bucket = self.provider.storage.buckets.create(name)
|
|
571
|
+
|
|
572
|
+
with cb_helpers.cleanup_action(lambda: test_bucket.delete()):
|
|
573
|
+
obj = test_bucket.objects.create("dlconfig.bin")
|
|
574
|
+
|
|
575
|
+
with cb_helpers.cleanup_action(lambda: obj.delete()):
|
|
576
|
+
obj.upload(b"some downloadable content")
|
|
577
|
+
stored = test_bucket.objects.get("dlconfig.bin")
|
|
578
|
+
|
|
579
|
+
fd, path = tempfile.mkstemp()
|
|
580
|
+
os.close(fd)
|
|
581
|
+
with cb_helpers.cleanup_action(lambda: os.remove(path)):
|
|
582
|
+
cfg = TransferConfig(part_size=8 * 1024 * 1024,
|
|
583
|
+
max_concurrency=2)
|
|
584
|
+
# pylint:disable=protected-access
|
|
585
|
+
with mock.patch.object(
|
|
586
|
+
stored._obj.meta.client, 'download_file',
|
|
587
|
+
wraps=stored._obj.meta.client
|
|
588
|
+
.download_file) as spy:
|
|
589
|
+
stored.download_to_file(path, config=cfg)
|
|
590
|
+
|
|
591
|
+
spy.assert_called_once()
|
|
592
|
+
config = spy.call_args.kwargs['Config']
|
|
593
|
+
self.assertIsInstance(config, S3TransferConfig)
|
|
594
|
+
self.assertEqual(
|
|
595
|
+
config.multipart_chunksize, 8 * 1024 * 1024)
|
|
596
|
+
self.assertEqual(config.max_concurrency, 2)
|
|
597
|
+
with open(path, 'rb') as f:
|
|
598
|
+
self.assertEqual(
|
|
599
|
+
f.read(), b"some downloadable content")
|
|
600
|
+
|
|
512
601
|
@skip("Skip unless you want to test objects bigger than 5GB")
|
|
513
602
|
@helpers.skipIfNoService(['storage.buckets'])
|
|
514
603
|
def test_upload_download_bucket_content_with_large_file(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|