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.
Files changed (71) hide show
  1. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/PKG-INFO +1 -1
  2. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/__init__.py +1 -1
  3. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/resources.py +92 -8
  4. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/resources.py +46 -16
  5. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/services.py +27 -0
  6. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/resources.py +18 -6
  7. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/services.py +10 -0
  8. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/azure_client.py +6 -0
  9. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/resources.py +12 -2
  10. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/services.py +8 -0
  11. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/resources.py +2 -2
  12. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/services.py +11 -0
  13. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/resources.py +2 -2
  14. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/services.py +10 -0
  15. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/PKG-INFO +1 -1
  16. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/SOURCES.txt +1 -0
  17. cloudbridge-4.3.0/tests/test_download_driver.py +236 -0
  18. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_multipart_driver.py +2 -2
  19. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_object_store_service.py +97 -8
  20. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/LICENSE +0 -0
  21. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/README.rst +0 -0
  22. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/__init__.py +0 -0
  23. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/helpers.py +0 -0
  24. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/middleware.py +0 -0
  25. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/provider.py +0 -0
  26. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/services.py +0 -0
  27. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/base/subservices.py +0 -0
  28. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/factory.py +0 -0
  29. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/__init__.py +0 -0
  30. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/exceptions.py +0 -0
  31. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/provider.py +0 -0
  32. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/interfaces/subservices.py +0 -0
  33. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/__init__.py +0 -0
  34. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/__init__.py +0 -0
  35. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/helpers.py +0 -0
  36. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/provider.py +0 -0
  37. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/aws/subservices.py +0 -0
  38. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/__init__.py +0 -0
  39. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/helpers.py +0 -0
  40. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/provider.py +0 -0
  41. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/azure/subservices.py +0 -0
  42. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/__init__.py +0 -0
  43. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/helpers.py +0 -0
  44. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/provider.py +0 -0
  45. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/subservices.py +0 -0
  46. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/mock/__init__.py +0 -0
  47. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/mock/provider.py +0 -0
  48. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/__init__.py +0 -0
  49. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/helpers.py +0 -0
  50. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/provider.py +0 -0
  51. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/subservices.py +0 -0
  52. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge/py.typed +0 -0
  53. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/dependency_links.txt +0 -0
  54. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/requires.txt +0 -0
  55. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/cloudbridge.egg-info/top_level.txt +0 -0
  56. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/pyproject.toml +0 -0
  57. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/setup.cfg +0 -0
  58. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_base_helpers.py +0 -0
  59. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_block_store_service.py +0 -0
  60. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_cloud_factory.py +0 -0
  61. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_cloud_helpers.py +0 -0
  62. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_compute_service.py +0 -0
  63. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_dns_service.py +0 -0
  64. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_image_service.py +0 -0
  65. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_interface.py +0 -0
  66. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_middleware_system.py +0 -0
  67. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_network_service.py +0 -0
  68. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_object_life_cycle.py +0 -0
  69. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_region_service.py +0 -0
  70. {cloudbridge-4.2.1 → cloudbridge-4.3.0}/tests/test_security_service.py +0 -0
  71. {cloudbridge-4.2.1 → 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.2.1
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
@@ -3,7 +3,7 @@ import logging
3
3
  from typing import Any
4
4
 
5
5
  # Current version of the library
6
- __version__ = '4.2.1'
6
+ __version__ = '4.3.0'
7
7
 
8
8
 
9
9
  def get_version() -> str:
@@ -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 UploadConfig
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 UploadConfig field, the provider/global config, then the class
972
+ # per-call TransferConfig field, the provider/global config, then the class
889
973
  # default constant.
890
- def _multipart_threshold(self, config: UploadConfig | None = None) -> int:
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: UploadConfig | None = None) -> int:
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: UploadConfig | None = None) -> int:
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: UploadConfig | None = None) -> BucketObject:
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: UploadConfig | None = None) -> BucketObject:
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: UploadConfig | None = None) -> BucketObject:
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 UploadConfig(object):
2164
+ class TransferConfig(object):
2165
2165
  """
2166
- Provider-agnostic, per-call tuning for an object upload.
2166
+ Provider-agnostic, per-call tuning for an object transfer in either
2167
+ direction.
2167
2168
 
2168
- Passed optionally to :meth:`.BucketObject.upload` and
2169
- :meth:`.BucketObject.upload_from_file`. Any field left as ``None`` falls
2170
- back to the provider/global configuration (the ``CB_MULTIPART_*`` settings).
2171
- Each provider maps these fields onto its native transfer mechanism.
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 upload is split into
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. Must be at least the
2184
- provider minimum (5 MiB on S3) for all but the final part.
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 upload in parallel.
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-UploadConfig: threshold={0}, part_size={1}, "
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: UploadConfig | None = None) -> BucketObject | None:
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:`.UploadConfig`
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: UploadConfig | None = None) -> BucketObject | None:
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:`.UploadConfig`
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 UploadConfig
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: UploadConfig | None = None) -> BucketObject:
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 = TransferConfig(
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: UploadConfig | None = None) -> BucketObject:
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 = TransferConfig(
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 UploadConfig
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: UploadConfig | None = None) -> BucketObject:
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 UploadConfig
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: UploadConfig | None = None) -> BucketObject:
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 UploadConfig
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: UploadConfig | None = None) -> BucketObject:
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudbridge
3
- Version: 4.2.1
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
@@ -57,6 +57,7 @@ tests/test_cloud_factory.py
57
57
  tests/test_cloud_helpers.py
58
58
  tests/test_compute_service.py
59
59
  tests/test_dns_service.py
60
+ tests/test_download_driver.py
60
61
  tests/test_image_service.py
61
62
  tests/test_interface.py
62
63
  tests/test_middleware_system.py
@@ -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 UploadConfig
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
- UploadConfig(max_concurrency=3))
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 UploadConfig
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, TransferConfig)
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 UploadConfig takes precedence over the global knobs.
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 = UploadConfig(part_size=8 * 1024 * 1024,
500
- max_concurrency=2)
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, TransferConfig)
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