cloudbridge 4.3.0__tar.gz → 4.4.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 (76) hide show
  1. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/PKG-INFO +1 -1
  2. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/__init__.py +1 -1
  3. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/resources.py +85 -43
  4. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/services.py +2 -1
  5. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/resources.py +41 -5
  6. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/helpers.py +55 -1
  7. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/resources.py +18 -7
  8. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/services.py +64 -11
  9. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/resources.py +18 -29
  10. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/resources.py +25 -8
  11. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/resources.py +7 -3
  12. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/PKG-INFO +1 -1
  13. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/SOURCES.txt +5 -0
  14. cloudbridge-4.4.0/tests/test_aws_dns_waiters.py +141 -0
  15. cloudbridge-4.4.0/tests/test_aws_pagination.py +119 -0
  16. cloudbridge-4.4.0/tests/test_aws_vm_types.py +166 -0
  17. cloudbridge-4.4.0/tests/test_default_network.py +68 -0
  18. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_download_driver.py +71 -1
  19. cloudbridge-4.4.0/tests/test_iter_content.py +293 -0
  20. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_object_store_service.py +108 -0
  21. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/LICENSE +0 -0
  22. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/README.rst +0 -0
  23. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/__init__.py +0 -0
  24. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/helpers.py +0 -0
  25. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/middleware.py +0 -0
  26. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/provider.py +0 -0
  27. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/subservices.py +0 -0
  28. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/factory.py +0 -0
  29. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/__init__.py +0 -0
  30. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/exceptions.py +0 -0
  31. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/provider.py +0 -0
  32. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/services.py +0 -0
  33. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/subservices.py +0 -0
  34. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/__init__.py +0 -0
  35. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/__init__.py +0 -0
  36. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/provider.py +0 -0
  37. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/subservices.py +0 -0
  38. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/__init__.py +0 -0
  39. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/azure_client.py +0 -0
  40. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/helpers.py +0 -0
  41. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/provider.py +0 -0
  42. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/services.py +0 -0
  43. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/subservices.py +0 -0
  44. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/__init__.py +0 -0
  45. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/helpers.py +0 -0
  46. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/provider.py +0 -0
  47. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/services.py +0 -0
  48. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/subservices.py +0 -0
  49. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/mock/__init__.py +0 -0
  50. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/mock/provider.py +0 -0
  51. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/__init__.py +0 -0
  52. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/helpers.py +0 -0
  53. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/provider.py +0 -0
  54. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/services.py +0 -0
  55. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/subservices.py +0 -0
  56. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/py.typed +0 -0
  57. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/dependency_links.txt +0 -0
  58. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/requires.txt +0 -0
  59. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/top_level.txt +0 -0
  60. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/pyproject.toml +0 -0
  61. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/setup.cfg +0 -0
  62. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_base_helpers.py +0 -0
  63. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_block_store_service.py +0 -0
  64. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_cloud_factory.py +0 -0
  65. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_cloud_helpers.py +0 -0
  66. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_compute_service.py +0 -0
  67. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_dns_service.py +0 -0
  68. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_image_service.py +0 -0
  69. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_interface.py +0 -0
  70. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_middleware_system.py +0 -0
  71. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_multipart_driver.py +0 -0
  72. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_network_service.py +0 -0
  73. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_object_life_cycle.py +0 -0
  74. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_region_service.py +0 -0
  75. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_security_service.py +0 -0
  76. {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_vm_types_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudbridge
3
- Version: 4.3.0
3
+ Version: 4.4.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.3.0'
6
+ __version__ = '4.4.0'
7
7
 
8
8
 
9
9
  def get_version() -> str:
@@ -8,7 +8,7 @@ import logging
8
8
  import os
9
9
  import queue
10
10
  import re
11
- import shutil
11
+ import threading
12
12
  import time
13
13
  import uuid
14
14
  from concurrent.futures import FIRST_COMPLETED
@@ -70,8 +70,6 @@ from cloudbridge.interfaces.resources import VolumeState
70
70
  from . import helpers as cb_helpers
71
71
 
72
72
  if TYPE_CHECKING:
73
- from _typeshed import SupportsRead
74
-
75
73
  from cloudbridge.base.provider import BaseCloudProvider
76
74
  from cloudbridge.base.services import BaseStorageService
77
75
  from cloudbridge.interfaces.services import BucketObjectService
@@ -839,6 +837,15 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
839
837
  # Number of parts uploaded in parallel by the transparent multipart path.
840
838
  CB_MULTIPART_MAX_CONCURRENCY = int(os.environ.get(
841
839
  'CB_MULTIPART_MAX_CONCURRENCY', 5))
840
+ # Size of each chunk yielded by the single-stream read path
841
+ # (``iter_content``/``save_content``). Sits at the knee of the
842
+ # throughput curve: reading an HTTP body at 1 MiB is ~12x cheaper per
843
+ # byte than at 4 KiB, while 4 MiB and above measure the same as 1 MiB
844
+ # and cost proportionally more memory per concurrent stream. Unrelated
845
+ # to CB_MULTIPART_PART_SIZE, which sizes the parts of a *parallel*
846
+ # transfer rather than the chunks of a sequential read.
847
+ CB_ITER_CHUNK_SIZE = int(os.environ.get(
848
+ 'CB_ITER_CHUNK_SIZE', 1024 * 1024)) # 1 MiB
842
849
 
843
850
  def __init__(self, provider: CloudProvider) -> None:
844
851
  super(BaseBucketObject, self).__init__(provider)
@@ -877,23 +884,54 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
877
884
  "in: http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMeta"
878
885
  "data.html#object-key-guidelines" % name)
879
886
 
880
- def save_content(self, target_stream: IO[bytes]) -> None:
881
- # iter_content() is declared Iterable[bytes] on the interface, but the
882
- # concrete objects returned by providers also support .read(); cast so
883
- # copyfileobj accepts it without changing behavior.
884
- shutil.copyfileobj(
885
- cast("SupportsRead[bytes]", self.iter_content()), target_stream)
887
+ def save_content(self, target_stream: IO[bytes],
888
+ chunk_size: int | None = None) -> None:
889
+ # Written in terms of iter_content so that the interface's promise -
890
+ # an Iterable[bytes] - is all a provider has to deliver. Copying via
891
+ # shutil.copyfileobj would additionally require a .read(), which not
892
+ # every provider's return value has.
893
+ for chunk in self.iter_content(chunk_size=chunk_size):
894
+ target_stream.write(chunk)
886
895
 
887
896
  def download_to_file(self, path: str,
888
897
  config: TransferConfig | None = None) -> None:
898
+ # Assemble the object in a private file alongside the destination and
899
+ # rename it into place once complete, so ``path`` only ever holds a
900
+ # whole object. Callers commonly download every copy of an object to
901
+ # one well-known path (a cache entry, say), so writing in place would
902
+ # let concurrent downloads truncate each other's file - or rename it
903
+ # away mid-transfer - and would destroy a previously downloaded copy
904
+ # when a transfer fails.
905
+ part_path = f"{path}.{uuid.uuid4().hex}.cbpart"
906
+ try:
907
+ self._download_to_path(part_path, config)
908
+ os.replace(part_path, path)
909
+ except BaseException:
910
+ try:
911
+ os.remove(part_path)
912
+ except OSError:
913
+ pass
914
+ raise
915
+
916
+ def _download_to_path(self, path: str,
917
+ config: TransferConfig | None = None) -> None:
918
+ """
919
+ Write this object's content to ``path``, which the caller owns.
920
+
921
+ Providers with an efficient, thread-safe native downloader (e.g. AWS
922
+ via boto3's ``download_file``, Azure via ``download_blob``) override
923
+ this to use it; the default implementation streams small objects and
924
+ fetches larger ones as parallel ranged reads.
925
+ """
889
926
  size = self.size
890
927
  if size <= self._multipart_threshold(config):
891
928
  with open(path, 'wb') as f:
892
929
  self.save_content(f)
893
930
  return
894
- self._download_ranged(path, size, config)
931
+ with open(path, 'w+b') as f:
932
+ self._download_ranged(f, size, config)
895
933
 
896
- def _download_ranged(self, path: str, size: int,
934
+ def _download_ranged(self, target: IO[bytes], size: int,
897
935
  config: TransferConfig | None = None) -> None:
898
936
  """
899
937
  Fetch the object as ranged reads across a bounded thread pool,
@@ -902,40 +940,26 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
902
940
  To stay safe even on providers whose SDK client/connection is not
903
941
  thread-safe, each worker reads through its own cloned provider (see
904
942
  :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.
943
+ threads. Memory is bounded to ~concurrency * part_size.
911
944
  """
912
945
  part_size = self._multipart_part_size(config)
913
946
  if part_size < 1:
914
947
  raise InvalidValueException('part_size', part_size)
915
948
  concurrency = max(1, self._multipart_max_concurrency(config))
949
+ target.truncate(size)
916
950
  ranges = [(offset, min(part_size, size - offset))
917
951
  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
952
+ if concurrency == 1:
953
+ bucket_objects = self._bucket_objects
954
+ for offset, length in ranges:
955
+ target.seek(offset)
956
+ target.write(bucket_objects.download_range(
957
+ self.bucket, self.name, offset, length))
958
+ else:
959
+ self._download_ranges_concurrently(target, ranges, concurrency)
936
960
 
937
961
  def _download_ranges_concurrently(
938
- self, path: str, ranges: list[tuple[int, int]],
962
+ self, target: IO[bytes], ranges: list[tuple[int, int]],
939
963
  concurrency: int) -> None:
940
964
  # A pool of cloned bucket-object services, one per worker, so each
941
965
  # thread touches an isolated provider/connection.
@@ -947,6 +971,7 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
947
971
 
948
972
  bucket = self.bucket
949
973
  name = self.name
974
+ write_lock = threading.Lock()
950
975
 
951
976
  def fetch_one(offset: int, length: int) -> None:
952
977
  service = clones.get()
@@ -954,13 +979,14 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
954
979
  data = service.download_range(bucket, name, offset, length)
955
980
  finally:
956
981
  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)
982
+ # Ranges are fetched in parallel but written through the one
983
+ # handle the caller opened, so a range can never be written to a
984
+ # file that has since been replaced. Serializing the writes costs
985
+ # little next to the fetches, and the data is released as soon as
986
+ # it is written, bounding memory to ~concurrency * part_size.
987
+ with write_lock:
988
+ target.seek(offset)
989
+ target.write(data)
964
990
 
965
991
  with ThreadPoolExecutor(max_workers=concurrency) as executor:
966
992
  futures = [executor.submit(fetch_one, offset, length)
@@ -990,6 +1016,22 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
990
1016
  return int(self._provider._get_config_value(
991
1017
  'multipart_max_concurrency', self.CB_MULTIPART_MAX_CONCURRENCY))
992
1018
 
1019
+ def _iter_chunk_size(self, chunk_size: int | None = None) -> int:
1020
+ """
1021
+ Resolve the chunk size for a single-stream read: an explicit
1022
+ ``chunk_size``, else the provider/global config, else the class
1023
+ default. Providers call this at the top of ``iter_content`` so the
1024
+ value is validated before any request is issued.
1025
+ """
1026
+ if chunk_size is None:
1027
+ chunk_size = int(self._provider._get_config_value(
1028
+ 'iter_chunk_size', self.CB_ITER_CHUNK_SIZE))
1029
+ else:
1030
+ chunk_size = int(chunk_size)
1031
+ if chunk_size <= 0:
1032
+ raise InvalidValueException('iter_chunk_size', chunk_size)
1033
+ return chunk_size
1034
+
993
1035
  @staticmethod
994
1036
  def _data_size(data: str | bytes | IO[bytes]) -> int | None:
995
1037
  """
@@ -320,7 +320,8 @@ class BaseNetworkService(
320
320
  log.info("Creating a CloudBridge-default network labeled %s",
321
321
  BaseNetwork.CB_DEFAULT_NETWORK_LABEL)
322
322
  return self.provider.networking.networks.create(
323
- BaseNetwork.CB_DEFAULT_NETWORK_LABEL, '10.0.0.0/16')
323
+ BaseNetwork.CB_DEFAULT_NETWORK_LABEL,
324
+ BaseNetwork.CB_DEFAULT_IPV4RANGE)
324
325
 
325
326
  @dispatch(event="provider.networking.networks.find",
326
327
  priority=BaseCloudService.STANDARD_EVENT_PRIORITY)
@@ -2357,20 +2357,52 @@ class BucketObject(CloudResource):
2357
2357
  pass
2358
2358
 
2359
2359
  @abstractmethod
2360
- def iter_content(self) -> Iterable[bytes]:
2360
+ def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
2361
2361
  """
2362
- Returns this object's content as an iterable.
2362
+ Returns this object's content as an iterable of byte chunks.
2363
+
2364
+ The object is streamed rather than held in memory, so this is safe
2365
+ for objects of any size. Chunks are sized by ``chunk_size``, never by
2366
+ the content itself - binary data is never split on newlines - and
2367
+ only the final chunk may be shorter.
2368
+
2369
+ ``chunk_size`` trades per-chunk overhead against memory and latency.
2370
+ Each chunk costs a read from the provider plus whatever the caller
2371
+ does per chunk, so small values are expensive over a large object;
2372
+ conversely a chunk is buffered in full before it is yielded, so large
2373
+ values cost memory per concurrent stream and delay the first chunk.
2374
+ The default suits most callers.
2375
+
2376
+ :type chunk_size: ``int``
2377
+ :param chunk_size: Maximum size in bytes of each chunk yielded. If
2378
+ ``None``, falls back to the provider/global configuration
2379
+ (``iter_chunk_size`` / the ``CB_ITER_CHUNK_SIZE`` setting,
2380
+ 1 MiB by default). Must be positive.
2363
2381
 
2364
2382
  :rtype: Iterable
2365
2383
  :return: An iterable of the file contents
2366
2384
 
2385
+ :raise: ``InvalidValueException`` if ``chunk_size`` is not positive.
2367
2386
  """
2368
2387
  pass
2369
2388
 
2370
2389
  @abstractmethod
2371
- def save_content(self, target_stream: IO[bytes]) -> None:
2390
+ def save_content(self, target_stream: IO[bytes],
2391
+ chunk_size: int | None = None) -> None:
2372
2392
  """
2373
2393
  Save this object and write its contents to the ``target_stream``.
2394
+
2395
+ The object is streamed through ``iter_content``, so ``chunk_size``
2396
+ means what it does there.
2397
+
2398
+ :type target_stream: ``IO[bytes]``
2399
+ :param target_stream: A writable binary stream to write to.
2400
+
2401
+ :type chunk_size: ``int``
2402
+ :param chunk_size: Maximum size in bytes of each chunk read from the
2403
+ provider. See :meth:`.iter_content`.
2404
+
2405
+ :raise: ``InvalidValueException`` if ``chunk_size`` is not positive.
2374
2406
  """
2375
2407
  pass
2376
2408
 
@@ -2388,8 +2420,12 @@ class BucketObject(CloudResource):
2388
2420
  remain single-stream alternatives for arbitrary target streams.
2389
2421
 
2390
2422
  :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.
2423
+ :param path: Local path to write the object's content to. The object
2424
+ is assembled out of the way and moved into place once complete,
2425
+ so ``path`` never holds a partial object: an existing file is
2426
+ replaced atomically, and a failed transfer leaves it untouched.
2427
+ Concurrent downloads to one path are therefore safe, with the
2428
+ last to complete winning.
2393
2429
 
2394
2430
  :type config: :class:`.TransferConfig`
2395
2431
  :param config: Optional per-call transfer tuning (threshold, part
@@ -25,6 +25,18 @@ log = logging.getLogger(__name__)
25
25
 
26
26
  T = TypeVar("T")
27
27
 
28
+ # How many records to request per call while satisfying a list method,
29
+ # overridable per provider with the ``aws_page_size`` config value. This is a
30
+ # transport setting and is deliberately not ``default_result_limit``, which
31
+ # bounds how many results the caller receives - see
32
+ # BotoGenericService._page_size for why conflating them is expensive.
33
+ #
34
+ # AWS-specific because it only means anything where the provider walks pages
35
+ # itself. GCP, Azure and OpenStack each return a single page plus a
36
+ # continuation token and let the caller drive, so there is nothing for a page
37
+ # size to amplify; boto3's paginator is the odd one out.
38
+ DEFAULT_PAGE_SIZE = 500
39
+
28
40
 
29
41
  def trim_empty_params(params_dict: dict[str, Any]) -> dict[str, Any]:
30
42
  """
@@ -153,6 +165,46 @@ class BotoGenericService(object):
153
165
  else:
154
166
  return None
155
167
 
168
+ def _page_size(self, client: Any, list_op: str) -> int:
169
+ """
170
+ Transport page size for a paginated call.
171
+
172
+ ``PageSize`` is how many items the service returns per request;
173
+ ``MaxItems`` is how many the caller asked for. Using the caller's
174
+ limit for both makes a small limit walk a large scan in tiny
175
+ increments: a filtered ``describe_images`` that takes 10.0s at a page
176
+ size of 1000 takes 977.6s at 5, for the same single result. Ask for a
177
+ full page regardless of the limit and let ``MaxItems`` do the
178
+ bounding - at worst one page more data is fetched than was wanted,
179
+ and a limit larger than a page is served over several requests.
180
+
181
+ The size comes from the ``aws_page_size`` config value so it can be
182
+ tuned per provider, then is clamped to whatever the service model
183
+ declares for this operation. EC2's bounds differ per call -
184
+ ``DescribeRouteTables`` permits 100 where most permit 1000, and
185
+ several require at least 5 - and falling outside them is a hard
186
+ ``InvalidParameterValue`` rather than a clamp, so neither the default
187
+ nor a configured value is passed through unchecked.
188
+ """
189
+ page_size = int(self.provider._get_config_value(
190
+ 'aws_page_size', DEFAULT_PAGE_SIZE))
191
+ api_name = client.meta.method_to_api_mapping.get(list_op)
192
+ if not api_name:
193
+ return page_size
194
+ input_shape = client.meta.service_model.operation_model(
195
+ api_name).input_shape
196
+ max_results = input_shape.members.get('MaxResults') if input_shape \
197
+ else None
198
+ # Not every operation declares bounds, even where the documentation
199
+ # gives them; the default is within all of the undeclared ones.
200
+ bounds = max_results.metadata if max_results else {}
201
+ ceiling, floor = bounds.get('max'), bounds.get('min')
202
+ if ceiling:
203
+ page_size = min(page_size, ceiling)
204
+ if floor:
205
+ page_size = max(page_size, floor)
206
+ return page_size
207
+
156
208
  def _get_list_operation(self) -> str:
157
209
  """
158
210
  This function discovers the list operation for a particular resource
@@ -195,7 +247,9 @@ class BotoGenericService(object):
195
247
  paginator = client.get_paginator(list_op)
196
248
  PaginationConfig: dict[str, Any] = {}
197
249
  if limit:
198
- PaginationConfig = {'MaxItems': limit, 'PageSize': limit}
250
+ PaginationConfig = {
251
+ 'MaxItems': limit,
252
+ 'PageSize': self._page_size(client, list_op)}
199
253
 
200
254
  if marker:
201
255
  PaginationConfig.update({'StartingToken': marker})
@@ -861,14 +861,21 @@ class AWSVMFirewallRule(BaseVMFirewallRule):
861
861
 
862
862
  class AWSBucketObject(BaseBucketObject):
863
863
  class BucketObjIterator():
864
- CHUNK_SIZE = 4096
864
+ """
865
+ Chunked reader over a boto3 ``StreamingBody``.
866
+
867
+ Also exposes ``read``/``close`` so the value handed back by
868
+ ``iter_content`` stays usable as a file-like object, as it has been
869
+ since this replaced boto2's ``Key``.
870
+ """
865
871
 
866
- def __init__(self, body: Any) -> None:
872
+ def __init__(self, body: Any, chunk_size: int) -> None:
867
873
  self.body = body
874
+ self.chunk_size = chunk_size
868
875
 
869
876
  def __iter__(self) -> Iterator[bytes]:
870
877
  while True:
871
- data = self.read(self.CHUNK_SIZE)
878
+ data = self.read(self.chunk_size)
872
879
  if data:
873
880
  yield data
874
881
  else:
@@ -910,8 +917,12 @@ class AWSBucketObject(BaseBucketObject):
910
917
  cast("AWSCloudProvider", self._provider).s3_conn
911
918
  .Bucket(self._obj.bucket_name))
912
919
 
913
- def iter_content(self) -> Iterable[bytes]:
914
- return self.BucketObjIterator(self._obj.get().get('Body'))
920
+ def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
921
+ # Resolve (and validate) the chunk size before the GET, so a bad
922
+ # value does not leave an unread response body behind.
923
+ chunk_size = self._iter_chunk_size(chunk_size)
924
+ return self.BucketObjIterator(
925
+ self._obj.get().get('Body'), chunk_size)
915
926
 
916
927
  def _upload_single_shot(self,
917
928
  data: str | bytes | IO[bytes]) -> BucketObject:
@@ -944,8 +955,8 @@ class AWSBucketObject(BaseBucketObject):
944
955
  self._obj.upload_file(path, Config=transfer_config)
945
956
  return self
946
957
 
947
- def download_to_file(self, path: str,
948
- config: TransferConfig | None = None) -> None:
958
+ def _download_to_path(self, path: str,
959
+ config: TransferConfig | None = None) -> None:
949
960
  # boto3's TransferManager downloads large objects as parallel ranged
950
961
  # GETs with a thread-safe client, so the transparent ranged path
951
962
  # delegates to it rather than CloudBridge's generic clone-pool driver.
@@ -806,8 +806,22 @@ class AWSImageService(BaseImageService):
806
806
  log.debug("Searching for AWS Image Service %s", label)
807
807
  obj_list.extend(
808
808
  self.svc.find(filters={'name': label}, **extra_args))
809
+ # A tag filter can only ever match images in the calling account.
810
+ # AMI tags are not visible across accounts, so an image owned by
811
+ # anyone else can never satisfy tag:Name however it is tagged -
812
+ # asking for every image carrying any visible tag, unscoped across
813
+ # a whole region, returns only this account's. Leaving Owners off
814
+ # therefore does not widen what the search can find; it only makes
815
+ # EC2 evaluate the filter against every public image in the
816
+ # region. Measured in ap-southeast-1, identical single-image
817
+ # results in 10.0s unscoped against 0.1s scoped.
818
+ #
819
+ # An explicit owners argument still wins, so a caller can ask for
820
+ # someone else's images and get the same (empty) answer as before.
821
+ tag_args = dict(extra_args)
822
+ tag_args.setdefault('Owners', ['self'])
809
823
  obj_list.extend(
810
- self.svc.find(filters={'tag:Name': label}, **extra_args))
824
+ self.svc.find(filters={'tag:Name': label}, **tag_args))
811
825
  return ClientPagedResultList(self.provider, obj_list)
812
826
 
813
827
  # Intentionally extends the base list() with a leading filter_by_owner
@@ -1023,6 +1037,9 @@ class AWSVMTypeService(BaseVMTypeService):
1023
1037
 
1024
1038
  def __init__(self, provider: CloudProvider) -> None:
1025
1039
  super(AWSVMTypeService, self).__init__(provider)
1040
+ # Raw instance type dicts, keyed by availability zone. See
1041
+ # _get_catalogue for why this is memoised.
1042
+ self._catalogue: dict[str | None, list[dict[str, Any]]] = {}
1026
1043
 
1027
1044
  @dispatch(event="provider.compute.vm_types.get",
1028
1045
  priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
@@ -1039,15 +1056,12 @@ class AWSVMTypeService(BaseVMTypeService):
1039
1056
  else:
1040
1057
  raise e
1041
1058
 
1042
- @dispatch(event="provider.compute.vm_types.list",
1043
- priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
1044
- def list(self, limit: int | None = None,
1045
- marker: str | None = None) -> ResultList[VMType]:
1059
+ def _fetch_catalogue(self, zone: str | None) -> list[dict[str, Any]]:
1046
1060
  client = cast("AWSCloudProvider", self.provider).ec2_conn.meta.client
1047
1061
  vmt_list_resp = client.describe_instance_type_offerings(
1048
1062
  LocationType='availability-zone',
1049
1063
  Filters=[{'Name': 'location',
1050
- 'Values': [self.provider.zone_name]}],
1064
+ 'Values': [zone]}],
1051
1065
  # MaxResults is set to max value (1000)
1052
1066
  # and client-side pagination is used
1053
1067
  **trim_empty_params({'MaxResults': 1000, 'NextToken': None}))
@@ -1056,7 +1070,7 @@ class AWSVMTypeService(BaseVMTypeService):
1056
1070
  vmt_list_resp = client.describe_instance_type_offerings(
1057
1071
  LocationType='availability-zone',
1058
1072
  Filters=[{'Name': 'location',
1059
- 'Values': [self.provider.zone_name]}],
1073
+ 'Values': [zone]}],
1060
1074
  **trim_empty_params(
1061
1075
  {'MaxResults': 1000,
1062
1076
  'NextToken': vmt_list_resp.get("NextToken")}))
@@ -1067,12 +1081,41 @@ class AWSVMTypeService(BaseVMTypeService):
1067
1081
  # describe_instance_types call can get at most 100 types at once
1068
1082
  chunks = [vmt_list_names[x:x + 100]
1069
1083
  for x in range(0, len(vmt_list_names), 100)]
1070
- raw_types = []
1084
+ raw_types: list[dict[str, Any]] = []
1071
1085
  for chunk in chunks:
1072
1086
  raw_chunk = client.describe_instance_types(
1073
1087
  InstanceTypes=chunk).get('InstanceTypes')
1074
1088
  raw_types.extend(raw_chunk)
1075
- cb_types = [AWSVMType(cast("AWSCloudProvider", self.provider), t) for t in raw_types]
1089
+ return raw_types
1090
+
1091
+ def _get_catalogue(self) -> list[dict[str, Any]]:
1092
+ """
1093
+ Return the raw instance type catalogue for the provider's zone,
1094
+ fetching it at most once per zone.
1095
+
1096
+ EC2 has no server-side paging for instance types, so ``list()`` must
1097
+ materialise the whole catalogue and page it client-side. Fetching it
1098
+ costs one ``DescribeInstanceTypeOfferings`` walk plus one
1099
+ ``DescribeInstanceTypes`` call per 100 types — around 14 calls for a
1100
+ real region. Without memoisation every page of a ``list(marker=...)``
1101
+ walk repeats all of that, making a full walk quadratic in API calls:
1102
+ 1343 types at a result limit of 5 costs ~4300 calls instead of ~14.
1103
+
1104
+ The catalogue is static for the lifetime of a provider, so it is held
1105
+ per zone (a provider may be cloned to another zone, which genuinely
1106
+ offers a different set of types).
1107
+ """
1108
+ zone = self.provider.zone_name
1109
+ if zone not in self._catalogue:
1110
+ self._catalogue[zone] = self._fetch_catalogue(zone)
1111
+ return self._catalogue[zone]
1112
+
1113
+ @dispatch(event="provider.compute.vm_types.list",
1114
+ priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
1115
+ def list(self, limit: int | None = None,
1116
+ marker: str | None = None) -> ResultList[VMType]:
1117
+ cb_types = [AWSVMType(cast("AWSCloudProvider", self.provider), t)
1118
+ for t in self._get_catalogue()]
1076
1119
  return ClientPagedResultList(self.provider, cb_types,
1077
1120
  limit=limit, marker=marker)
1078
1121
 
@@ -1674,6 +1717,14 @@ class AWSDnsZoneService(BaseDnsZoneService):
1674
1717
  client.delete_hosted_zone(Id=dns_zone.aws_id)
1675
1718
 
1676
1719
 
1720
+ # Route53 reports record changes INSYNC within seconds, but boto3's
1721
+ # resource_record_sets_changed waiter polls every 30s by default, so every
1722
+ # record change costs a full 30s of sleep no matter how fast it propagated.
1723
+ # Poll often enough that the granularity stops dominating, while keeping the
1724
+ # same ~30 minute ceiling for changes that genuinely are slow.
1725
+ DNS_CHANGE_WAITER_CONFIG = {'Delay': 5, 'MaxAttempts': 360}
1726
+
1727
+
1677
1728
  class AWSDnsRecordService(BaseDnsRecordService):
1678
1729
 
1679
1730
  def __init__(self, provider: CloudProvider) -> None:
@@ -1765,7 +1816,8 @@ class AWSDnsRecordService(BaseDnsRecordService):
1765
1816
  # waiting, this is skipped for mock tests.
1766
1817
  if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
1767
1818
  waiter = client.get_waiter('resource_record_sets_changed')
1768
- waiter.wait(Id=response.get('ChangeInfo').get('Id'))
1819
+ waiter.wait(Id=response.get('ChangeInfo').get('Id'),
1820
+ WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
1769
1821
  return cast(DnsRecord, self.get(dns_zone, name + ":" + type))
1770
1822
 
1771
1823
  def delete(self, dns_zone: DnsZone | str,
@@ -1793,4 +1845,5 @@ class AWSDnsRecordService(BaseDnsRecordService):
1793
1845
  # waiting, this is skipped for mock tests.
1794
1846
  if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
1795
1847
  waiter = client.get_waiter('resource_record_sets_changed')
1796
- waiter.wait(Id=response.get('ChangeInfo').get('Id'))
1848
+ waiter.wait(Id=response.get('ChangeInfo').get('Id'),
1849
+ WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
@@ -4,7 +4,6 @@ DataTypes used by this provider
4
4
  from __future__ import annotations
5
5
 
6
6
  import collections
7
- import io
8
7
  import logging
9
8
  from datetime import datetime
10
9
  from typing import Any
@@ -245,37 +244,27 @@ class AzureBucketObject(BaseBucketObject):
245
244
  """
246
245
  return self._blob_properties.last_modified.strftime("%Y-%m-%dT%H:%M:%S.%f")
247
246
 
248
- def iter_content(self) -> Iterable[bytes]:
247
+ def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
249
248
  """
250
- Returns this object's content as an
251
- iterable stream.
249
+ Returns this object's content as an iterable of byte chunks.
252
250
  """
253
-
254
- def iterable_to_stream(iterable: Iterator[bytes]) -> io.RawIOBase:
255
- class IterStream(io.RawIOBase):
256
- def __init__(self) -> None:
257
- self.leftover: bytes | None = None
258
-
259
- def readable(self) -> bool:
260
- return True
261
-
262
- def readinto(self, b: Any) -> int:
263
- try:
264
- buffer_length = len(b) # We're supposed to return at most this much
265
- chunk = self.leftover or next(iterable)
266
- output, self.leftover = chunk[:buffer_length], chunk[buffer_length:]
267
- b[:len(output)] = output
268
- return len(output)
269
- except StopIteration:
270
- return 0 # indicate EOF
271
-
272
- return IterStream()
251
+ chunk_size = self._iter_chunk_size(chunk_size)
252
+ # The downloader buffers the service's own chunks (max_chunk_get_size,
253
+ # 4 MiB by default) and read() serves chunk_size slices out of them
254
+ # over a single connection, so the caller's chunk size is independent
255
+ # of the service's. Previously this returned a RawIOBase wrapper,
256
+ # which iterates by *line* rather than by size - splitting binary
257
+ # content on b"\n", and buffering a newline-free blob whole.
258
+ downloader = self._blob_client.download_blob()
273
259
 
274
260
  def blob_iterator() -> Iterator[bytes]:
275
- for chunk in self._blob_client.download_blob().chunks():
276
- yield chunk
261
+ while True:
262
+ data = downloader.read(chunk_size)
263
+ if not data:
264
+ break
265
+ yield data
277
266
 
278
- return iterable_to_stream(blob_iterator())
267
+ return blob_iterator()
279
268
 
280
269
  @property
281
270
  def bucket(self) -> AzureBucket:
@@ -302,8 +291,8 @@ class AzureBucketObject(BaseBucketObject):
302
291
  max_concurrency=self._multipart_max_concurrency(config))
303
292
  return self
304
293
 
305
- def download_to_file(self, path: str,
306
- config: TransferConfig | None = None) -> None:
294
+ def _download_to_path(self, path: str,
295
+ config: TransferConfig | None = None) -> None:
307
296
  # azure-storage-blob's downloader fetches block ranges concurrently
308
297
  # with a thread-safe client, so delegate to it rather than
309
298
  # CloudBridge's generic clone-pool driver.