cloudbridge 4.3.1__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.1 → cloudbridge-4.4.0}/PKG-INFO +1 -1
  2. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/__init__.py +1 -1
  3. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/resources.py +33 -9
  4. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/services.py +2 -1
  5. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/resources.py +35 -3
  6. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/helpers.py +55 -1
  7. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/resources.py +16 -5
  8. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/services.py +15 -1
  9. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/resources.py +16 -27
  10. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/resources.py +25 -8
  11. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/resources.py +7 -3
  12. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/PKG-INFO +1 -1
  13. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/SOURCES.txt +3 -0
  14. cloudbridge-4.4.0/tests/test_aws_pagination.py +119 -0
  15. cloudbridge-4.4.0/tests/test_default_network.py +68 -0
  16. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_download_driver.py +1 -1
  17. cloudbridge-4.4.0/tests/test_iter_content.py +293 -0
  18. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_object_store_service.py +108 -0
  19. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/LICENSE +0 -0
  20. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/README.rst +0 -0
  21. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/__init__.py +0 -0
  22. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/helpers.py +0 -0
  23. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/middleware.py +0 -0
  24. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/provider.py +0 -0
  25. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/subservices.py +0 -0
  26. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/factory.py +0 -0
  27. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/__init__.py +0 -0
  28. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/exceptions.py +0 -0
  29. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/provider.py +0 -0
  30. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/services.py +0 -0
  31. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/subservices.py +0 -0
  32. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/__init__.py +0 -0
  33. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/__init__.py +0 -0
  34. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/provider.py +0 -0
  35. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/subservices.py +0 -0
  36. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/__init__.py +0 -0
  37. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/azure_client.py +0 -0
  38. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/helpers.py +0 -0
  39. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/provider.py +0 -0
  40. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/services.py +0 -0
  41. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/subservices.py +0 -0
  42. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/__init__.py +0 -0
  43. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/helpers.py +0 -0
  44. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/provider.py +0 -0
  45. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/services.py +0 -0
  46. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/subservices.py +0 -0
  47. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/mock/__init__.py +0 -0
  48. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/mock/provider.py +0 -0
  49. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/__init__.py +0 -0
  50. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/helpers.py +0 -0
  51. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/provider.py +0 -0
  52. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/services.py +0 -0
  53. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/subservices.py +0 -0
  54. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/py.typed +0 -0
  55. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/dependency_links.txt +0 -0
  56. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/requires.txt +0 -0
  57. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/top_level.txt +0 -0
  58. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/pyproject.toml +0 -0
  59. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/setup.cfg +0 -0
  60. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_aws_dns_waiters.py +0 -0
  61. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_aws_vm_types.py +0 -0
  62. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_base_helpers.py +0 -0
  63. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_block_store_service.py +0 -0
  64. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_cloud_factory.py +0 -0
  65. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_cloud_helpers.py +0 -0
  66. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_compute_service.py +0 -0
  67. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_dns_service.py +0 -0
  68. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_image_service.py +0 -0
  69. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_interface.py +0 -0
  70. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_middleware_system.py +0 -0
  71. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_multipart_driver.py +0 -0
  72. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_network_service.py +0 -0
  73. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_object_life_cycle.py +0 -0
  74. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_region_service.py +0 -0
  75. {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_security_service.py +0 -0
  76. {cloudbridge-4.3.1 → 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.1
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.1'
6
+ __version__ = '4.4.0'
7
7
 
8
8
 
9
9
  def get_version() -> str:
@@ -8,7 +8,6 @@ import logging
8
8
  import os
9
9
  import queue
10
10
  import re
11
- import shutil
12
11
  import threading
13
12
  import time
14
13
  import uuid
@@ -71,8 +70,6 @@ from cloudbridge.interfaces.resources import VolumeState
71
70
  from . import helpers as cb_helpers
72
71
 
73
72
  if TYPE_CHECKING:
74
- from _typeshed import SupportsRead
75
-
76
73
  from cloudbridge.base.provider import BaseCloudProvider
77
74
  from cloudbridge.base.services import BaseStorageService
78
75
  from cloudbridge.interfaces.services import BucketObjectService
@@ -840,6 +837,15 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
840
837
  # Number of parts uploaded in parallel by the transparent multipart path.
841
838
  CB_MULTIPART_MAX_CONCURRENCY = int(os.environ.get(
842
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
843
849
 
844
850
  def __init__(self, provider: CloudProvider) -> None:
845
851
  super(BaseBucketObject, self).__init__(provider)
@@ -878,12 +884,14 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
878
884
  "in: http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMeta"
879
885
  "data.html#object-key-guidelines" % name)
880
886
 
881
- def save_content(self, target_stream: IO[bytes]) -> None:
882
- # iter_content() is declared Iterable[bytes] on the interface, but the
883
- # concrete objects returned by providers also support .read(); cast so
884
- # copyfileobj accepts it without changing behavior.
885
- shutil.copyfileobj(
886
- 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)
887
895
 
888
896
  def download_to_file(self, path: str,
889
897
  config: TransferConfig | None = None) -> None:
@@ -1008,6 +1016,22 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
1008
1016
  return int(self._provider._get_config_value(
1009
1017
  'multipart_max_concurrency', self.CB_MULTIPART_MAX_CONCURRENCY))
1010
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
+
1011
1035
  @staticmethod
1012
1036
  def _data_size(data: str | bytes | IO[bytes]) -> int | None:
1013
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
 
@@ -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:
@@ -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
@@ -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:
@@ -2132,14 +2132,31 @@ class GCPBucketObject(BaseBucketObject):
2132
2132
  def last_modified(self) -> str:
2133
2133
  return self._obj['updated']
2134
2134
 
2135
- def iter_content(self) -> io.BytesIO:
2136
- provider = cast("GCPCloudProvider", self._provider)
2137
- return io.BytesIO(provider
2138
- .gcp_storage
2139
- .objects()
2140
- .get_media(bucket=self._obj['bucket'],
2141
- object=self.name)
2142
- .execute())
2135
+ def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
2136
+ """
2137
+ Returns this object's content as an iterable of byte chunks.
2138
+
2139
+ google-api-python-client buffers whole responses, so there is no
2140
+ single-connection streaming read to be had here; content is fetched
2141
+ as successive ranged GETs of ``chunk_size`` bytes instead. That keeps
2142
+ memory flat at one chunk - this previously materialised the entire
2143
+ object in a ``BytesIO`` - at the cost of one request per chunk, so
2144
+ prefer a large ``chunk_size`` when streaming a large object, or
2145
+ :meth:`.download_to_file`, which fetches ranges in parallel.
2146
+ """
2147
+ chunk_size = self._iter_chunk_size(chunk_size)
2148
+ bucket_objects = self._bucket_objects
2149
+ size = self.size
2150
+
2151
+ def range_iterator() -> Iterator[bytes]:
2152
+ offset = 0
2153
+ while offset < size:
2154
+ length = min(chunk_size, size - offset)
2155
+ yield bucket_objects.download_range(
2156
+ self.bucket, self.name, offset, length)
2157
+ offset += length
2158
+
2159
+ return range_iterator()
2143
2160
 
2144
2161
  @property
2145
2162
  def bucket(self) -> Bucket:
@@ -1389,10 +1389,14 @@ class OpenStackBucketObject(BaseBucketObject):
1389
1389
  def last_modified(self) -> str:
1390
1390
  return self._obj.get("last_modified")
1391
1391
 
1392
- def iter_content(self) -> Iterable[bytes]:
1393
- """Returns this object's content as an iterable."""
1392
+ def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
1393
+ """Returns this object's content as an iterable of byte chunks."""
1394
+ # resp_chunk_size makes swiftclient hand back a streaming _ObjectBody
1395
+ # (iterable, and file-like) instead of the whole object as bytes.
1396
+ chunk_size = self._iter_chunk_size(chunk_size)
1394
1397
  _, content = cast("OpenStackCloudProvider", self._provider).swift \
1395
- .get_object(self.cbcontainer.name, self.name, resp_chunk_size=65536)
1398
+ .get_object(self.cbcontainer.name, self.name,
1399
+ resp_chunk_size=chunk_size)
1396
1400
  return content
1397
1401
 
1398
1402
  @property
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudbridge
3
- Version: 4.3.1
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
@@ -52,16 +52,19 @@ cloudbridge/providers/openstack/resources.py
52
52
  cloudbridge/providers/openstack/services.py
53
53
  cloudbridge/providers/openstack/subservices.py
54
54
  tests/test_aws_dns_waiters.py
55
+ tests/test_aws_pagination.py
55
56
  tests/test_aws_vm_types.py
56
57
  tests/test_base_helpers.py
57
58
  tests/test_block_store_service.py
58
59
  tests/test_cloud_factory.py
59
60
  tests/test_cloud_helpers.py
60
61
  tests/test_compute_service.py
62
+ tests/test_default_network.py
61
63
  tests/test_dns_service.py
62
64
  tests/test_download_driver.py
63
65
  tests/test_image_service.py
64
66
  tests/test_interface.py
67
+ tests/test_iter_content.py
65
68
  tests/test_middleware_system.py
66
69
  tests/test_multipart_driver.py
67
70
  tests/test_network_service.py
@@ -0,0 +1,119 @@
1
+ """
2
+ Unit tests for ``BotoEC2Service`` server-side pagination.
3
+
4
+ ``_get_paginated_results`` hands boto3 a ``PaginationConfig`` built from two
5
+ separate numbers: ``MaxItems``, how many results the caller asked for, and
6
+ ``PageSize``, how many the service returns per request. They used to be the
7
+ same value, which made a small result limit walk a large scan in tiny
8
+ increments - a filtered ``describe_images`` measured 977.6s at a page size of
9
+ 5 against 10.0s at 1000, for the same single result.
10
+
11
+ Separating them moves where page boundaries fall, and the resume token
12
+ handed back to callers is defined in terms of those boundaries. These tests
13
+ pin the round trip: paging a collection with a small limit must still visit
14
+ every object exactly once and terminate, whatever the transport page size.
15
+
16
+ Exercised against moto so they run in CI without cloud credentials.
17
+ """
18
+ import unittest
19
+
20
+ from moto import mock_aws
21
+
22
+ from cloudbridge.factory import CloudProviderFactory
23
+ from cloudbridge.factory import ProviderList
24
+ from cloudbridge.providers.aws.helpers import DEFAULT_PAGE_SIZE
25
+
26
+ # More objects than one page at the result limit below, so paging is forced.
27
+ OBJECT_COUNT = 7
28
+ RESULT_LIMIT = 2
29
+
30
+
31
+ class AWSPaginationTestCase(unittest.TestCase):
32
+
33
+ def setUp(self):
34
+ self.mock = mock_aws()
35
+ self.mock.start()
36
+ self.provider = CloudProviderFactory().create_provider(
37
+ ProviderList.AWS,
38
+ {'aws_access_key': 'a', 'aws_secret_key': 'b',
39
+ 'aws_region_name': 'us-east-1',
40
+ 'default_result_limit': RESULT_LIMIT})
41
+
42
+ def tearDown(self):
43
+ self.mock.stop()
44
+
45
+ def _create_volumes(self):
46
+ created = []
47
+ for i in range(OBJECT_COUNT):
48
+ vol = self.provider.storage.volumes.create(
49
+ 'cb-page-%d' % i, 1)
50
+ created.append(vol.id)
51
+ return created
52
+
53
+ def test_paging_visits_every_object_exactly_once(self):
54
+ expected = self._create_volumes()
55
+
56
+ seen = []
57
+ page = self.provider.storage.volumes.list()
58
+ seen.extend(o.id for o in page)
59
+ pages = 1
60
+ while page.is_truncated:
61
+ page = self.provider.storage.volumes.list(marker=page.marker)
62
+ seen.extend(o.id for o in page)
63
+ pages += 1
64
+ self.assertLess(pages, OBJECT_COUNT + 5,
65
+ "Paging failed to terminate; the resume token is "
66
+ "probably not advancing.")
67
+
68
+ self.assertEqual(sorted(seen), sorted(expected),
69
+ "Paging must visit every object exactly once.")
70
+ self.assertEqual(len(seen), len(set(seen)),
71
+ "Paging returned duplicates across pages.")
72
+
73
+ def test_a_page_holds_no_more_than_the_result_limit(self):
74
+ # The caller's limit bounds what comes back, independently of how
75
+ # much was fetched per request to produce it.
76
+ self._create_volumes()
77
+ page = self.provider.storage.volumes.list()
78
+ self.assertLessEqual(len(page), RESULT_LIMIT)
79
+
80
+ def _page_size(self, list_op):
81
+ # pylint:disable=protected-access
82
+ return self.provider.storage.volumes.svc._page_size(
83
+ self.provider.ec2_conn.meta.client, list_op)
84
+
85
+ def test_transport_page_size_is_independent_of_the_result_limit(self):
86
+ # The regression this guards: PageSize tracking the result limit is
87
+ # what made a sparse scan pathological.
88
+ page_size = self._page_size('describe_volumes')
89
+ self.assertEqual(page_size, DEFAULT_PAGE_SIZE)
90
+ self.assertNotEqual(page_size, RESULT_LIMIT)
91
+
92
+ def test_page_size_is_clamped_to_the_operations_ceiling(self):
93
+ # DescribeRouteTables allows 100 where most EC2 describes allow more,
94
+ # and exceeding a ceiling is a hard InvalidParameterValue.
95
+ self.assertEqual(self._page_size('describe_route_tables'), 100)
96
+
97
+ def test_page_size_survives_an_unknown_operation(self):
98
+ self.assertEqual(self._page_size('not_an_operation'),
99
+ DEFAULT_PAGE_SIZE)
100
+
101
+ def test_configured_page_size_is_used(self):
102
+ self.provider.config['aws_page_size'] = 250
103
+ self.assertEqual(self._page_size('describe_volumes'), 250)
104
+
105
+ def test_configured_page_size_is_clamped_to_the_ceiling(self):
106
+ # A configured value is no safer than the default: DescribeRouteTables
107
+ # would reject anything above 100 outright.
108
+ self.provider.config['aws_page_size'] = 900
109
+ self.assertEqual(self._page_size('describe_route_tables'), 100)
110
+
111
+ def test_configured_page_size_is_raised_to_the_floor(self):
112
+ # Several EC2 describes require at least 5, so a smaller configured
113
+ # value would be rejected rather than honoured.
114
+ self.provider.config['aws_page_size'] = 1
115
+ self.assertEqual(self._page_size('describe_vpcs'), 5)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
@@ -0,0 +1,68 @@
1
+ """
2
+ Provider-agnostic unit tests for ``BaseNetworkService.get_or_create_default``.
3
+
4
+ AWS and GCP override this method, and the mock provider used in CI is
5
+ AWS-backed, so the base implementation - the one Azure and OpenStack actually
6
+ inherit - is never executed by the networking service suite. It is exercised
7
+ here directly against in-memory fakes.
8
+ """
9
+ import unittest
10
+ from unittest import mock
11
+
12
+ from cloudbridge.base.resources import BaseNetwork
13
+ from cloudbridge.base.services import BaseNetworkService
14
+
15
+
16
+ class _NetworkRecorder:
17
+ """Stands in for provider.networking.networks."""
18
+
19
+ def __init__(self, existing=None):
20
+ self.existing = existing or []
21
+ self.created = []
22
+ self.find_labels = []
23
+
24
+ def find(self, label=None, **kwargs):
25
+ self.find_labels.append(label)
26
+ return list(self.existing)
27
+
28
+ def create(self, label, cidr_block, **kwargs):
29
+ self.created.append((label, cidr_block))
30
+ return ("network", label, cidr_block)
31
+
32
+
33
+ class _FakeProvider:
34
+ def __init__(self, networks):
35
+ self.middleware = mock.Mock()
36
+ self.networking = mock.Mock(networks=networks)
37
+
38
+
39
+ class DefaultNetworkTestCase(unittest.TestCase):
40
+
41
+ def test_creates_default_network_with_the_default_cidr(self):
42
+ networks = _NetworkRecorder()
43
+ service = BaseNetworkService(_FakeProvider(networks))
44
+
45
+ # Patched away from the built-in so that a hardcoded 10.0.0.0/16 in
46
+ # the service cannot pass by coincidence.
47
+ with mock.patch.object(BaseNetwork, 'CB_DEFAULT_IPV4RANGE',
48
+ '192.168.0.0/16'):
49
+ service.get_or_create_default()
50
+
51
+ self.assertEqual(
52
+ networks.created,
53
+ [(BaseNetwork.CB_DEFAULT_NETWORK_LABEL, '192.168.0.0/16')],
54
+ "The default network must be created with the configured default "
55
+ "CIDR, not a hardcoded one.")
56
+
57
+ def test_returns_the_existing_default_network_without_creating(self):
58
+ networks = _NetworkRecorder(existing=["existing-net"])
59
+ service = BaseNetworkService(_FakeProvider(networks))
60
+
61
+ self.assertEqual(service.get_or_create_default(), "existing-net")
62
+ self.assertEqual(networks.created, [])
63
+ self.assertEqual(networks.find_labels,
64
+ [BaseNetwork.CB_DEFAULT_NETWORK_LABEL])
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()
@@ -107,7 +107,7 @@ class _DriverObject(BaseBucketObject):
107
107
  def bucket(self):
108
108
  return "BUCKET"
109
109
 
110
- def save_content(self, target_stream):
110
+ def save_content(self, target_stream, chunk_size=None):
111
111
  self._provider._recorder.single_shot = True
112
112
  target_stream.write(self._provider._recorder.content)
113
113