cloudbridge 4.2.1__tar.gz → 4.3.1__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 (73) hide show
  1. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/PKG-INFO +1 -1
  2. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/__init__.py +1 -1
  3. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/base/resources.py +110 -8
  4. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/interfaces/resources.py +50 -16
  5. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/interfaces/services.py +27 -0
  6. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/aws/resources.py +18 -6
  7. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/aws/services.py +59 -10
  8. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/azure/azure_client.py +6 -0
  9. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/azure/resources.py +12 -2
  10. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/azure/services.py +8 -0
  11. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/resources.py +2 -2
  12. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/services.py +11 -0
  13. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/resources.py +2 -2
  14. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/services.py +10 -0
  15. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge.egg-info/PKG-INFO +1 -1
  16. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge.egg-info/SOURCES.txt +3 -0
  17. cloudbridge-4.3.1/tests/test_aws_dns_waiters.py +141 -0
  18. cloudbridge-4.3.1/tests/test_aws_vm_types.py +166 -0
  19. cloudbridge-4.3.1/tests/test_download_driver.py +306 -0
  20. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_multipart_driver.py +2 -2
  21. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_object_store_service.py +97 -8
  22. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/LICENSE +0 -0
  23. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/README.rst +0 -0
  24. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/base/__init__.py +0 -0
  25. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/base/helpers.py +0 -0
  26. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/base/middleware.py +0 -0
  27. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/base/provider.py +0 -0
  28. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/base/services.py +0 -0
  29. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/base/subservices.py +0 -0
  30. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/factory.py +0 -0
  31. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/interfaces/__init__.py +0 -0
  32. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/interfaces/exceptions.py +0 -0
  33. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/interfaces/provider.py +0 -0
  34. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/interfaces/subservices.py +0 -0
  35. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/__init__.py +0 -0
  36. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/aws/__init__.py +0 -0
  37. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/aws/helpers.py +0 -0
  38. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/aws/provider.py +0 -0
  39. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/aws/subservices.py +0 -0
  40. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/azure/__init__.py +0 -0
  41. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/azure/helpers.py +0 -0
  42. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/azure/provider.py +0 -0
  43. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/azure/subservices.py +0 -0
  44. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/__init__.py +0 -0
  45. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/helpers.py +0 -0
  46. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/provider.py +0 -0
  47. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/subservices.py +0 -0
  48. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/mock/__init__.py +0 -0
  49. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/mock/provider.py +0 -0
  50. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/__init__.py +0 -0
  51. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/helpers.py +0 -0
  52. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/provider.py +0 -0
  53. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/subservices.py +0 -0
  54. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge/py.typed +0 -0
  55. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge.egg-info/dependency_links.txt +0 -0
  56. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge.egg-info/requires.txt +0 -0
  57. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/cloudbridge.egg-info/top_level.txt +0 -0
  58. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/pyproject.toml +0 -0
  59. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/setup.cfg +0 -0
  60. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_base_helpers.py +0 -0
  61. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_block_store_service.py +0 -0
  62. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_cloud_factory.py +0 -0
  63. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_cloud_helpers.py +0 -0
  64. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_compute_service.py +0 -0
  65. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_dns_service.py +0 -0
  66. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_image_service.py +0 -0
  67. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_interface.py +0 -0
  68. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_middleware_system.py +0 -0
  69. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_network_service.py +0 -0
  70. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_object_life_cycle.py +0 -0
  71. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_region_service.py +0 -0
  72. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/tests/test_security_service.py +0 -0
  73. {cloudbridge-4.2.1 → cloudbridge-4.3.1}/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.1
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.1'
7
7
 
8
8
 
9
9
  def get_version() -> str:
@@ -9,6 +9,7 @@ import os
9
9
  import queue
10
10
  import re
11
11
  import shutil
12
+ import threading
12
13
  import time
13
14
  import uuid
14
15
  from concurrent.futures import FIRST_COMPLETED
@@ -59,7 +60,7 @@ from cloudbridge.interfaces.resources import Snapshot
59
60
  from cloudbridge.interfaces.resources import SnapshotState
60
61
  from cloudbridge.interfaces.resources import Subnet
61
62
  from cloudbridge.interfaces.resources import SubnetState
62
- from cloudbridge.interfaces.resources import UploadConfig
63
+ from cloudbridge.interfaces.resources import TransferConfig
63
64
  from cloudbridge.interfaces.resources import UploadPart
64
65
  from cloudbridge.interfaces.resources import VMFirewall
65
66
  from cloudbridge.interfaces.resources import VMFirewallRule
@@ -884,23 +885,124 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
884
885
  shutil.copyfileobj(
885
886
  cast("SupportsRead[bytes]", self.iter_content()), target_stream)
886
887
 
888
+ def download_to_file(self, path: str,
889
+ config: TransferConfig | None = None) -> None:
890
+ # Assemble the object in a private file alongside the destination and
891
+ # rename it into place once complete, so ``path`` only ever holds a
892
+ # whole object. Callers commonly download every copy of an object to
893
+ # one well-known path (a cache entry, say), so writing in place would
894
+ # let concurrent downloads truncate each other's file - or rename it
895
+ # away mid-transfer - and would destroy a previously downloaded copy
896
+ # when a transfer fails.
897
+ part_path = f"{path}.{uuid.uuid4().hex}.cbpart"
898
+ try:
899
+ self._download_to_path(part_path, config)
900
+ os.replace(part_path, path)
901
+ except BaseException:
902
+ try:
903
+ os.remove(part_path)
904
+ except OSError:
905
+ pass
906
+ raise
907
+
908
+ def _download_to_path(self, path: str,
909
+ config: TransferConfig | None = None) -> None:
910
+ """
911
+ Write this object's content to ``path``, which the caller owns.
912
+
913
+ Providers with an efficient, thread-safe native downloader (e.g. AWS
914
+ via boto3's ``download_file``, Azure via ``download_blob``) override
915
+ this to use it; the default implementation streams small objects and
916
+ fetches larger ones as parallel ranged reads.
917
+ """
918
+ size = self.size
919
+ if size <= self._multipart_threshold(config):
920
+ with open(path, 'wb') as f:
921
+ self.save_content(f)
922
+ return
923
+ with open(path, 'w+b') as f:
924
+ self._download_ranged(f, size, config)
925
+
926
+ def _download_ranged(self, target: IO[bytes], size: int,
927
+ config: TransferConfig | None = None) -> None:
928
+ """
929
+ Fetch the object as ranged reads across a bounded thread pool,
930
+ writing each range at its offset into a preallocated file.
931
+
932
+ To stay safe even on providers whose SDK client/connection is not
933
+ thread-safe, each worker reads through its own cloned provider (see
934
+ :meth:`.CloudProvider.clone`), so no provider state is shared between
935
+ threads. Memory is bounded to ~concurrency * part_size.
936
+ """
937
+ part_size = self._multipart_part_size(config)
938
+ if part_size < 1:
939
+ raise InvalidValueException('part_size', part_size)
940
+ concurrency = max(1, self._multipart_max_concurrency(config))
941
+ target.truncate(size)
942
+ ranges = [(offset, min(part_size, size - offset))
943
+ for offset in range(0, size, part_size)]
944
+ if concurrency == 1:
945
+ bucket_objects = self._bucket_objects
946
+ for offset, length in ranges:
947
+ target.seek(offset)
948
+ target.write(bucket_objects.download_range(
949
+ self.bucket, self.name, offset, length))
950
+ else:
951
+ self._download_ranges_concurrently(target, ranges, concurrency)
952
+
953
+ def _download_ranges_concurrently(
954
+ self, target: IO[bytes], ranges: list[tuple[int, int]],
955
+ concurrency: int) -> None:
956
+ # A pool of cloned bucket-object services, one per worker, so each
957
+ # thread touches an isolated provider/connection.
958
+ clones: "queue.Queue[BucketObjectService]" = queue.Queue()
959
+ for _ in range(concurrency):
960
+ storage = cast("BaseStorageService",
961
+ self._provider.clone().storage)
962
+ clones.put(storage._bucket_objects)
963
+
964
+ bucket = self.bucket
965
+ name = self.name
966
+ write_lock = threading.Lock()
967
+
968
+ def fetch_one(offset: int, length: int) -> None:
969
+ service = clones.get()
970
+ try:
971
+ data = service.download_range(bucket, name, offset, length)
972
+ finally:
973
+ clones.put(service)
974
+ # Ranges are fetched in parallel but written through the one
975
+ # handle the caller opened, so a range can never be written to a
976
+ # file that has since been replaced. Serializing the writes costs
977
+ # little next to the fetches, and the data is released as soon as
978
+ # it is written, bounding memory to ~concurrency * part_size.
979
+ with write_lock:
980
+ target.seek(offset)
981
+ target.write(data)
982
+
983
+ with ThreadPoolExecutor(max_workers=concurrency) as executor:
984
+ futures = [executor.submit(fetch_one, offset, length)
985
+ for offset, length in ranges]
986
+ for future in futures:
987
+ future.result()
988
+
887
989
  # The three resolvers below pick, in order of precedence: an explicit
888
- # per-call UploadConfig field, the provider/global config, then the class
990
+ # per-call TransferConfig field, the provider/global config, then the class
889
991
  # default constant.
890
- def _multipart_threshold(self, config: UploadConfig | None = None) -> int:
992
+ def _multipart_threshold(self, config: TransferConfig | None = None) -> int:
891
993
  if config is not None and config.threshold is not None:
892
994
  return int(config.threshold)
893
995
  return int(self._provider._get_config_value(
894
996
  'multipart_threshold', self.CB_MULTIPART_THRESHOLD))
895
997
 
896
- def _multipart_part_size(self, config: UploadConfig | None = None) -> int:
998
+ def _multipart_part_size(self, config: TransferConfig | None = None) -> int:
897
999
  if config is not None and config.part_size is not None:
898
1000
  return int(config.part_size)
899
1001
  return int(self._provider._get_config_value(
900
1002
  'multipart_part_size', self.CB_MULTIPART_PART_SIZE))
901
1003
 
902
1004
  def _multipart_max_concurrency(
903
- self, config: UploadConfig | None = None) -> int:
1005
+ self, config: TransferConfig | None = None) -> int:
904
1006
  if config is not None and config.max_concurrency is not None:
905
1007
  return int(config.max_concurrency)
906
1008
  return int(self._provider._get_config_value(
@@ -936,7 +1038,7 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
936
1038
  return data
937
1039
 
938
1040
  def upload(self, data: str | bytes | IO[bytes],
939
- config: UploadConfig | None = None) -> BucketObject:
1041
+ config: TransferConfig | None = None) -> BucketObject:
940
1042
  size = self._data_size(data)
941
1043
  if size is not None and size > self._multipart_threshold(config):
942
1044
  return self._upload_multipart(self._as_stream(data), config)
@@ -944,14 +1046,14 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
944
1046
 
945
1047
  def upload_from_file(
946
1048
  self, path: str,
947
- config: UploadConfig | None = None) -> BucketObject:
1049
+ config: TransferConfig | None = None) -> BucketObject:
948
1050
  if os.path.getsize(path) > self._multipart_threshold(config):
949
1051
  with open(path, 'rb') as f:
950
1052
  return self._upload_multipart(f, config)
951
1053
  return self._upload_from_file_single_shot(path)
952
1054
 
953
1055
  def _upload_multipart(self, stream: IO[bytes],
954
- config: UploadConfig | None = None) -> BucketObject:
1056
+ config: TransferConfig | None = None) -> BucketObject:
955
1057
  """
956
1058
  Drive the explicit multipart lifecycle over a stream, reading it one
957
1059
  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,41 @@ 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. The object
2392
+ is assembled out of the way and moved into place once complete,
2393
+ so ``path`` never holds a partial object: an existing file is
2394
+ replaced atomically, and a failed transfer leaves it untouched.
2395
+ Concurrent downloads to one path are therefore safe, with the
2396
+ last to complete winning.
2397
+
2398
+ :type config: :class:`.TransferConfig`
2399
+ :param config: Optional per-call transfer tuning (threshold, part
2400
+ size, concurrency). Any field left unset falls back to the
2401
+ provider/global configuration.
2402
+ """
2403
+ pass
2404
+
2371
2405
  @abstractmethod
2372
2406
  def upload(self, source_stream: IO[bytes],
2373
- config: UploadConfig | None = None) -> BucketObject | None:
2407
+ config: TransferConfig | None = None) -> BucketObject | None:
2374
2408
  """
2375
2409
  Set the contents of the object to the data read from the source stream.
2376
2410
 
2377
- :type config: :class:`.UploadConfig`
2411
+ :type config: :class:`.TransferConfig`
2378
2412
  :param config: Optional per-call upload tuning (multipart threshold,
2379
2413
  part size, concurrency). Any field left unset falls back to the
2380
2414
  provider/global configuration.
@@ -2386,7 +2420,7 @@ class BucketObject(CloudResource):
2386
2420
 
2387
2421
  @abstractmethod
2388
2422
  def upload_from_file(self, path: str,
2389
- config: UploadConfig | None = None) -> BucketObject | None:
2423
+ config: TransferConfig | None = None) -> BucketObject | None:
2390
2424
  """
2391
2425
  Store the contents of the file pointed by the "path" variable.
2392
2426
 
@@ -2396,7 +2430,7 @@ class BucketObject(CloudResource):
2396
2430
  :type path: ``str``
2397
2431
  :param path: Absolute path to the file to be uploaded to S3.
2398
2432
 
2399
- :type config: :class:`.UploadConfig`
2433
+ :type config: :class:`.TransferConfig`
2400
2434
  :param config: Optional per-call upload tuning (multipart threshold,
2401
2435
  part size, concurrency). Any field left unset falls back to the
2402
2436
  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_path(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
 
@@ -1013,6 +1023,9 @@ class AWSVMTypeService(BaseVMTypeService):
1013
1023
 
1014
1024
  def __init__(self, provider: CloudProvider) -> None:
1015
1025
  super(AWSVMTypeService, self).__init__(provider)
1026
+ # Raw instance type dicts, keyed by availability zone. See
1027
+ # _get_catalogue for why this is memoised.
1028
+ self._catalogue: dict[str | None, list[dict[str, Any]]] = {}
1016
1029
 
1017
1030
  @dispatch(event="provider.compute.vm_types.get",
1018
1031
  priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
@@ -1029,15 +1042,12 @@ class AWSVMTypeService(BaseVMTypeService):
1029
1042
  else:
1030
1043
  raise e
1031
1044
 
1032
- @dispatch(event="provider.compute.vm_types.list",
1033
- priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
1034
- def list(self, limit: int | None = None,
1035
- marker: str | None = None) -> ResultList[VMType]:
1045
+ def _fetch_catalogue(self, zone: str | None) -> list[dict[str, Any]]:
1036
1046
  client = cast("AWSCloudProvider", self.provider).ec2_conn.meta.client
1037
1047
  vmt_list_resp = client.describe_instance_type_offerings(
1038
1048
  LocationType='availability-zone',
1039
1049
  Filters=[{'Name': 'location',
1040
- 'Values': [self.provider.zone_name]}],
1050
+ 'Values': [zone]}],
1041
1051
  # MaxResults is set to max value (1000)
1042
1052
  # and client-side pagination is used
1043
1053
  **trim_empty_params({'MaxResults': 1000, 'NextToken': None}))
@@ -1046,7 +1056,7 @@ class AWSVMTypeService(BaseVMTypeService):
1046
1056
  vmt_list_resp = client.describe_instance_type_offerings(
1047
1057
  LocationType='availability-zone',
1048
1058
  Filters=[{'Name': 'location',
1049
- 'Values': [self.provider.zone_name]}],
1059
+ 'Values': [zone]}],
1050
1060
  **trim_empty_params(
1051
1061
  {'MaxResults': 1000,
1052
1062
  'NextToken': vmt_list_resp.get("NextToken")}))
@@ -1057,12 +1067,41 @@ class AWSVMTypeService(BaseVMTypeService):
1057
1067
  # describe_instance_types call can get at most 100 types at once
1058
1068
  chunks = [vmt_list_names[x:x + 100]
1059
1069
  for x in range(0, len(vmt_list_names), 100)]
1060
- raw_types = []
1070
+ raw_types: list[dict[str, Any]] = []
1061
1071
  for chunk in chunks:
1062
1072
  raw_chunk = client.describe_instance_types(
1063
1073
  InstanceTypes=chunk).get('InstanceTypes')
1064
1074
  raw_types.extend(raw_chunk)
1065
- cb_types = [AWSVMType(cast("AWSCloudProvider", self.provider), t) for t in raw_types]
1075
+ return raw_types
1076
+
1077
+ def _get_catalogue(self) -> list[dict[str, Any]]:
1078
+ """
1079
+ Return the raw instance type catalogue for the provider's zone,
1080
+ fetching it at most once per zone.
1081
+
1082
+ EC2 has no server-side paging for instance types, so ``list()`` must
1083
+ materialise the whole catalogue and page it client-side. Fetching it
1084
+ costs one ``DescribeInstanceTypeOfferings`` walk plus one
1085
+ ``DescribeInstanceTypes`` call per 100 types — around 14 calls for a
1086
+ real region. Without memoisation every page of a ``list(marker=...)``
1087
+ walk repeats all of that, making a full walk quadratic in API calls:
1088
+ 1343 types at a result limit of 5 costs ~4300 calls instead of ~14.
1089
+
1090
+ The catalogue is static for the lifetime of a provider, so it is held
1091
+ per zone (a provider may be cloned to another zone, which genuinely
1092
+ offers a different set of types).
1093
+ """
1094
+ zone = self.provider.zone_name
1095
+ if zone not in self._catalogue:
1096
+ self._catalogue[zone] = self._fetch_catalogue(zone)
1097
+ return self._catalogue[zone]
1098
+
1099
+ @dispatch(event="provider.compute.vm_types.list",
1100
+ priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
1101
+ def list(self, limit: int | None = None,
1102
+ marker: str | None = None) -> ResultList[VMType]:
1103
+ cb_types = [AWSVMType(cast("AWSCloudProvider", self.provider), t)
1104
+ for t in self._get_catalogue()]
1066
1105
  return ClientPagedResultList(self.provider, cb_types,
1067
1106
  limit=limit, marker=marker)
1068
1107
 
@@ -1664,6 +1703,14 @@ class AWSDnsZoneService(BaseDnsZoneService):
1664
1703
  client.delete_hosted_zone(Id=dns_zone.aws_id)
1665
1704
 
1666
1705
 
1706
+ # Route53 reports record changes INSYNC within seconds, but boto3's
1707
+ # resource_record_sets_changed waiter polls every 30s by default, so every
1708
+ # record change costs a full 30s of sleep no matter how fast it propagated.
1709
+ # Poll often enough that the granularity stops dominating, while keeping the
1710
+ # same ~30 minute ceiling for changes that genuinely are slow.
1711
+ DNS_CHANGE_WAITER_CONFIG = {'Delay': 5, 'MaxAttempts': 360}
1712
+
1713
+
1667
1714
  class AWSDnsRecordService(BaseDnsRecordService):
1668
1715
 
1669
1716
  def __init__(self, provider: CloudProvider) -> None:
@@ -1755,7 +1802,8 @@ class AWSDnsRecordService(BaseDnsRecordService):
1755
1802
  # waiting, this is skipped for mock tests.
1756
1803
  if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
1757
1804
  waiter = client.get_waiter('resource_record_sets_changed')
1758
- waiter.wait(Id=response.get('ChangeInfo').get('Id'))
1805
+ waiter.wait(Id=response.get('ChangeInfo').get('Id'),
1806
+ WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
1759
1807
  return cast(DnsRecord, self.get(dns_zone, name + ":" + type))
1760
1808
 
1761
1809
  def delete(self, dns_zone: DnsZone | str,
@@ -1783,4 +1831,5 @@ class AWSDnsRecordService(BaseDnsRecordService):
1783
1831
  # waiting, this is skipped for mock tests.
1784
1832
  if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
1785
1833
  waiter = client.get_waiter('resource_record_sets_changed')
1786
- waiter.wait(Id=response.get('ChangeInfo').get('Id'))
1834
+ waiter.wait(Id=response.get('ChangeInfo').get('Id'),
1835
+ WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
@@ -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_path(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.