hexkit 3.6.0__tar.gz → 3.8.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.
- {hexkit-3.6.0/src/hexkit.egg-info → hexkit-3.8.0}/PKG-INFO +1 -1
- {hexkit-3.6.0 → hexkit-3.8.0}/pyproject.toml +1 -1
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/protocols/objstorage.py +31 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/akafka/testcontainer.py +1 -1
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/provider.py +83 -25
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/testutils/_fixtures.py +14 -13
- {hexkit-3.6.0 → hexkit-3.8.0/src/hexkit.egg-info}/PKG-INFO +1 -1
- {hexkit-3.6.0 → hexkit-3.8.0}/LICENSE +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/MANIFEST.in +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/README.md +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/setup.cfg +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/__main__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/base.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/config.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/correlation.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/custom_types.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/log.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/protocols/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/protocols/dao.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/protocols/daopub.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/protocols/daosub.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/protocols/eventpub.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/protocols/eventsub.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/akafka/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/akafka/config.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/akafka/testutils.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/mongodb/provider.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/mongokafka/provider.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/testutils/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/testing/__init__.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/providers/testing/eventpub.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/py.typed +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit/utils.py +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit.egg-info/SOURCES.txt +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit.egg-info/requires.txt +0 -0
- {hexkit-3.6.0 → hexkit-3.8.0}/src/hexkit.egg-info/top_level.txt +0 -0
|
@@ -122,6 +122,7 @@ class ObjectStorageProtocol(ABC):
|
|
|
122
122
|
object_id: str,
|
|
123
123
|
part_number: int,
|
|
124
124
|
expires_after: int = 3600,
|
|
125
|
+
part_md5: Optional[str] = None,
|
|
125
126
|
) -> str:
|
|
126
127
|
"""Given a id of an instantiated multipart upload along with the corresponding
|
|
127
128
|
bucket and object ID, it returns a presigned URL for uploading a file part with the
|
|
@@ -137,6 +138,7 @@ class ObjectStorageProtocol(ABC):
|
|
|
137
138
|
object_id=object_id,
|
|
138
139
|
part_number=part_number,
|
|
139
140
|
expires_after=expires_after,
|
|
141
|
+
part_md5=part_md5,
|
|
140
142
|
)
|
|
141
143
|
|
|
142
144
|
async def abort_multipart_upload(
|
|
@@ -207,6 +209,12 @@ class ObjectStorageProtocol(ABC):
|
|
|
207
209
|
bucket_id=bucket_id, object_id=object_id, object_md5sum=object_md5sum
|
|
208
210
|
)
|
|
209
211
|
|
|
212
|
+
async def get_object_etag(self, *, bucket_id: str, object_id: str) -> str:
|
|
213
|
+
"""Returns the etag of an object."""
|
|
214
|
+
self._validate_bucket_id(bucket_id)
|
|
215
|
+
self._validate_object_id(object_id)
|
|
216
|
+
return await self._get_object_etag(bucket_id=bucket_id, object_id=object_id)
|
|
217
|
+
|
|
210
218
|
async def get_object_size(self, *, bucket_id: str, object_id: str) -> int:
|
|
211
219
|
"""Returns the size of an object in bytes."""
|
|
212
220
|
self._validate_bucket_id(bucket_id)
|
|
@@ -220,9 +228,15 @@ class ObjectStorageProtocol(ABC):
|
|
|
220
228
|
source_object_id: str,
|
|
221
229
|
dest_bucket_id: str,
|
|
222
230
|
dest_object_id: str,
|
|
231
|
+
abort_failed: bool = True,
|
|
223
232
|
) -> None:
|
|
224
233
|
"""Copy an object from one bucket (`source_bucket_id` and `source_object_id`) to
|
|
225
234
|
another bucket (`dest_bucket_id` and `dest_object_id`).
|
|
235
|
+
|
|
236
|
+
If `abort_failed` is set to true (default), a failed copy operation tries to
|
|
237
|
+
abort the ongoing multipart upload it created (if using multipart mode).
|
|
238
|
+
This only works reliably as long as there are no other ongoing multipart operations for
|
|
239
|
+
the same destination bucket and object ID, in which case this should be set to false.
|
|
226
240
|
"""
|
|
227
241
|
self._validate_bucket_id(source_bucket_id)
|
|
228
242
|
self._validate_object_id(source_object_id)
|
|
@@ -233,6 +247,7 @@ class ObjectStorageProtocol(ABC):
|
|
|
233
247
|
source_object_id=source_object_id,
|
|
234
248
|
dest_bucket_id=dest_bucket_id,
|
|
235
249
|
dest_object_id=dest_object_id,
|
|
250
|
+
abort_failed=abort_failed,
|
|
236
251
|
)
|
|
237
252
|
|
|
238
253
|
async def delete_object(self, *, bucket_id: str, object_id: str) -> None:
|
|
@@ -336,6 +351,7 @@ class ObjectStorageProtocol(ABC):
|
|
|
336
351
|
object_id: str,
|
|
337
352
|
part_number: int,
|
|
338
353
|
expires_after: int = 3600,
|
|
354
|
+
part_md5: Optional[str] = None,
|
|
339
355
|
) -> str:
|
|
340
356
|
"""
|
|
341
357
|
Given a id of an instantiated multipart upload along with the corresponding
|
|
@@ -402,6 +418,15 @@ class ObjectStorageProtocol(ABC):
|
|
|
402
418
|
"""
|
|
403
419
|
...
|
|
404
420
|
|
|
421
|
+
@abstractmethod
|
|
422
|
+
async def _get_object_etag(self, *, bucket_id: str, object_id: str) -> str:
|
|
423
|
+
"""Return the etag of an object.
|
|
424
|
+
|
|
425
|
+
*To be implemented by the provider. Input validation is done outside of this
|
|
426
|
+
method.*
|
|
427
|
+
"""
|
|
428
|
+
...
|
|
429
|
+
|
|
405
430
|
@abstractmethod
|
|
406
431
|
async def _get_object_size(self, *, bucket_id: str, object_id: str) -> int:
|
|
407
432
|
"""
|
|
@@ -435,11 +460,17 @@ class ObjectStorageProtocol(ABC):
|
|
|
435
460
|
source_object_id: str,
|
|
436
461
|
dest_bucket_id: str,
|
|
437
462
|
dest_object_id: str,
|
|
463
|
+
abort_failed: bool = True,
|
|
438
464
|
) -> None:
|
|
439
465
|
"""
|
|
440
466
|
Copy an object from one bucket (`source_bucket_id` and `source_object_id`) to
|
|
441
467
|
another bucket (`dest_bucket_id` and `dest_object_id`).
|
|
442
468
|
|
|
469
|
+
If `abort_failed` is set to true (default), a failed copy operation tries to
|
|
470
|
+
abort the ongoing multipart upload it created (if using multipart mode).
|
|
471
|
+
This only works reliably as long as there are no other ongoing multipart operations for
|
|
472
|
+
the same destination bucket and object ID, in which case this should be set to false.
|
|
473
|
+
|
|
443
474
|
*To be implemented by the provider. Input validation is done outside of this
|
|
444
475
|
method.*
|
|
445
476
|
"""
|
|
@@ -151,7 +151,7 @@ class KafkaSSLContainer(DockerContainer):
|
|
|
151
151
|
with tarfile.TarFile(fileobj=archive, mode="w") as tar:
|
|
152
152
|
tarinfo = tarfile.TarInfo(name=path)
|
|
153
153
|
tarinfo.size = len(content)
|
|
154
|
-
tarinfo.mtime = time.time()
|
|
154
|
+
tarinfo.mtime = time.time()
|
|
155
155
|
tar.addfile(tarinfo, BytesIO(content))
|
|
156
156
|
archive.seek(0)
|
|
157
157
|
self.get_wrapped_container().put_archive("/", archive)
|
|
@@ -22,6 +22,7 @@ Utilities for testing are located in `./testutils.py`.
|
|
|
22
22
|
# ruff: noqa: PLR0913
|
|
23
23
|
|
|
24
24
|
import asyncio
|
|
25
|
+
import re
|
|
25
26
|
from functools import lru_cache
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
from typing import Any, Optional
|
|
@@ -31,6 +32,7 @@ import botocore.client
|
|
|
31
32
|
import botocore.config
|
|
32
33
|
import botocore.configloader
|
|
33
34
|
import botocore.exceptions
|
|
35
|
+
import botocore.handlers
|
|
34
36
|
from boto3.s3.transfer import TransferConfig
|
|
35
37
|
from pydantic import Field, SecretStr
|
|
36
38
|
from pydantic_settings import BaseSettings
|
|
@@ -39,6 +41,10 @@ from hexkit.protocols.objstorage import ObjectStorageProtocol, PresignedPostURL
|
|
|
39
41
|
from hexkit.utils import calc_part_size
|
|
40
42
|
|
|
41
43
|
__all__ = ["ObjectStorageProtocol", "PresignedPostURL"]
|
|
44
|
+
# Allow colon character in bucket names to accommodate Ceph multi tenancy S3
|
|
45
|
+
botocore.handlers.VALID_BUCKET = re.compile(
|
|
46
|
+
r"^(?:[a-zA-Z0-9_]{1,191}:)?[a-z0-9\-]{3,63}$"
|
|
47
|
+
)
|
|
42
48
|
|
|
43
49
|
|
|
44
50
|
class S3Config(BaseSettings):
|
|
@@ -116,6 +122,8 @@ def read_aws_config_ini(aws_config_ini: Path) -> botocore.config.Config:
|
|
|
116
122
|
class S3ObjectStorage(ObjectStorageProtocol):
|
|
117
123
|
"""S3-based provider implementing the ObjectStorageProtocol."""
|
|
118
124
|
|
|
125
|
+
_re_bucket_id = botocore.handlers.VALID_BUCKET
|
|
126
|
+
|
|
119
127
|
def __init__(
|
|
120
128
|
self,
|
|
121
129
|
*,
|
|
@@ -223,27 +231,33 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
223
231
|
|
|
224
232
|
async def _does_bucket_exist(self, bucket_id: str) -> bool:
|
|
225
233
|
"""Check whether a bucket with the specified ID (`bucket_id`) exists.
|
|
226
|
-
|
|
234
|
+
|
|
235
|
+
Returns `True` if it exists and `False` otherwise.
|
|
236
|
+
|
|
237
|
+
Note that this method does not need to have the permission to list buckets.
|
|
227
238
|
"""
|
|
228
239
|
try:
|
|
229
|
-
|
|
240
|
+
await asyncio.to_thread(self._client.head_bucket, Bucket=bucket_id)
|
|
230
241
|
except botocore.exceptions.ClientError as error:
|
|
242
|
+
if error.response["Error"]["Code"] == "404":
|
|
243
|
+
return False
|
|
231
244
|
raise self._translate_s3_client_errors(
|
|
232
245
|
error, bucket_id=bucket_id
|
|
233
246
|
) from error
|
|
234
|
-
|
|
235
|
-
return any(bucket["Name"] == bucket_id for bucket in bucket_list["Buckets"])
|
|
247
|
+
return True
|
|
236
248
|
|
|
237
249
|
async def _assert_bucket_exists(self, bucket_id: str) -> None:
|
|
238
|
-
"""
|
|
239
|
-
|
|
250
|
+
"""Assert that the bucket with the specified ID (`bucket_id`) exists.
|
|
251
|
+
|
|
252
|
+
If it does not exist, a BucketNotFoundError is raised.
|
|
240
253
|
"""
|
|
241
254
|
if not await self.does_bucket_exist(bucket_id):
|
|
242
255
|
raise self.BucketNotFoundError(bucket_id=bucket_id)
|
|
243
256
|
|
|
244
257
|
async def _assert_bucket_not_exists(self, bucket_id: str) -> None:
|
|
245
|
-
"""
|
|
246
|
-
|
|
258
|
+
"""Assert that the bucket with the specified ID (`bucket_id`) does not exist.
|
|
259
|
+
|
|
260
|
+
If it exists, a BucketAlreadyExistsError is raised.
|
|
247
261
|
"""
|
|
248
262
|
if await self.does_bucket_exist(bucket_id):
|
|
249
263
|
raise self.BucketAlreadyExistsError(bucket_id=bucket_id)
|
|
@@ -274,7 +288,7 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
274
288
|
await self._assert_bucket_exists(bucket_id)
|
|
275
289
|
|
|
276
290
|
try:
|
|
277
|
-
bucket = self._resource.Bucket(bucket_id)
|
|
291
|
+
bucket = self._resource.Bucket(bucket_id) # pyright: ignore
|
|
278
292
|
content = await asyncio.to_thread(bucket.objects.all)
|
|
279
293
|
if delete_content:
|
|
280
294
|
await asyncio.to_thread(content.delete)
|
|
@@ -289,7 +303,7 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
289
303
|
await self._assert_bucket_exists(bucket_id)
|
|
290
304
|
|
|
291
305
|
try:
|
|
292
|
-
bucket = self._resource.Bucket(bucket_id)
|
|
306
|
+
bucket = self._resource.Bucket(bucket_id) # pyright: ignore
|
|
293
307
|
content = await asyncio.to_thread(bucket.objects.all)
|
|
294
308
|
return [object_summary.key for object_summary in content]
|
|
295
309
|
except botocore.exceptions.ClientError as error:
|
|
@@ -306,17 +320,18 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
306
320
|
Return `True` if checks succeed and `False` otherwise.
|
|
307
321
|
"""
|
|
308
322
|
if object_md5sum is not None:
|
|
309
|
-
raise NotImplementedError("
|
|
323
|
+
raise NotImplementedError("MD5 checking is not yet implemented.")
|
|
310
324
|
|
|
311
325
|
try:
|
|
312
|
-
|
|
313
|
-
self._client.head_object,
|
|
314
|
-
Bucket=bucket_id,
|
|
315
|
-
Key=object_id,
|
|
326
|
+
await asyncio.to_thread(
|
|
327
|
+
self._client.head_object, Bucket=bucket_id, Key=object_id
|
|
316
328
|
)
|
|
317
|
-
except botocore.exceptions.ClientError:
|
|
318
|
-
|
|
319
|
-
|
|
329
|
+
except botocore.exceptions.ClientError as error:
|
|
330
|
+
if error.response["Error"]["Code"] == "404":
|
|
331
|
+
return False
|
|
332
|
+
raise self._translate_s3_client_errors(
|
|
333
|
+
error, bucket_id=bucket_id, object_id=object_id
|
|
334
|
+
) from error
|
|
320
335
|
return True
|
|
321
336
|
|
|
322
337
|
async def _assert_object_exists(self, *, bucket_id: str, object_id: str) -> None:
|
|
@@ -451,7 +466,7 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
451
466
|
)
|
|
452
467
|
|
|
453
468
|
async def _init_multipart_upload(self, *, bucket_id: str, object_id: str) -> str:
|
|
454
|
-
"""Initiates a
|
|
469
|
+
"""Initiates a multipart upload procedure. Returns the upload ID."""
|
|
455
470
|
await self._assert_no_multipart_upload(bucket_id=bucket_id, object_id=object_id)
|
|
456
471
|
|
|
457
472
|
try:
|
|
@@ -473,6 +488,7 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
473
488
|
object_id: str,
|
|
474
489
|
part_number: int,
|
|
475
490
|
expires_after: int = 3600,
|
|
491
|
+
part_md5: Optional[str] = None,
|
|
476
492
|
) -> str:
|
|
477
493
|
"""Given a id of an instantiated multipart upload along with the corresponding
|
|
478
494
|
bucket and object ID, it returns a presigned URL for uploading a file part with the
|
|
@@ -491,16 +507,20 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
491
507
|
upload_id=upload_id, bucket_id=bucket_id, object_id=object_id
|
|
492
508
|
)
|
|
493
509
|
|
|
510
|
+
params = {
|
|
511
|
+
"Bucket": bucket_id,
|
|
512
|
+
"Key": object_id,
|
|
513
|
+
"UploadId": upload_id,
|
|
514
|
+
"PartNumber": part_number,
|
|
515
|
+
}
|
|
516
|
+
# add additional parameters if any were passed
|
|
517
|
+
if part_md5:
|
|
518
|
+
params["ContentMD5"] = part_md5
|
|
494
519
|
try:
|
|
495
520
|
return await asyncio.to_thread(
|
|
496
521
|
self._client.generate_presigned_url,
|
|
497
522
|
ClientMethod="upload_part",
|
|
498
|
-
Params=
|
|
499
|
-
"Bucket": bucket_id,
|
|
500
|
-
"Key": object_id,
|
|
501
|
-
"UploadId": upload_id,
|
|
502
|
-
"PartNumber": part_number,
|
|
503
|
-
},
|
|
523
|
+
Params=params,
|
|
504
524
|
ExpiresIn=expires_after,
|
|
505
525
|
)
|
|
506
526
|
except botocore.exceptions.ClientError as error:
|
|
@@ -757,6 +777,22 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
757
777
|
|
|
758
778
|
return presigned_url
|
|
759
779
|
|
|
780
|
+
async def _get_object_etag(self, *, bucket_id: str, object_id: str) -> str:
|
|
781
|
+
"""Return the etag of an object."""
|
|
782
|
+
await self._assert_object_exists(bucket_id=bucket_id, object_id=object_id)
|
|
783
|
+
|
|
784
|
+
object_metadata = await self._get_object_metadata(
|
|
785
|
+
bucket_id=bucket_id, object_id=object_id
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
if "ETag" not in object_metadata:
|
|
789
|
+
raise self.ObjectError(
|
|
790
|
+
f"Could not get the etag of the object with ID '{object_id}' in"
|
|
791
|
+
+ f" bucket '{bucket_id}'."
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
return object_metadata["ETag"]
|
|
795
|
+
|
|
760
796
|
async def _get_object_size(self, *, bucket_id: str, object_id: str) -> int:
|
|
761
797
|
"""Returns the size of an object in bytes."""
|
|
762
798
|
await self._assert_object_exists(bucket_id=bucket_id, object_id=object_id)
|
|
@@ -795,9 +831,15 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
795
831
|
source_object_id: str,
|
|
796
832
|
dest_bucket_id: str,
|
|
797
833
|
dest_object_id: str,
|
|
834
|
+
abort_failed: bool = True,
|
|
798
835
|
) -> None:
|
|
799
836
|
"""Copy an object from one bucket (`source_bucket_id` and `source_object_id`) to
|
|
800
837
|
another bucket (`dest_bucket_id` and `dest_object_id`).
|
|
838
|
+
|
|
839
|
+
If `abort_failed` is set to true (default), a failed copy operation tries to
|
|
840
|
+
abort the ongoing multipart upload it created (if using multipart mode).
|
|
841
|
+
This only works reliably as long as there are no other ongoing multipart operations for
|
|
842
|
+
the same destination bucket and object ID, in which case this should be set to false.
|
|
801
843
|
"""
|
|
802
844
|
file_size = await self._get_object_size(
|
|
803
845
|
bucket_id=source_bucket_id, object_id=source_object_id
|
|
@@ -825,6 +867,22 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
825
867
|
Config=transfer_config,
|
|
826
868
|
)
|
|
827
869
|
except botocore.exceptions.ClientError as error:
|
|
870
|
+
if abort_failed:
|
|
871
|
+
# try to find and abort the multipart operation, if multipart copy mode is used
|
|
872
|
+
# There should only be one ongoing multipart upload/copy at at a time as long
|
|
873
|
+
# as a new object ID is generated for each attempt
|
|
874
|
+
try:
|
|
875
|
+
upload_ids = await self._list_multipart_upload_for_object(
|
|
876
|
+
bucket_id=dest_bucket_id, object_id=dest_object_id
|
|
877
|
+
)
|
|
878
|
+
if len(upload_ids) == 1:
|
|
879
|
+
await self._abort_multipart_upload(
|
|
880
|
+
upload_id=upload_ids[0],
|
|
881
|
+
bucket_id=dest_bucket_id,
|
|
882
|
+
object_id=dest_object_id,
|
|
883
|
+
)
|
|
884
|
+
except botocore.exceptions.ClientError:
|
|
885
|
+
pass
|
|
828
886
|
raise self._translate_s3_client_errors(error) from error
|
|
829
887
|
|
|
830
888
|
async def _delete_object(self, *, bucket_id: str, object_id: str) -> None:
|
|
@@ -24,7 +24,7 @@ from collections.abc import AsyncGenerator, Generator
|
|
|
24
24
|
from contextlib import contextmanager
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
from tempfile import NamedTemporaryFile
|
|
27
|
-
from typing import
|
|
27
|
+
from typing import NamedTuple, Optional, Union
|
|
28
28
|
|
|
29
29
|
try:
|
|
30
30
|
from typing import Self
|
|
@@ -64,7 +64,7 @@ __all__ = [
|
|
|
64
64
|
]
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
LOCALSTACK_IMAGE = "localstack/localstack:3.
|
|
67
|
+
LOCALSTACK_IMAGE = "localstack/localstack:3.8.1"
|
|
68
68
|
|
|
69
69
|
TEST_FILE_DIR = Path(__file__).parent.parent.resolve() / "test_files"
|
|
70
70
|
|
|
@@ -223,19 +223,23 @@ class S3ContainerFixture(LocalStackContainer):
|
|
|
223
223
|
|
|
224
224
|
def __init__(
|
|
225
225
|
self,
|
|
226
|
-
|
|
226
|
+
image: str = LOCALSTACK_IMAGE,
|
|
227
|
+
edge_port: int = 4566,
|
|
227
228
|
region_name: Optional[str] = None,
|
|
228
|
-
**kwargs
|
|
229
|
+
**kwargs,
|
|
229
230
|
) -> None:
|
|
230
231
|
"""Initialize the container."""
|
|
231
|
-
super().__init__(
|
|
232
|
+
super().__init__(
|
|
233
|
+
image=image, edge_port=edge_port, region_name=region_name, **kwargs
|
|
234
|
+
)
|
|
232
235
|
|
|
233
236
|
def __enter__(self) -> Self:
|
|
234
237
|
"""Enter the container context."""
|
|
235
238
|
super().__enter__()
|
|
236
|
-
|
|
239
|
+
url = self.get_url()
|
|
240
|
+
url = url.replace("localhost", "127.0.0.1")
|
|
237
241
|
s3_config = S3Config( # type: ignore [call-arg]
|
|
238
|
-
s3_endpoint_url=
|
|
242
|
+
s3_endpoint_url=url,
|
|
239
243
|
s3_access_key_id="test",
|
|
240
244
|
s3_secret_access_key=SecretStr("test"),
|
|
241
245
|
)
|
|
@@ -375,8 +379,8 @@ class FederatedS3Fixture:
|
|
|
375
379
|
|
|
376
380
|
# Add the dummy items
|
|
377
381
|
for bucket, objects in contents.items():
|
|
378
|
-
for
|
|
379
|
-
with temp_file_object(bucket,
|
|
382
|
+
for obj in objects:
|
|
383
|
+
with temp_file_object(bucket, obj, 1) as file:
|
|
380
384
|
await storage.populate_file_objects([file])
|
|
381
385
|
|
|
382
386
|
|
|
@@ -418,10 +422,7 @@ def _s3_multi_container_fixture(
|
|
|
418
422
|
|
|
419
423
|
if not storage_aliases:
|
|
420
424
|
raise RuntimeError("The 'storage_aliases' list must not be empty.")
|
|
421
|
-
s3_containers = {
|
|
422
|
-
alias: S3ContainerFixture(name=f"{alias}_s3_container")
|
|
423
|
-
for alias in storage_aliases
|
|
424
|
-
}
|
|
425
|
+
s3_containers = {alias: S3ContainerFixture() for alias in storage_aliases}
|
|
425
426
|
with S3MultiContainerFixture(s3_containers) as s3_multi_container:
|
|
426
427
|
yield s3_multi_container
|
|
427
428
|
|
|
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
|