cloudbridge 4.3.0__tar.gz → 4.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/PKG-INFO +1 -1
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/__init__.py +1 -1
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/resources.py +52 -34
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/resources.py +6 -2
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/resources.py +2 -2
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/services.py +49 -10
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/resources.py +2 -2
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/PKG-INFO +1 -1
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/SOURCES.txt +2 -0
- cloudbridge-4.3.1/tests/test_aws_dns_waiters.py +141 -0
- cloudbridge-4.3.1/tests/test_aws_vm_types.py +166 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_download_driver.py +70 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/LICENSE +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/README.rst +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/middleware.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/services.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/factory.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/exceptions.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/services.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/azure_client.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/services.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/resources.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/services.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/mock/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/mock/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/resources.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/services.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/py.typed +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/dependency_links.txt +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/requires.txt +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/top_level.txt +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/pyproject.toml +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/setup.cfg +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_base_helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_block_store_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_cloud_factory.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_cloud_helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_compute_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_dns_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_image_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_interface.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_middleware_system.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_multipart_driver.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_network_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_object_life_cycle.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_object_store_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_region_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_security_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_vm_types_service.py +0 -0
|
@@ -9,6 +9,7 @@ import os
|
|
|
9
9
|
import queue
|
|
10
10
|
import re
|
|
11
11
|
import shutil
|
|
12
|
+
import threading
|
|
12
13
|
import time
|
|
13
14
|
import uuid
|
|
14
15
|
from concurrent.futures import FIRST_COMPLETED
|
|
@@ -886,14 +887,43 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
886
887
|
|
|
887
888
|
def download_to_file(self, path: str,
|
|
888
889
|
config: TransferConfig | None = None) -> None:
|
|
890
|
+
# Assemble the object in a private file alongside the destination and
|
|
891
|
+
# rename it into place once complete, so ``path`` only ever holds a
|
|
892
|
+
# whole object. Callers commonly download every copy of an object to
|
|
893
|
+
# one well-known path (a cache entry, say), so writing in place would
|
|
894
|
+
# let concurrent downloads truncate each other's file - or rename it
|
|
895
|
+
# away mid-transfer - and would destroy a previously downloaded copy
|
|
896
|
+
# when a transfer fails.
|
|
897
|
+
part_path = f"{path}.{uuid.uuid4().hex}.cbpart"
|
|
898
|
+
try:
|
|
899
|
+
self._download_to_path(part_path, config)
|
|
900
|
+
os.replace(part_path, path)
|
|
901
|
+
except BaseException:
|
|
902
|
+
try:
|
|
903
|
+
os.remove(part_path)
|
|
904
|
+
except OSError:
|
|
905
|
+
pass
|
|
906
|
+
raise
|
|
907
|
+
|
|
908
|
+
def _download_to_path(self, path: str,
|
|
909
|
+
config: TransferConfig | None = None) -> None:
|
|
910
|
+
"""
|
|
911
|
+
Write this object's content to ``path``, which the caller owns.
|
|
912
|
+
|
|
913
|
+
Providers with an efficient, thread-safe native downloader (e.g. AWS
|
|
914
|
+
via boto3's ``download_file``, Azure via ``download_blob``) override
|
|
915
|
+
this to use it; the default implementation streams small objects and
|
|
916
|
+
fetches larger ones as parallel ranged reads.
|
|
917
|
+
"""
|
|
889
918
|
size = self.size
|
|
890
919
|
if size <= self._multipart_threshold(config):
|
|
891
920
|
with open(path, 'wb') as f:
|
|
892
921
|
self.save_content(f)
|
|
893
922
|
return
|
|
894
|
-
|
|
923
|
+
with open(path, 'w+b') as f:
|
|
924
|
+
self._download_ranged(f, size, config)
|
|
895
925
|
|
|
896
|
-
def _download_ranged(self,
|
|
926
|
+
def _download_ranged(self, target: IO[bytes], size: int,
|
|
897
927
|
config: TransferConfig | None = None) -> None:
|
|
898
928
|
"""
|
|
899
929
|
Fetch the object as ranged reads across a bounded thread pool,
|
|
@@ -902,40 +932,26 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
902
932
|
To stay safe even on providers whose SDK client/connection is not
|
|
903
933
|
thread-safe, each worker reads through its own cloned provider (see
|
|
904
934
|
:meth:`.CloudProvider.clone`), so no provider state is shared between
|
|
905
|
-
threads. Memory is bounded to ~concurrency * part_size.
|
|
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.
|
|
935
|
+
threads. Memory is bounded to ~concurrency * part_size.
|
|
911
936
|
"""
|
|
912
937
|
part_size = self._multipart_part_size(config)
|
|
913
938
|
if part_size < 1:
|
|
914
939
|
raise InvalidValueException('part_size', part_size)
|
|
915
940
|
concurrency = max(1, self._multipart_max_concurrency(config))
|
|
941
|
+
target.truncate(size)
|
|
916
942
|
ranges = [(offset, min(part_size, size - offset))
|
|
917
943
|
for offset in range(0, size, part_size)]
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
bucket_objects
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
|
944
|
+
if concurrency == 1:
|
|
945
|
+
bucket_objects = self._bucket_objects
|
|
946
|
+
for offset, length in ranges:
|
|
947
|
+
target.seek(offset)
|
|
948
|
+
target.write(bucket_objects.download_range(
|
|
949
|
+
self.bucket, self.name, offset, length))
|
|
950
|
+
else:
|
|
951
|
+
self._download_ranges_concurrently(target, ranges, concurrency)
|
|
936
952
|
|
|
937
953
|
def _download_ranges_concurrently(
|
|
938
|
-
self,
|
|
954
|
+
self, target: IO[bytes], ranges: list[tuple[int, int]],
|
|
939
955
|
concurrency: int) -> None:
|
|
940
956
|
# A pool of cloned bucket-object services, one per worker, so each
|
|
941
957
|
# thread touches an isolated provider/connection.
|
|
@@ -947,6 +963,7 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
947
963
|
|
|
948
964
|
bucket = self.bucket
|
|
949
965
|
name = self.name
|
|
966
|
+
write_lock = threading.Lock()
|
|
950
967
|
|
|
951
968
|
def fetch_one(offset: int, length: int) -> None:
|
|
952
969
|
service = clones.get()
|
|
@@ -954,13 +971,14 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
954
971
|
data = service.download_range(bucket, name, offset, length)
|
|
955
972
|
finally:
|
|
956
973
|
clones.put(service)
|
|
957
|
-
#
|
|
958
|
-
#
|
|
959
|
-
#
|
|
960
|
-
#
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
974
|
+
# Ranges are fetched in parallel but written through the one
|
|
975
|
+
# handle the caller opened, so a range can never be written to a
|
|
976
|
+
# file that has since been replaced. Serializing the writes costs
|
|
977
|
+
# little next to the fetches, and the data is released as soon as
|
|
978
|
+
# it is written, bounding memory to ~concurrency * part_size.
|
|
979
|
+
with write_lock:
|
|
980
|
+
target.seek(offset)
|
|
981
|
+
target.write(data)
|
|
964
982
|
|
|
965
983
|
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
966
984
|
futures = [executor.submit(fetch_one, offset, length)
|
|
@@ -2388,8 +2388,12 @@ class BucketObject(CloudResource):
|
|
|
2388
2388
|
remain single-stream alternatives for arbitrary target streams.
|
|
2389
2389
|
|
|
2390
2390
|
:type path: ``str``
|
|
2391
|
-
:param path: Local path to write the object's content to.
|
|
2392
|
-
|
|
2391
|
+
:param path: Local path to write the object's content to. The object
|
|
2392
|
+
is assembled out of the way and moved into place once complete,
|
|
2393
|
+
so ``path`` never holds a partial object: an existing file is
|
|
2394
|
+
replaced atomically, and a failed transfer leaves it untouched.
|
|
2395
|
+
Concurrent downloads to one path are therefore safe, with the
|
|
2396
|
+
last to complete winning.
|
|
2393
2397
|
|
|
2394
2398
|
:type config: :class:`.TransferConfig`
|
|
2395
2399
|
:param config: Optional per-call transfer tuning (threshold, part
|
|
@@ -944,8 +944,8 @@ class AWSBucketObject(BaseBucketObject):
|
|
|
944
944
|
self._obj.upload_file(path, Config=transfer_config)
|
|
945
945
|
return self
|
|
946
946
|
|
|
947
|
-
def
|
|
948
|
-
|
|
947
|
+
def _download_to_path(self, path: str,
|
|
948
|
+
config: TransferConfig | None = None) -> None:
|
|
949
949
|
# boto3's TransferManager downloads large objects as parallel ranged
|
|
950
950
|
# GETs with a thread-safe client, so the transparent ranged path
|
|
951
951
|
# delegates to it rather than CloudBridge's generic clone-pool driver.
|
|
@@ -1023,6 +1023,9 @@ class AWSVMTypeService(BaseVMTypeService):
|
|
|
1023
1023
|
|
|
1024
1024
|
def __init__(self, provider: CloudProvider) -> None:
|
|
1025
1025
|
super(AWSVMTypeService, self).__init__(provider)
|
|
1026
|
+
# Raw instance type dicts, keyed by availability zone. See
|
|
1027
|
+
# _get_catalogue for why this is memoised.
|
|
1028
|
+
self._catalogue: dict[str | None, list[dict[str, Any]]] = {}
|
|
1026
1029
|
|
|
1027
1030
|
@dispatch(event="provider.compute.vm_types.get",
|
|
1028
1031
|
priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
|
|
@@ -1039,15 +1042,12 @@ class AWSVMTypeService(BaseVMTypeService):
|
|
|
1039
1042
|
else:
|
|
1040
1043
|
raise e
|
|
1041
1044
|
|
|
1042
|
-
|
|
1043
|
-
priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
|
|
1044
|
-
def list(self, limit: int | None = None,
|
|
1045
|
-
marker: str | None = None) -> ResultList[VMType]:
|
|
1045
|
+
def _fetch_catalogue(self, zone: str | None) -> list[dict[str, Any]]:
|
|
1046
1046
|
client = cast("AWSCloudProvider", self.provider).ec2_conn.meta.client
|
|
1047
1047
|
vmt_list_resp = client.describe_instance_type_offerings(
|
|
1048
1048
|
LocationType='availability-zone',
|
|
1049
1049
|
Filters=[{'Name': 'location',
|
|
1050
|
-
'Values': [
|
|
1050
|
+
'Values': [zone]}],
|
|
1051
1051
|
# MaxResults is set to max value (1000)
|
|
1052
1052
|
# and client-side pagination is used
|
|
1053
1053
|
**trim_empty_params({'MaxResults': 1000, 'NextToken': None}))
|
|
@@ -1056,7 +1056,7 @@ class AWSVMTypeService(BaseVMTypeService):
|
|
|
1056
1056
|
vmt_list_resp = client.describe_instance_type_offerings(
|
|
1057
1057
|
LocationType='availability-zone',
|
|
1058
1058
|
Filters=[{'Name': 'location',
|
|
1059
|
-
'Values': [
|
|
1059
|
+
'Values': [zone]}],
|
|
1060
1060
|
**trim_empty_params(
|
|
1061
1061
|
{'MaxResults': 1000,
|
|
1062
1062
|
'NextToken': vmt_list_resp.get("NextToken")}))
|
|
@@ -1067,12 +1067,41 @@ class AWSVMTypeService(BaseVMTypeService):
|
|
|
1067
1067
|
# describe_instance_types call can get at most 100 types at once
|
|
1068
1068
|
chunks = [vmt_list_names[x:x + 100]
|
|
1069
1069
|
for x in range(0, len(vmt_list_names), 100)]
|
|
1070
|
-
raw_types = []
|
|
1070
|
+
raw_types: list[dict[str, Any]] = []
|
|
1071
1071
|
for chunk in chunks:
|
|
1072
1072
|
raw_chunk = client.describe_instance_types(
|
|
1073
1073
|
InstanceTypes=chunk).get('InstanceTypes')
|
|
1074
1074
|
raw_types.extend(raw_chunk)
|
|
1075
|
-
|
|
1075
|
+
return raw_types
|
|
1076
|
+
|
|
1077
|
+
def _get_catalogue(self) -> list[dict[str, Any]]:
|
|
1078
|
+
"""
|
|
1079
|
+
Return the raw instance type catalogue for the provider's zone,
|
|
1080
|
+
fetching it at most once per zone.
|
|
1081
|
+
|
|
1082
|
+
EC2 has no server-side paging for instance types, so ``list()`` must
|
|
1083
|
+
materialise the whole catalogue and page it client-side. Fetching it
|
|
1084
|
+
costs one ``DescribeInstanceTypeOfferings`` walk plus one
|
|
1085
|
+
``DescribeInstanceTypes`` call per 100 types — around 14 calls for a
|
|
1086
|
+
real region. Without memoisation every page of a ``list(marker=...)``
|
|
1087
|
+
walk repeats all of that, making a full walk quadratic in API calls:
|
|
1088
|
+
1343 types at a result limit of 5 costs ~4300 calls instead of ~14.
|
|
1089
|
+
|
|
1090
|
+
The catalogue is static for the lifetime of a provider, so it is held
|
|
1091
|
+
per zone (a provider may be cloned to another zone, which genuinely
|
|
1092
|
+
offers a different set of types).
|
|
1093
|
+
"""
|
|
1094
|
+
zone = self.provider.zone_name
|
|
1095
|
+
if zone not in self._catalogue:
|
|
1096
|
+
self._catalogue[zone] = self._fetch_catalogue(zone)
|
|
1097
|
+
return self._catalogue[zone]
|
|
1098
|
+
|
|
1099
|
+
@dispatch(event="provider.compute.vm_types.list",
|
|
1100
|
+
priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
|
|
1101
|
+
def list(self, limit: int | None = None,
|
|
1102
|
+
marker: str | None = None) -> ResultList[VMType]:
|
|
1103
|
+
cb_types = [AWSVMType(cast("AWSCloudProvider", self.provider), t)
|
|
1104
|
+
for t in self._get_catalogue()]
|
|
1076
1105
|
return ClientPagedResultList(self.provider, cb_types,
|
|
1077
1106
|
limit=limit, marker=marker)
|
|
1078
1107
|
|
|
@@ -1674,6 +1703,14 @@ class AWSDnsZoneService(BaseDnsZoneService):
|
|
|
1674
1703
|
client.delete_hosted_zone(Id=dns_zone.aws_id)
|
|
1675
1704
|
|
|
1676
1705
|
|
|
1706
|
+
# Route53 reports record changes INSYNC within seconds, but boto3's
|
|
1707
|
+
# resource_record_sets_changed waiter polls every 30s by default, so every
|
|
1708
|
+
# record change costs a full 30s of sleep no matter how fast it propagated.
|
|
1709
|
+
# Poll often enough that the granularity stops dominating, while keeping the
|
|
1710
|
+
# same ~30 minute ceiling for changes that genuinely are slow.
|
|
1711
|
+
DNS_CHANGE_WAITER_CONFIG = {'Delay': 5, 'MaxAttempts': 360}
|
|
1712
|
+
|
|
1713
|
+
|
|
1677
1714
|
class AWSDnsRecordService(BaseDnsRecordService):
|
|
1678
1715
|
|
|
1679
1716
|
def __init__(self, provider: CloudProvider) -> None:
|
|
@@ -1765,7 +1802,8 @@ class AWSDnsRecordService(BaseDnsRecordService):
|
|
|
1765
1802
|
# waiting, this is skipped for mock tests.
|
|
1766
1803
|
if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
|
|
1767
1804
|
waiter = client.get_waiter('resource_record_sets_changed')
|
|
1768
|
-
waiter.wait(Id=response.get('ChangeInfo').get('Id')
|
|
1805
|
+
waiter.wait(Id=response.get('ChangeInfo').get('Id'),
|
|
1806
|
+
WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
|
|
1769
1807
|
return cast(DnsRecord, self.get(dns_zone, name + ":" + type))
|
|
1770
1808
|
|
|
1771
1809
|
def delete(self, dns_zone: DnsZone | str,
|
|
@@ -1793,4 +1831,5 @@ class AWSDnsRecordService(BaseDnsRecordService):
|
|
|
1793
1831
|
# waiting, this is skipped for mock tests.
|
|
1794
1832
|
if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
|
|
1795
1833
|
waiter = client.get_waiter('resource_record_sets_changed')
|
|
1796
|
-
waiter.wait(Id=response.get('ChangeInfo').get('Id')
|
|
1834
|
+
waiter.wait(Id=response.get('ChangeInfo').get('Id'),
|
|
1835
|
+
WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
|
|
@@ -302,8 +302,8 @@ class AzureBucketObject(BaseBucketObject):
|
|
|
302
302
|
max_concurrency=self._multipart_max_concurrency(config))
|
|
303
303
|
return self
|
|
304
304
|
|
|
305
|
-
def
|
|
306
|
-
|
|
305
|
+
def _download_to_path(self, path: str,
|
|
306
|
+
config: TransferConfig | None = None) -> None:
|
|
307
307
|
# azure-storage-blob's downloader fetches block ranges concurrently
|
|
308
308
|
# with a thread-safe client, so delegate to it rather than
|
|
309
309
|
# CloudBridge's generic clone-pool driver.
|
|
@@ -51,6 +51,8 @@ cloudbridge/providers/openstack/provider.py
|
|
|
51
51
|
cloudbridge/providers/openstack/resources.py
|
|
52
52
|
cloudbridge/providers/openstack/services.py
|
|
53
53
|
cloudbridge/providers/openstack/subservices.py
|
|
54
|
+
tests/test_aws_dns_waiters.py
|
|
55
|
+
tests/test_aws_vm_types.py
|
|
54
56
|
tests/test_base_helpers.py
|
|
55
57
|
tests/test_block_store_service.py
|
|
56
58
|
tests/test_cloud_factory.py
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for how ``AWSDnsRecordService`` waits on Route53 changes.
|
|
3
|
+
|
|
4
|
+
Creating or deleting a record blocks until Route53 reports the change INSYNC.
|
|
5
|
+
boto3's ``resource_record_sets_changed`` waiter polls every 30 seconds by
|
|
6
|
+
default, so a change that propagates in a few seconds still costs a full 30 --
|
|
7
|
+
and a test that makes four record changes pays 120 seconds of pure sleep.
|
|
8
|
+
Measured against real Route53, INSYNC was reached within the first poll
|
|
9
|
+
interval every time, making the granularity the entire cost.
|
|
10
|
+
|
|
11
|
+
These tests drive the real botocore waiter against a simulated clock, so they
|
|
12
|
+
assert on how long we *would* sleep without actually sleeping.
|
|
13
|
+
"""
|
|
14
|
+
import unittest
|
|
15
|
+
from unittest import mock
|
|
16
|
+
|
|
17
|
+
import botocore.client
|
|
18
|
+
import botocore.waiter
|
|
19
|
+
from botocore.exceptions import WaiterError
|
|
20
|
+
|
|
21
|
+
from cloudbridge.providers.aws import AWSCloudProvider
|
|
22
|
+
from cloudbridge.providers.aws.resources import AWSDnsRecord
|
|
23
|
+
from cloudbridge.providers.aws.resources import AWSDnsZone
|
|
24
|
+
from cloudbridge.providers.aws.services import AWSDnsRecordService
|
|
25
|
+
|
|
26
|
+
# Simulated seconds before Route53 reports INSYNC. Real-world measurement put
|
|
27
|
+
# this comfortably inside one 30s poll interval.
|
|
28
|
+
INSYNC_AFTER = 6.0
|
|
29
|
+
BOTO_DEFAULT_DELAY = 30.0
|
|
30
|
+
# The waiter's ceiling must stay at roughly 30 minutes however it is polled.
|
|
31
|
+
REQUIRED_CEILING = 1700.0
|
|
32
|
+
|
|
33
|
+
ZONE = {'Id': '/hostedzone/Z1EXAMPLE', 'Name': 'example.com.'}
|
|
34
|
+
RECORD = {'Name': 'sub.example.com.', 'Type': 'CNAME', 'TTL': 500,
|
|
35
|
+
'ResourceRecords': [{'Value': 'hello.com.'}]}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Route53Sim:
|
|
39
|
+
"""Canned Route53 responses driven by a simulated clock."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, insync_after=INSYNC_AFTER):
|
|
42
|
+
self.insync_after = insync_after
|
|
43
|
+
self.clock = 0.0
|
|
44
|
+
self.sleeps = []
|
|
45
|
+
self.get_change_calls = 0
|
|
46
|
+
|
|
47
|
+
def api(self, operation_name, params):
|
|
48
|
+
if operation_name == 'ChangeResourceRecordSets':
|
|
49
|
+
return {'ChangeInfo': {'Id': '/change/C1', 'Status': 'PENDING'}}
|
|
50
|
+
if operation_name == 'GetChange':
|
|
51
|
+
self.get_change_calls += 1
|
|
52
|
+
status = ('INSYNC' if self.clock >= self.insync_after
|
|
53
|
+
else 'PENDING')
|
|
54
|
+
return {'ChangeInfo': {'Id': '/change/C1', 'Status': status}}
|
|
55
|
+
if operation_name == 'ListResourceRecordSets':
|
|
56
|
+
return {'ResourceRecordSets': [RECORD], 'IsTruncated': False}
|
|
57
|
+
raise AssertionError('unexpected operation: ' + operation_name)
|
|
58
|
+
|
|
59
|
+
def sleep(self, secs):
|
|
60
|
+
self.sleeps.append(secs)
|
|
61
|
+
self.clock += secs
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def total_wait(self):
|
|
65
|
+
return sum(self.sleeps)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _provider():
|
|
69
|
+
return AWSCloudProvider({'aws_access_key': 'dummy',
|
|
70
|
+
'aws_secret_key': 'dummy',
|
|
71
|
+
'aws_zone_name': 'us-east-1a'})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run(sim, fn):
|
|
75
|
+
"""Run fn with Route53 stubbed and the waiter's clock simulated."""
|
|
76
|
+
with mock.patch.object(botocore.client.BaseClient, '_make_api_call',
|
|
77
|
+
lambda self, op, params: sim.api(op, params)), \
|
|
78
|
+
mock.patch.object(botocore.waiter.time, 'sleep', sim.sleep):
|
|
79
|
+
return fn()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AWSDnsWaiterTestCase(unittest.TestCase):
|
|
83
|
+
|
|
84
|
+
def setUp(self):
|
|
85
|
+
self.provider = _provider()
|
|
86
|
+
self.svc = AWSDnsRecordService(self.provider)
|
|
87
|
+
self.zone = AWSDnsZone(self.provider, ZONE)
|
|
88
|
+
self.record = AWSDnsRecord(self.provider, self.zone, RECORD)
|
|
89
|
+
|
|
90
|
+
def test_create_does_not_burn_a_full_poll_interval_on_a_fast_change(self):
|
|
91
|
+
sim = _Route53Sim()
|
|
92
|
+
|
|
93
|
+
_run(sim, lambda: self.svc.create(
|
|
94
|
+
self.zone, 'sub.example.com.', 'CNAME', 'hello.com', ttl=500))
|
|
95
|
+
|
|
96
|
+
self.assertLess(
|
|
97
|
+
sim.total_wait, BOTO_DEFAULT_DELAY,
|
|
98
|
+
"A change that went INSYNC after %ss cost %ss of sleep; the "
|
|
99
|
+
"waiter is still polling at boto3's %ss default"
|
|
100
|
+
% (INSYNC_AFTER, sim.total_wait, BOTO_DEFAULT_DELAY))
|
|
101
|
+
|
|
102
|
+
def test_delete_does_not_burn_a_full_poll_interval_on_a_fast_change(self):
|
|
103
|
+
sim = _Route53Sim()
|
|
104
|
+
|
|
105
|
+
_run(sim, lambda: self.svc.delete(self.zone, self.record))
|
|
106
|
+
|
|
107
|
+
self.assertLess(
|
|
108
|
+
sim.total_wait, BOTO_DEFAULT_DELAY,
|
|
109
|
+
"A change that went INSYNC after %ss cost %ss of sleep; the "
|
|
110
|
+
"waiter is still polling at boto3's %ss default"
|
|
111
|
+
% (INSYNC_AFTER, sim.total_wait, BOTO_DEFAULT_DELAY))
|
|
112
|
+
|
|
113
|
+
def test_waiter_polls_until_the_change_is_actually_insync(self):
|
|
114
|
+
"""Faster polling must not mean giving up early."""
|
|
115
|
+
sim = _Route53Sim(insync_after=47.0)
|
|
116
|
+
|
|
117
|
+
_run(sim, lambda: self.svc.create(
|
|
118
|
+
self.zone, 'sub.example.com.', 'CNAME', 'hello.com', ttl=500))
|
|
119
|
+
|
|
120
|
+
self.assertGreaterEqual(sim.clock, 47.0,
|
|
121
|
+
"Returned before the change was INSYNC")
|
|
122
|
+
self.assertGreater(sim.get_change_calls, 1)
|
|
123
|
+
|
|
124
|
+
def test_waiter_ceiling_is_still_about_thirty_minutes(self):
|
|
125
|
+
"""Polling more often must not shrink how long we are willing to
|
|
126
|
+
wait -- a genuinely slow change should still be given ~30 minutes
|
|
127
|
+
before the waiter gives up."""
|
|
128
|
+
sim = _Route53Sim(insync_after=float('inf'))
|
|
129
|
+
|
|
130
|
+
with self.assertRaises(WaiterError):
|
|
131
|
+
_run(sim, lambda: self.svc.create(
|
|
132
|
+
self.zone, 'sub.example.com.', 'CNAME', 'hello.com', ttl=500))
|
|
133
|
+
|
|
134
|
+
self.assertGreaterEqual(
|
|
135
|
+
sim.total_wait, REQUIRED_CEILING,
|
|
136
|
+
"Waiter gave up after only %ss of simulated waiting"
|
|
137
|
+
% sim.total_wait)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == '__main__':
|
|
141
|
+
unittest.main()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for ``AWSVMTypeService`` catalogue retrieval.
|
|
3
|
+
|
|
4
|
+
AWS has no server-side pagination for VM types, so ``list()`` materialises the
|
|
5
|
+
whole catalogue and hands it to ``ClientPagedResultList`` for client-side
|
|
6
|
+
paging. Fetching that catalogue is expensive: one
|
|
7
|
+
``DescribeInstanceTypeOfferings`` walk plus a ``DescribeInstanceTypes`` call
|
|
8
|
+
per 100 types (~14 calls for a real region). Re-fetching it for every page
|
|
9
|
+
makes walking the full list quadratic in API calls, so these tests pin the
|
|
10
|
+
catalogue down to a single fetch per zone.
|
|
11
|
+
|
|
12
|
+
Exercised against an in-memory fake connection so they run in CI without
|
|
13
|
+
cloud credentials.
|
|
14
|
+
"""
|
|
15
|
+
import unittest
|
|
16
|
+
|
|
17
|
+
from cloudbridge.providers.aws import AWSCloudProvider
|
|
18
|
+
|
|
19
|
+
# Enough types to force several pages at the tests' small result limit, and
|
|
20
|
+
# more than one 100-type DescribeInstanceTypes chunk.
|
|
21
|
+
CATALOGUE_SIZE = 250
|
|
22
|
+
CHUNK_SIZE = 100
|
|
23
|
+
PAGE_SIZE = 5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _FakeEC2Client:
|
|
27
|
+
"""Records every catalogue call made against it."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, types_by_zone):
|
|
30
|
+
self._types_by_zone = types_by_zone
|
|
31
|
+
self.offering_calls = [] # zone per call
|
|
32
|
+
self.describe_type_calls = [] # list of requested type names
|
|
33
|
+
|
|
34
|
+
def describe_instance_type_offerings(self, **kwargs):
|
|
35
|
+
zone = kwargs['Filters'][0]['Values'][0]
|
|
36
|
+
self.offering_calls.append(zone)
|
|
37
|
+
names = self._types_by_zone[zone]
|
|
38
|
+
return {'InstanceTypeOfferings': [{'InstanceType': n} for n in names]}
|
|
39
|
+
|
|
40
|
+
def describe_instance_types(self, **kwargs):
|
|
41
|
+
requested = kwargs['InstanceTypes']
|
|
42
|
+
self.describe_type_calls.append(requested)
|
|
43
|
+
return {'InstanceTypes': [{'InstanceType': n,
|
|
44
|
+
'CurrentGeneration': True,
|
|
45
|
+
'VCpuInfo': {'DefaultVCpus': 2}}
|
|
46
|
+
for n in requested]}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _FakeEC2Conn:
|
|
50
|
+
def __init__(self, client):
|
|
51
|
+
self.meta = type('_Meta', (), {'client': client})()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _make_provider(zone, types_by_zone):
|
|
55
|
+
provider = AWSCloudProvider({
|
|
56
|
+
'aws_access_key': 'dummy',
|
|
57
|
+
'aws_secret_key': 'dummy',
|
|
58
|
+
'aws_zone_name': zone,
|
|
59
|
+
'default_result_limit': PAGE_SIZE,
|
|
60
|
+
})
|
|
61
|
+
client = _FakeEC2Client(types_by_zone)
|
|
62
|
+
# ec2_conn is a lazily-populated property backed by this attribute.
|
|
63
|
+
provider._ec2_conn = _FakeEC2Conn(client)
|
|
64
|
+
return provider, client
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _zone_types(prefix, count=CATALOGUE_SIZE):
|
|
68
|
+
return ['{0}.type{1}'.format(prefix, i) for i in range(count)]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AWSVMTypeCatalogueTestCase(unittest.TestCase):
|
|
72
|
+
|
|
73
|
+
def setUp(self):
|
|
74
|
+
self.zone = 'us-east-1a'
|
|
75
|
+
self.types = {self.zone: _zone_types('a')}
|
|
76
|
+
|
|
77
|
+
def _calls_for_one_catalogue_fetch(self):
|
|
78
|
+
expected_chunks = -(-CATALOGUE_SIZE // CHUNK_SIZE) # ceil div
|
|
79
|
+
return 1, expected_chunks
|
|
80
|
+
|
|
81
|
+
def test_single_list_fetches_catalogue_once(self):
|
|
82
|
+
provider, client = _make_provider(self.zone, self.types)
|
|
83
|
+
|
|
84
|
+
provider.compute.vm_types.list()
|
|
85
|
+
|
|
86
|
+
offerings, chunks = self._calls_for_one_catalogue_fetch()
|
|
87
|
+
self.assertEqual(len(client.offering_calls), offerings)
|
|
88
|
+
self.assertEqual(len(client.describe_type_calls), chunks)
|
|
89
|
+
|
|
90
|
+
def test_repeated_list_calls_reuse_the_catalogue(self):
|
|
91
|
+
provider, client = _make_provider(self.zone, self.types)
|
|
92
|
+
|
|
93
|
+
provider.compute.vm_types.list()
|
|
94
|
+
provider.compute.vm_types.list()
|
|
95
|
+
provider.compute.vm_types.list()
|
|
96
|
+
|
|
97
|
+
offerings, chunks = self._calls_for_one_catalogue_fetch()
|
|
98
|
+
self.assertEqual(
|
|
99
|
+
len(client.offering_calls), offerings,
|
|
100
|
+
"Catalogue offerings should be fetched once and reused, but were "
|
|
101
|
+
"fetched %s times" % len(client.offering_calls))
|
|
102
|
+
self.assertEqual(
|
|
103
|
+
len(client.describe_type_calls), chunks,
|
|
104
|
+
"Instance type details should be fetched once and reused, but "
|
|
105
|
+
"%s calls were made" % len(client.describe_type_calls))
|
|
106
|
+
|
|
107
|
+
def test_paging_through_all_pages_does_not_refetch_catalogue(self):
|
|
108
|
+
"""The cost of walking every page must not scale with the page count.
|
|
109
|
+
|
|
110
|
+
This is the access pattern used by the standard-behaviour test helper
|
|
111
|
+
(``check_list``) and it is what made the AWS suite take ~50 minutes.
|
|
112
|
+
"""
|
|
113
|
+
provider, client = _make_provider(self.zone, self.types)
|
|
114
|
+
|
|
115
|
+
result = provider.compute.vm_types.list()
|
|
116
|
+
pages = 1
|
|
117
|
+
while result.is_truncated:
|
|
118
|
+
result = provider.compute.vm_types.list(marker=result.marker)
|
|
119
|
+
pages += 1
|
|
120
|
+
|
|
121
|
+
self.assertEqual(pages, -(-CATALOGUE_SIZE // PAGE_SIZE),
|
|
122
|
+
"Expected to walk the whole catalogue")
|
|
123
|
+
offerings, chunks = self._calls_for_one_catalogue_fetch()
|
|
124
|
+
self.assertEqual(
|
|
125
|
+
len(client.offering_calls), offerings,
|
|
126
|
+
"Walking %s pages refetched the offerings %s times"
|
|
127
|
+
% (pages, len(client.offering_calls)))
|
|
128
|
+
self.assertEqual(
|
|
129
|
+
len(client.describe_type_calls), chunks,
|
|
130
|
+
"Walking %s pages made %s DescribeInstanceTypes calls; the "
|
|
131
|
+
"catalogue should be fetched once"
|
|
132
|
+
% (pages, len(client.describe_type_calls)))
|
|
133
|
+
|
|
134
|
+
def test_list_still_returns_the_full_catalogue_contents(self):
|
|
135
|
+
"""Caching must not change what callers observe."""
|
|
136
|
+
provider, client = _make_provider(self.zone, self.types)
|
|
137
|
+
|
|
138
|
+
seen = []
|
|
139
|
+
result = provider.compute.vm_types.list()
|
|
140
|
+
seen.extend(t.id for t in result)
|
|
141
|
+
while result.is_truncated:
|
|
142
|
+
result = provider.compute.vm_types.list(marker=result.marker)
|
|
143
|
+
seen.extend(t.id for t in result)
|
|
144
|
+
|
|
145
|
+
self.assertEqual(seen, self.types[self.zone])
|
|
146
|
+
|
|
147
|
+
def test_catalogue_is_keyed_by_zone(self):
|
|
148
|
+
"""A provider cloned to another zone must not reuse the first zone's
|
|
149
|
+
catalogue — zones genuinely offer different instance types."""
|
|
150
|
+
other_zone = 'us-east-1b'
|
|
151
|
+
types = {self.zone: _zone_types('a'), other_zone: _zone_types('b')}
|
|
152
|
+
provider, client = _make_provider(self.zone, types)
|
|
153
|
+
|
|
154
|
+
first = [t.id for t in provider.compute.vm_types.list()]
|
|
155
|
+
|
|
156
|
+
provider.config['aws_zone_name'] = other_zone
|
|
157
|
+
provider._zone_name = other_zone
|
|
158
|
+
second = [t.id for t in provider.compute.vm_types.list()]
|
|
159
|
+
|
|
160
|
+
self.assertEqual(first, types[self.zone][:PAGE_SIZE])
|
|
161
|
+
self.assertEqual(second, types[other_zone][:PAGE_SIZE])
|
|
162
|
+
self.assertEqual(client.offering_calls, [self.zone, other_zone])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == '__main__':
|
|
166
|
+
unittest.main()
|
|
@@ -9,6 +9,7 @@ driver is exercised here directly against in-memory fakes so it has coverage
|
|
|
9
9
|
in CI without cloud credentials.
|
|
10
10
|
"""
|
|
11
11
|
import os
|
|
12
|
+
import shutil
|
|
12
13
|
import tempfile
|
|
13
14
|
import threading
|
|
14
15
|
import unittest
|
|
@@ -32,12 +33,15 @@ class _Recorder:
|
|
|
32
33
|
self.active = 0
|
|
33
34
|
self.max_active = 0
|
|
34
35
|
self.fail_on_offset = None # offset that should raise
|
|
36
|
+
self.on_serve = None # hook called as each range is served
|
|
35
37
|
|
|
36
38
|
def serve_range(self, service, offset, length):
|
|
37
39
|
with self._lock:
|
|
38
40
|
self.active += 1
|
|
39
41
|
self.max_active = max(self.max_active, self.active)
|
|
40
42
|
try:
|
|
43
|
+
if self.on_serve:
|
|
44
|
+
self.on_serve()
|
|
41
45
|
if self.fail_on_offset == offset:
|
|
42
46
|
raise RuntimeError("boom at offset %d" % offset)
|
|
43
47
|
# Hold briefly so concurrent fetches genuinely overlap.
|
|
@@ -222,6 +226,72 @@ class DownloadDriverTestCase(unittest.TestCase):
|
|
|
222
226
|
if os.path.exists(path):
|
|
223
227
|
os.remove(path)
|
|
224
228
|
|
|
229
|
+
def test_destination_only_appears_once_complete(self):
|
|
230
|
+
content = bytes(range(256))
|
|
231
|
+
recorder = _Recorder(content)
|
|
232
|
+
driver = self._driver(
|
|
233
|
+
recorder, threshold=1, part_size=16, concurrency=3)
|
|
234
|
+
fd, path = tempfile.mkstemp()
|
|
235
|
+
os.close(fd)
|
|
236
|
+
os.remove(path)
|
|
237
|
+
seen_early = []
|
|
238
|
+
recorder.on_serve = lambda: seen_early.append(os.path.exists(path))
|
|
239
|
+
try:
|
|
240
|
+
driver.download_to_file(path)
|
|
241
|
+
with open(path, 'rb') as f:
|
|
242
|
+
self.assertEqual(f.read(), content)
|
|
243
|
+
finally:
|
|
244
|
+
if os.path.exists(path):
|
|
245
|
+
os.remove(path)
|
|
246
|
+
# A partially written object is never visible at the destination.
|
|
247
|
+
self.assertTrue(seen_early)
|
|
248
|
+
self.assertNotIn(True, seen_early)
|
|
249
|
+
|
|
250
|
+
def test_survives_concurrent_downloader_taking_the_destination(self):
|
|
251
|
+
# Galaxy gives every download of a dataset the same cache .tmp path,
|
|
252
|
+
# so a second download of the same dataset can rename the destination
|
|
253
|
+
# away while this one is still fetching ranges.
|
|
254
|
+
content = bytes(range(256))
|
|
255
|
+
recorder = _Recorder(content)
|
|
256
|
+
driver = self._driver(
|
|
257
|
+
recorder, threshold=1, part_size=16, concurrency=3)
|
|
258
|
+
directory = tempfile.mkdtemp()
|
|
259
|
+
path = os.path.join(directory, 'dataset.dat')
|
|
260
|
+
taken = os.path.join(directory, 'taken.dat')
|
|
261
|
+
|
|
262
|
+
def steal_destination():
|
|
263
|
+
if os.path.exists(path):
|
|
264
|
+
os.replace(path, taken)
|
|
265
|
+
|
|
266
|
+
recorder.on_serve = steal_destination
|
|
267
|
+
try:
|
|
268
|
+
driver.download_to_file(path)
|
|
269
|
+
with open(path, 'rb') as f:
|
|
270
|
+
self.assertEqual(f.read(), content)
|
|
271
|
+
finally:
|
|
272
|
+
shutil.rmtree(directory)
|
|
273
|
+
|
|
274
|
+
def test_failed_download_leaves_an_existing_destination_intact(self):
|
|
275
|
+
content = bytes(range(64))
|
|
276
|
+
recorder = _Recorder(content)
|
|
277
|
+
recorder.fail_on_offset = 16
|
|
278
|
+
driver = self._driver(
|
|
279
|
+
recorder, threshold=1, part_size=16, concurrency=2)
|
|
280
|
+
directory = tempfile.mkdtemp()
|
|
281
|
+
path = os.path.join(directory, 'dataset.dat')
|
|
282
|
+
with open(path, 'wb') as f:
|
|
283
|
+
f.write(b'previously cached')
|
|
284
|
+
try:
|
|
285
|
+
with self.assertRaises(Exception):
|
|
286
|
+
driver.download_to_file(path)
|
|
287
|
+
# The cached copy survives a failed refetch, and no scratch file
|
|
288
|
+
# is left behind next to it.
|
|
289
|
+
with open(path, 'rb') as f:
|
|
290
|
+
self.assertEqual(f.read(), b'previously cached')
|
|
291
|
+
self.assertEqual(os.listdir(directory), ['dataset.dat'])
|
|
292
|
+
finally:
|
|
293
|
+
shutil.rmtree(directory)
|
|
294
|
+
|
|
225
295
|
def test_part_size_must_be_positive(self):
|
|
226
296
|
content = bytes(range(16))
|
|
227
297
|
recorder = _Recorder(content)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|