cloudbridge 4.2.0__tar.gz → 4.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/PKG-INFO +5 -2
  2. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/__init__.py +1 -1
  3. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/resources.py +92 -8
  4. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/resources.py +63 -17
  5. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/services.py +27 -0
  6. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/resources.py +28 -8
  7. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/services.py +10 -0
  8. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/azure_client.py +12 -2
  9. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/resources.py +18 -4
  10. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/services.py +8 -0
  11. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/resources.py +15 -4
  12. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/services.py +11 -0
  13. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/resources.py +23 -5
  14. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/services.py +10 -0
  15. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/PKG-INFO +5 -2
  16. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/SOURCES.txt +1 -0
  17. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/pyproject.toml +7 -2
  18. cloudbridge-4.3.0/tests/test_download_driver.py +236 -0
  19. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_multipart_driver.py +2 -2
  20. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_object_store_service.py +160 -8
  21. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/LICENSE +0 -0
  22. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/README.rst +0 -0
  23. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/__init__.py +0 -0
  24. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/helpers.py +0 -0
  25. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/middleware.py +0 -0
  26. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/provider.py +0 -0
  27. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/services.py +0 -0
  28. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/base/subservices.py +0 -0
  29. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/factory.py +0 -0
  30. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/__init__.py +0 -0
  31. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/exceptions.py +0 -0
  32. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/provider.py +0 -0
  33. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/interfaces/subservices.py +0 -0
  34. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/__init__.py +0 -0
  35. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/__init__.py +0 -0
  36. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/helpers.py +0 -0
  37. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/provider.py +0 -0
  38. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/aws/subservices.py +0 -0
  39. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/__init__.py +0 -0
  40. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/helpers.py +0 -0
  41. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/provider.py +0 -0
  42. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/azure/subservices.py +0 -0
  43. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/__init__.py +0 -0
  44. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/helpers.py +0 -0
  45. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/provider.py +0 -0
  46. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/gcp/subservices.py +0 -0
  47. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/mock/__init__.py +0 -0
  48. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/mock/provider.py +0 -0
  49. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/__init__.py +0 -0
  50. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/helpers.py +0 -0
  51. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/provider.py +0 -0
  52. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/providers/openstack/subservices.py +0 -0
  53. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge/py.typed +0 -0
  54. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/dependency_links.txt +0 -0
  55. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/requires.txt +0 -0
  56. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/cloudbridge.egg-info/top_level.txt +0 -0
  57. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/setup.cfg +0 -0
  58. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_base_helpers.py +0 -0
  59. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_block_store_service.py +0 -0
  60. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_cloud_factory.py +0 -0
  61. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_cloud_helpers.py +0 -0
  62. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_compute_service.py +0 -0
  63. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_dns_service.py +0 -0
  64. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_image_service.py +0 -0
  65. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_interface.py +0 -0
  66. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_middleware_system.py +0 -0
  67. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_network_service.py +0 -0
  68. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_object_life_cycle.py +0 -0
  69. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_region_service.py +0 -0
  70. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_security_service.py +0 -0
  71. {cloudbridge-4.2.0 → cloudbridge-4.3.0}/tests/test_vm_types_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudbridge
3
- Version: 4.2.0
3
+ Version: 4.3.0
4
4
  Summary: A simple layer of abstraction over multiple cloud providers.
5
5
  Author-email: Galaxy and GVL Projects <help@genome.edu.au>
6
6
  License-Expression: MIT
@@ -15,10 +15,13 @@ Classifier: Intended Audience :: System Administrators
15
15
  Classifier: Operating System :: OS Independent
16
16
  Classifier: Programming Language :: Python
17
17
  Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
18
21
  Classifier: Programming Language :: Python :: 3.13
19
22
  Classifier: Programming Language :: Python :: Implementation :: CPython
20
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
- Requires-Python: >=3.13
24
+ Requires-Python: >=3.10
22
25
  Description-Content-Type: text/x-rst
23
26
  License-File: LICENSE
24
27
  Requires-Dist: tenacity>=6.0
@@ -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.0'
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.
@@ -2435,7 +2465,9 @@ class BucketObject(CloudResource):
2435
2465
  pass
2436
2466
 
2437
2467
  @abstractmethod
2438
- def generate_url(self, expires_in: int, writable: bool = False) -> str:
2468
+ def generate_url(self, expires_in: int, writable: bool = False,
2469
+ content_disposition: str | None = None,
2470
+ content_type: str | None = None) -> str:
2439
2471
  """
2440
2472
  Generate a signed URL to this object.
2441
2473
 
@@ -2449,6 +2481,20 @@ class BucketObject(CloudResource):
2449
2481
  :param writable: Write permission for this signed URL. Users with the URL
2450
2482
  will be able to upload to this object, but they will NOT be able to
2451
2483
  read from it.
2484
+ :type content_disposition: ``str``
2485
+ :param content_disposition: When set, ask the backing store to serve
2486
+ the object with this ``Content-Disposition`` response header on
2487
+ GET (e.g. ``attachment; filename="data.txt"``). This is a
2488
+ response-serving hint: every provider accepts it and honors it
2489
+ where the backing store supports response-header overrides (AWS,
2490
+ Azure and GCP fully; OpenStack Swift honors the filename portion
2491
+ via its tempurl ``filename`` parameter). Ignored when
2492
+ ``writable`` is ``True``.
2493
+ :type content_type: ``str``
2494
+ :param content_type: When set, ask the backing store to serve the
2495
+ object with this ``Content-Type`` response header on GET. Honored
2496
+ by AWS, Azure and GCP; OpenStack Swift cannot override the
2497
+ content type. Ignored when ``writable`` is ``True``.
2452
2498
 
2453
2499
  :rtype: ``str``
2454
2500
  :return: A URL to access the object.
@@ -1383,6 +1383,33 @@ class BucketObjectService(CloudService):
1383
1383
  """
1384
1384
  pass
1385
1385
 
1386
+ @abstractmethod
1387
+ def download_range(self, bucket: Bucket | str, object_name: str,
1388
+ offset: int, length: int) -> bytes:
1389
+ """
1390
+ Read a byte range of an object.
1391
+
1392
+ This is the primitive behind
1393
+ :meth:`.BucketObject.download_to_file`'s parallel ranged downloads;
1394
+ it is also usable directly for partial reads.
1395
+
1396
+ :type bucket: :class:`.Bucket`
1397
+ :param bucket: The bucket containing the object.
1398
+
1399
+ :type object_name: ``str``
1400
+ :param object_name: The key of the object to read.
1401
+
1402
+ :type offset: ``int``
1403
+ :param offset: Zero-based byte offset to start reading at.
1404
+
1405
+ :type length: ``int``
1406
+ :param length: Number of bytes to read.
1407
+
1408
+ :rtype: ``bytes``
1409
+ :return: The requested byte range of the object's content.
1410
+ """
1411
+ pass
1412
+
1386
1413
 
1387
1414
  class SecurityService(CloudService):
1388
1415
 
@@ -13,7 +13,7 @@ from typing import Iterator
13
13
  from typing import TYPE_CHECKING
14
14
  from typing import cast
15
15
 
16
- from boto3.s3.transfer import TransferConfig
16
+ from boto3.s3.transfer import TransferConfig as S3TransferConfig
17
17
 
18
18
  from botocore.exceptions import ClientError
19
19
 
@@ -58,7 +58,7 @@ from cloudbridge.interfaces.resources import SnapshotState
58
58
  from cloudbridge.interfaces.resources import Subnet
59
59
  from cloudbridge.interfaces.resources import SubnetState
60
60
  from cloudbridge.interfaces.resources import TrafficDirection
61
- from cloudbridge.interfaces.resources import 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,26 +932,46 @@ 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
 
950
- def generate_url(self, expires_in: int, writable: bool = False) -> str:
962
+ def generate_url(self, expires_in: int, writable: bool = False,
963
+ content_disposition: str | None = None,
964
+ content_type: str | None = None) -> str:
965
+ params = {'Bucket': self._obj.bucket_name, 'Key': self.id}
966
+ if not writable:
967
+ if content_disposition:
968
+ params['ResponseContentDisposition'] = content_disposition
969
+ if content_type:
970
+ params['ResponseContentType'] = content_type
951
971
  return cast("AWSCloudProvider", self._provider).s3_conn.meta.client \
952
972
  .generate_presigned_url(
953
973
  'put_object' if writable else 'get_object',
954
- Params={'Bucket': self._obj.bucket_name, 'Key': self.id},
974
+ Params=params,
955
975
  ExpiresIn=expires_in)
956
976
 
957
977
  def refresh(self) -> None:
@@ -733,6 +733,16 @@ class AWSBucketObjectService(BaseBucketObjectService):
733
733
  Bucket=cast(Any, bucket).name, Key=upload.object_name,
734
734
  UploadId=upload.id)
735
735
 
736
+ @dispatch(event="provider.storage._bucket_objects.download_range",
737
+ priority=BaseBucketObjectService.STANDARD_EVENT_PRIORITY)
738
+ def download_range(self, bucket: Bucket | str, object_name: str,
739
+ offset: int, length: int) -> bytes:
740
+ client = cast("AWSCloudProvider", self.provider).s3_conn.meta.client
741
+ response = client.get_object(
742
+ Bucket=cast(Any, bucket).name, Key=object_name,
743
+ Range='bytes=%d-%d' % (offset, offset + length - 1))
744
+ return cast(bytes, response['Body'].read())
745
+
736
746
 
737
747
  class AWSComputeService(BaseComputeService):
738
748
 
@@ -507,8 +507,16 @@ class AzureClient(object):
507
507
  blob_client = self.blob_client(container_name, blob_name)
508
508
  blob_client.delete_blob(delete_snapshots)
509
509
 
510
+ def get_blob_range(self, container_name: str, blob_name: str,
511
+ offset: int, length: int) -> bytes:
512
+ blob_client = self.blob_client(container_name, blob_name)
513
+ downloader = blob_client.download_blob(offset=offset, length=length)
514
+ return downloader.readall()
515
+
510
516
  def get_blob_url(self, container_name: Any, blob_name: str,
511
- expiry_time: int, writable: bool) -> str:
517
+ expiry_time: int, writable: bool,
518
+ content_disposition: str | None = None,
519
+ content_type: str | None = None) -> str:
512
520
  now = datetime.datetime.utcnow()
513
521
  expiry = now + datetime.timedelta(
514
522
  seconds=expiry_time)
@@ -520,7 +528,9 @@ class AzureClient(object):
520
528
  sas = generate_blob_sas(
521
529
  self.storage_account, container_name, blob_name,
522
530
  permission=BlobSasPermissions(read=True, write=writable), expiry=expiry,
523
- user_delegation_key=delegation_key
531
+ user_delegation_key=delegation_key,
532
+ content_disposition=content_disposition,
533
+ content_type=content_type
524
534
  )
525
535
  url = f"https://{self.storage_account}.blob.core.windows.net/{container_name}/{blob_name}?{sas}"
526
536
  return url
@@ -62,7 +62,7 @@ from cloudbridge.interfaces.resources import SnapshotState
62
62
  from cloudbridge.interfaces.resources import Subnet
63
63
  from cloudbridge.interfaces.resources import SubnetState
64
64
  from cloudbridge.interfaces.resources import TrafficDirection
65
- from cloudbridge.interfaces.resources import 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.
@@ -311,13 +321,17 @@ class AzureBucketObject(BaseBucketObject):
311
321
  """
312
322
  self._blob_client.delete_blob()
313
323
 
314
- def generate_url(self, expires_in: int, writable: bool = False) -> str:
324
+ def generate_url(self, expires_in: int, writable: bool = False,
325
+ content_disposition: str | None = None,
326
+ content_type: str | None = None) -> str:
315
327
  """
316
328
  Generate a URL to this object.
317
329
  """
318
330
  return cast(
319
331
  "AzureCloudProvider", self._provider).azure_client.get_blob_url(
320
- self._container, self.name, expires_in, writable)
332
+ self._container, self.name, expires_in, writable,
333
+ content_disposition=None if writable else content_disposition,
334
+ content_type=None if writable else content_type)
321
335
 
322
336
  def refresh(self) -> None:
323
337
  pass
@@ -766,6 +766,14 @@ class AzureBucketObjectService(BaseBucketObjectService):
766
766
  "%s/%s will expire automatically.",
767
767
  cast("Bucket", bucket).name, upload.object_name)
768
768
 
769
+ @dispatch(event="provider.storage._bucket_objects.download_range",
770
+ priority=BaseBucketObjectService.STANDARD_EVENT_PRIORITY)
771
+ def download_range(self, bucket: Bucket | str, object_name: str,
772
+ offset: int, length: int) -> bytes:
773
+ azure_client = cast("AzureCloudProvider", self.provider).azure_client
774
+ return cast(bytes, azure_client.get_blob_range(
775
+ cast("Bucket", bucket).name, object_name, offset, length))
776
+
769
777
 
770
778
  class AzureComputeService(BaseComputeService):
771
779
  def __init__(self, provider: CloudProvider) -> None:
@@ -81,7 +81,7 @@ if TYPE_CHECKING:
81
81
  from cloudbridge.interfaces.resources import Snapshot
82
82
  from cloudbridge.interfaces.resources import Subnet
83
83
  from cloudbridge.interfaces.resources import SubnetSubService
84
- from cloudbridge.interfaces.resources import 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
 
@@ -2198,7 +2198,9 @@ class GCPBucketObject(BaseBucketObject):
2198
2198
  .delete(bucket=self._obj['bucket'], object=self.name)
2199
2199
  .execute())
2200
2200
 
2201
- def generate_url(self, expires_in: int, writable: bool = False) -> str:
2201
+ def generate_url(self, expires_in: int, writable: bool = False,
2202
+ content_disposition: str | None = None,
2203
+ content_type: str | None = None) -> str:
2202
2204
  """
2203
2205
  Generates a signed URL accessible to everyone.
2204
2206
 
@@ -2210,10 +2212,19 @@ class GCPBucketObject(BaseBucketObject):
2210
2212
  provider = cast("GCPCloudProvider", self._provider)
2211
2213
  http_method = "PUT" if writable else "GET"
2212
2214
 
2215
+ query_parameters: dict[str, Any] = {}
2216
+ if not writable:
2217
+ if content_disposition:
2218
+ query_parameters[
2219
+ 'response-content-disposition'] = content_disposition
2220
+ if content_type:
2221
+ query_parameters['response-content-type'] = content_type
2222
+
2213
2223
  # pylint:disable=protected-access
2214
2224
  return helpers.generate_signed_url(
2215
2225
  provider._credentials, self._obj['bucket'], self.name,
2216
- expiration=expires_in, http_method=http_method)
2226
+ expiration=expires_in, http_method=http_method,
2227
+ query_parameters=query_parameters or None)
2217
2228
 
2218
2229
  def refresh(self) -> None:
2219
2230
  # pylint:disable=protected-access
@@ -1769,6 +1769,17 @@ class GCPBucketObjectService(BaseBucketObjectService):
1769
1769
  self._delete_objects(bucket, self._list_temp_objects(bucket, upload),
1770
1770
  ignore_missing=True)
1771
1771
 
1772
+ @dispatch(event="provider.storage._bucket_objects.download_range",
1773
+ priority=BaseBucketObjectService.STANDARD_EVENT_PRIORITY)
1774
+ def download_range(self, bucket: Bucket | str, object_name: str,
1775
+ offset: int, length: int) -> bytes:
1776
+ provider = cast("GCPCloudProvider", self.provider)
1777
+ request = provider.gcp_storage.objects().get_media(
1778
+ bucket=cast(Any, bucket).name, object=object_name)
1779
+ request.headers['range'] = 'bytes=%d-%d' % (
1780
+ offset, offset + length - 1)
1781
+ return cast(bytes, request.execute())
1782
+
1772
1783
  def _compose(self, bucket: Bucket | str, upload: MultipartUpload,
1773
1784
  destination: str, sources: builtins.list[str]) -> builtins.list[str]:
1774
1785
  """