hexkit 3.5.0__tar.gz → 3.7.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.5.0/src/hexkit.egg-info → hexkit-3.7.0}/PKG-INFO +1 -1
- {hexkit-3.5.0 → hexkit-3.7.0}/pyproject.toml +1 -1
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/config.py +1 -1
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/dao.py +14 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/objstorage.py +31 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/testcontainer.py +1 -1
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongodb/provider.py +12 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongokafka/provider.py +16 -2
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/provider.py +75 -25
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/testutils/__init__.py +18 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/testutils/_fixtures.py +156 -7
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/testutils/_utils.py +2 -2
- {hexkit-3.5.0 → hexkit-3.7.0/src/hexkit.egg-info}/PKG-INFO +1 -1
- {hexkit-3.5.0 → hexkit-3.7.0}/LICENSE +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/MANIFEST.in +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/README.md +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/setup.cfg +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/__main__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/base.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/correlation.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/custom_types.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/log.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/daopub.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/daosub.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/eventpub.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/eventsub.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/config.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/testutils.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/testing/__init__.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/testing/eventpub.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/py.typed +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/utils.py +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit.egg-info/SOURCES.txt +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit.egg-info/requires.txt +0 -0
- {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit.egg-info/top_level.txt +0 -0
|
@@ -143,7 +143,7 @@ def config_from_yaml(
|
|
|
143
143
|
model_config = SettingsConfigDict(frozen=True, env_prefix=f"{prefix}_")
|
|
144
144
|
|
|
145
145
|
@classmethod
|
|
146
|
-
def settings_customise_sources(
|
|
146
|
+
def settings_customise_sources(
|
|
147
147
|
cls,
|
|
148
148
|
settings_cls: type[BaseSettings],
|
|
149
149
|
init_settings: PydanticBaseSettingsSource,
|
|
@@ -21,6 +21,7 @@ with the database.
|
|
|
21
21
|
# ruff: noqa: PLR0913
|
|
22
22
|
|
|
23
23
|
import typing
|
|
24
|
+
import warnings
|
|
24
25
|
from abc import ABC, abstractmethod
|
|
25
26
|
from collections.abc import AsyncGenerator, AsyncIterator, Collection, Mapping
|
|
26
27
|
from contextlib import AbstractAsyncContextManager
|
|
@@ -29,6 +30,7 @@ from typing import Any, Optional, TypeVar, Union, overload
|
|
|
29
30
|
from uuid import uuid4
|
|
30
31
|
|
|
31
32
|
from pydantic import BaseModel
|
|
33
|
+
from typing_extensions import deprecated
|
|
32
34
|
|
|
33
35
|
from hexkit.utils import FieldNotInModelError, validate_fields_in_model
|
|
34
36
|
|
|
@@ -259,6 +261,9 @@ class DaoNaturalId(DaoCommons[Dto], typing.Protocol[Dto]):
|
|
|
259
261
|
...
|
|
260
262
|
|
|
261
263
|
|
|
264
|
+
@deprecated(
|
|
265
|
+
"uuid4_id_generator is deprecated as of v3.6 and will be removed in hexkit v4."
|
|
266
|
+
)
|
|
262
267
|
async def uuid4_id_generator() -> AsyncGenerator[str, None]:
|
|
263
268
|
"""Generates a new ID using the UUID4 algorithm.
|
|
264
269
|
This is an AsyncGenerator to be compliant with the id_generator requirements of the
|
|
@@ -441,6 +446,15 @@ class DaoFactoryProtocol(DaoFactoryBase, ABC):
|
|
|
441
446
|
# instantiate the default ID generator:
|
|
442
447
|
id_generator = uuid4_id_generator()
|
|
443
448
|
|
|
449
|
+
if dto_creation_model is not None:
|
|
450
|
+
dep_msg = (
|
|
451
|
+
"The DAO API (and existing providers) are deprecated as of v3.6."
|
|
452
|
+
+ " They will be simplified in hexkit v4."
|
|
453
|
+
+ " Please plan to provide IDs during resource creation. The same model"
|
|
454
|
+
+ " should be used for both creation and retrieval."
|
|
455
|
+
)
|
|
456
|
+
warnings.warn(dep_msg, category=DeprecationWarning, stacklevel=2)
|
|
457
|
+
|
|
444
458
|
return await self._get_dao(
|
|
445
459
|
name=name,
|
|
446
460
|
dto_model=dto_model,
|
|
@@ -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 json
|
|
25
|
+
import warnings
|
|
25
26
|
from abc import ABC
|
|
26
27
|
from collections.abc import AsyncGenerator, AsyncIterator, Collection, Mapping
|
|
27
28
|
from contextlib import AbstractAsyncContextManager
|
|
@@ -163,6 +164,17 @@ class MongoDbDaoBase(ABC, Generic[Dto]):
|
|
|
163
164
|
self._document_to_dto = document_to_dto
|
|
164
165
|
self._dto_to_document = dto_to_document
|
|
165
166
|
|
|
167
|
+
warnings.warn(
|
|
168
|
+
"The MongoDbDaoBase class and subclasses are deprecated as of v3.6 and will"
|
|
169
|
+
+ " be replaced in hexkit v4 with the new MongoDbDao class. To replace the"
|
|
170
|
+
+ " MongoDbDaoNaturalId: just use the new class. To replace the"
|
|
171
|
+
+ " MongoDbDaoSurrogateId: use the new class, remove the creation model and"
|
|
172
|
+
+ " id_generator parameters, and make sure the supplied DTO model includes"
|
|
173
|
+
+ " the ID field with a default factory for generating new IDs.",
|
|
174
|
+
category=DeprecationWarning,
|
|
175
|
+
stacklevel=2,
|
|
176
|
+
)
|
|
177
|
+
|
|
166
178
|
async def get_by_id(self, id_: str) -> Dto:
|
|
167
179
|
"""Get a resource by providing its ID.
|
|
168
180
|
|
|
@@ -255,7 +255,14 @@ class MongoKafkaDaoPublisher(Generic[Dto]):
|
|
|
255
255
|
document = self._dao._dto_to_document(dto)
|
|
256
256
|
document.setdefault("__metadata__", {})["correlation_id"] = correlation_id
|
|
257
257
|
result = await self._collection.replace_one(
|
|
258
|
-
{
|
|
258
|
+
{
|
|
259
|
+
"_id": document["_id"],
|
|
260
|
+
"$or": [
|
|
261
|
+
{"__metadata__": {"$exists": False}},
|
|
262
|
+
{"__metadata__.deleted": False},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
document,
|
|
259
266
|
)
|
|
260
267
|
if result.matched_count == 0:
|
|
261
268
|
raise ResourceNotFoundError(id_=document["_id"])
|
|
@@ -282,7 +289,14 @@ class MongoKafkaDaoPublisher(Generic[Dto]):
|
|
|
282
289
|
},
|
|
283
290
|
}
|
|
284
291
|
result = await self._collection.replace_one(
|
|
285
|
-
{
|
|
292
|
+
{
|
|
293
|
+
"_id": document["_id"],
|
|
294
|
+
"$or": [
|
|
295
|
+
{"__metadata__": {"$exists": False}},
|
|
296
|
+
{"__metadata__.deleted": False},
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
document,
|
|
286
300
|
)
|
|
287
301
|
if result.matched_count == 0:
|
|
288
302
|
raise ResourceNotFoundError(id_=id_)
|
|
@@ -223,27 +223,33 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
223
223
|
|
|
224
224
|
async def _does_bucket_exist(self, bucket_id: str) -> bool:
|
|
225
225
|
"""Check whether a bucket with the specified ID (`bucket_id`) exists.
|
|
226
|
-
|
|
226
|
+
|
|
227
|
+
Returns `True` if it exists and `False` otherwise.
|
|
228
|
+
|
|
229
|
+
Note that this method does not need to have the permission to list buckets.
|
|
227
230
|
"""
|
|
228
231
|
try:
|
|
229
|
-
|
|
232
|
+
await asyncio.to_thread(self._client.head_bucket, Bucket=bucket_id)
|
|
230
233
|
except botocore.exceptions.ClientError as error:
|
|
234
|
+
if error.response["Error"]["Code"] == "404":
|
|
235
|
+
return False
|
|
231
236
|
raise self._translate_s3_client_errors(
|
|
232
237
|
error, bucket_id=bucket_id
|
|
233
238
|
) from error
|
|
234
|
-
|
|
235
|
-
return any(bucket["Name"] == bucket_id for bucket in bucket_list["Buckets"])
|
|
239
|
+
return True
|
|
236
240
|
|
|
237
241
|
async def _assert_bucket_exists(self, bucket_id: str) -> None:
|
|
238
|
-
"""
|
|
239
|
-
|
|
242
|
+
"""Assert that the bucket with the specified ID (`bucket_id`) exists.
|
|
243
|
+
|
|
244
|
+
If it does not exist, a BucketNotFoundError is raised.
|
|
240
245
|
"""
|
|
241
246
|
if not await self.does_bucket_exist(bucket_id):
|
|
242
247
|
raise self.BucketNotFoundError(bucket_id=bucket_id)
|
|
243
248
|
|
|
244
249
|
async def _assert_bucket_not_exists(self, bucket_id: str) -> None:
|
|
245
|
-
"""
|
|
246
|
-
|
|
250
|
+
"""Assert that the bucket with the specified ID (`bucket_id`) does not exist.
|
|
251
|
+
|
|
252
|
+
If it exists, a BucketAlreadyExistsError is raised.
|
|
247
253
|
"""
|
|
248
254
|
if await self.does_bucket_exist(bucket_id):
|
|
249
255
|
raise self.BucketAlreadyExistsError(bucket_id=bucket_id)
|
|
@@ -274,7 +280,7 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
274
280
|
await self._assert_bucket_exists(bucket_id)
|
|
275
281
|
|
|
276
282
|
try:
|
|
277
|
-
bucket = self._resource.Bucket(bucket_id)
|
|
283
|
+
bucket = self._resource.Bucket(bucket_id) # pyright: ignore
|
|
278
284
|
content = await asyncio.to_thread(bucket.objects.all)
|
|
279
285
|
if delete_content:
|
|
280
286
|
await asyncio.to_thread(content.delete)
|
|
@@ -289,7 +295,7 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
289
295
|
await self._assert_bucket_exists(bucket_id)
|
|
290
296
|
|
|
291
297
|
try:
|
|
292
|
-
bucket = self._resource.Bucket(bucket_id)
|
|
298
|
+
bucket = self._resource.Bucket(bucket_id) # pyright: ignore
|
|
293
299
|
content = await asyncio.to_thread(bucket.objects.all)
|
|
294
300
|
return [object_summary.key for object_summary in content]
|
|
295
301
|
except botocore.exceptions.ClientError as error:
|
|
@@ -306,17 +312,18 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
306
312
|
Return `True` if checks succeed and `False` otherwise.
|
|
307
313
|
"""
|
|
308
314
|
if object_md5sum is not None:
|
|
309
|
-
raise NotImplementedError("
|
|
315
|
+
raise NotImplementedError("MD5 checking is not yet implemented.")
|
|
310
316
|
|
|
311
317
|
try:
|
|
312
|
-
|
|
313
|
-
self._client.head_object,
|
|
314
|
-
Bucket=bucket_id,
|
|
315
|
-
Key=object_id,
|
|
318
|
+
await asyncio.to_thread(
|
|
319
|
+
self._client.head_object, Bucket=bucket_id, Key=object_id
|
|
316
320
|
)
|
|
317
|
-
except botocore.exceptions.ClientError:
|
|
318
|
-
|
|
319
|
-
|
|
321
|
+
except botocore.exceptions.ClientError as error:
|
|
322
|
+
if error.response["Error"]["Code"] == "404":
|
|
323
|
+
return False
|
|
324
|
+
raise self._translate_s3_client_errors(
|
|
325
|
+
error, bucket_id=bucket_id, object_id=object_id
|
|
326
|
+
) from error
|
|
320
327
|
return True
|
|
321
328
|
|
|
322
329
|
async def _assert_object_exists(self, *, bucket_id: str, object_id: str) -> None:
|
|
@@ -451,7 +458,7 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
451
458
|
)
|
|
452
459
|
|
|
453
460
|
async def _init_multipart_upload(self, *, bucket_id: str, object_id: str) -> str:
|
|
454
|
-
"""Initiates a
|
|
461
|
+
"""Initiates a multipart upload procedure. Returns the upload ID."""
|
|
455
462
|
await self._assert_no_multipart_upload(bucket_id=bucket_id, object_id=object_id)
|
|
456
463
|
|
|
457
464
|
try:
|
|
@@ -473,6 +480,7 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
473
480
|
object_id: str,
|
|
474
481
|
part_number: int,
|
|
475
482
|
expires_after: int = 3600,
|
|
483
|
+
part_md5: Optional[str] = None,
|
|
476
484
|
) -> str:
|
|
477
485
|
"""Given a id of an instantiated multipart upload along with the corresponding
|
|
478
486
|
bucket and object ID, it returns a presigned URL for uploading a file part with the
|
|
@@ -491,16 +499,20 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
491
499
|
upload_id=upload_id, bucket_id=bucket_id, object_id=object_id
|
|
492
500
|
)
|
|
493
501
|
|
|
502
|
+
params = {
|
|
503
|
+
"Bucket": bucket_id,
|
|
504
|
+
"Key": object_id,
|
|
505
|
+
"UploadId": upload_id,
|
|
506
|
+
"PartNumber": part_number,
|
|
507
|
+
}
|
|
508
|
+
# add additional parameters if any were passed
|
|
509
|
+
if part_md5:
|
|
510
|
+
params["ContentMD5"] = part_md5
|
|
494
511
|
try:
|
|
495
512
|
return await asyncio.to_thread(
|
|
496
513
|
self._client.generate_presigned_url,
|
|
497
514
|
ClientMethod="upload_part",
|
|
498
|
-
Params=
|
|
499
|
-
"Bucket": bucket_id,
|
|
500
|
-
"Key": object_id,
|
|
501
|
-
"UploadId": upload_id,
|
|
502
|
-
"PartNumber": part_number,
|
|
503
|
-
},
|
|
515
|
+
Params=params,
|
|
504
516
|
ExpiresIn=expires_after,
|
|
505
517
|
)
|
|
506
518
|
except botocore.exceptions.ClientError as error:
|
|
@@ -757,6 +769,22 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
757
769
|
|
|
758
770
|
return presigned_url
|
|
759
771
|
|
|
772
|
+
async def _get_object_etag(self, *, bucket_id: str, object_id: str) -> str:
|
|
773
|
+
"""Return the etag of an object."""
|
|
774
|
+
await self._assert_object_exists(bucket_id=bucket_id, object_id=object_id)
|
|
775
|
+
|
|
776
|
+
object_metadata = await self._get_object_metadata(
|
|
777
|
+
bucket_id=bucket_id, object_id=object_id
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
if "ETag" not in object_metadata:
|
|
781
|
+
raise self.ObjectError(
|
|
782
|
+
f"Could not get the etag of the object with ID '{object_id}' in"
|
|
783
|
+
+ f" bucket '{bucket_id}'."
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
return object_metadata["ETag"]
|
|
787
|
+
|
|
760
788
|
async def _get_object_size(self, *, bucket_id: str, object_id: str) -> int:
|
|
761
789
|
"""Returns the size of an object in bytes."""
|
|
762
790
|
await self._assert_object_exists(bucket_id=bucket_id, object_id=object_id)
|
|
@@ -795,9 +823,15 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
795
823
|
source_object_id: str,
|
|
796
824
|
dest_bucket_id: str,
|
|
797
825
|
dest_object_id: str,
|
|
826
|
+
abort_failed: bool = True,
|
|
798
827
|
) -> None:
|
|
799
828
|
"""Copy an object from one bucket (`source_bucket_id` and `source_object_id`) to
|
|
800
829
|
another bucket (`dest_bucket_id` and `dest_object_id`).
|
|
830
|
+
|
|
831
|
+
If `abort_failed` is set to true (default), a failed copy operation tries to
|
|
832
|
+
abort the ongoing multipart upload it created (if using multipart mode).
|
|
833
|
+
This only works reliably as long as there are no other ongoing multipart operations for
|
|
834
|
+
the same destination bucket and object ID, in which case this should be set to false.
|
|
801
835
|
"""
|
|
802
836
|
file_size = await self._get_object_size(
|
|
803
837
|
bucket_id=source_bucket_id, object_id=source_object_id
|
|
@@ -825,6 +859,22 @@ class S3ObjectStorage(ObjectStorageProtocol):
|
|
|
825
859
|
Config=transfer_config,
|
|
826
860
|
)
|
|
827
861
|
except botocore.exceptions.ClientError as error:
|
|
862
|
+
if abort_failed:
|
|
863
|
+
# try to find and abort the multipart operation, if multipart copy mode is used
|
|
864
|
+
# There should only be one ongoing multipart upload/copy at at a time as long
|
|
865
|
+
# as a new object ID is generated for each attempt
|
|
866
|
+
try:
|
|
867
|
+
upload_ids = await self._list_multipart_upload_for_object(
|
|
868
|
+
bucket_id=dest_bucket_id, object_id=dest_object_id
|
|
869
|
+
)
|
|
870
|
+
if len(upload_ids) == 1:
|
|
871
|
+
await self._abort_multipart_upload(
|
|
872
|
+
upload_id=upload_ids[0],
|
|
873
|
+
bucket_id=dest_bucket_id,
|
|
874
|
+
object_id=dest_object_id,
|
|
875
|
+
)
|
|
876
|
+
except botocore.exceptions.ClientError:
|
|
877
|
+
pass
|
|
828
878
|
raise self._translate_s3_client_errors(error) from error
|
|
829
879
|
|
|
830
880
|
async def _delete_object(self, *, bucket_id: str, object_id: str) -> None:
|
|
@@ -20,18 +20,27 @@ from ._fixtures import (
|
|
|
20
20
|
MEBIBYTE,
|
|
21
21
|
TEST_FILE_DIR,
|
|
22
22
|
TEST_FILE_PATHS,
|
|
23
|
+
FederatedS3Fixture,
|
|
23
24
|
FileObject,
|
|
24
25
|
S3ContainerFixture,
|
|
25
26
|
S3Fixture,
|
|
27
|
+
S3MultiContainerFixture,
|
|
26
28
|
calc_md5,
|
|
29
|
+
clean_federated_s3_fixture,
|
|
27
30
|
clean_s3_fixture,
|
|
31
|
+
federated_s3_fixture,
|
|
32
|
+
get_clean_federated_s3_fixture,
|
|
28
33
|
get_clean_s3_fixture,
|
|
34
|
+
get_persistent_federated_s3_fixture,
|
|
29
35
|
get_persistent_s3_fixture,
|
|
30
36
|
get_s3_container_fixture,
|
|
37
|
+
get_s3_multi_container_fixture,
|
|
38
|
+
persistent_federated_s3_fixture,
|
|
31
39
|
persistent_s3_fixture,
|
|
32
40
|
populate_storage,
|
|
33
41
|
s3_container_fixture,
|
|
34
42
|
s3_fixture,
|
|
43
|
+
s3_multi_container_fixture,
|
|
35
44
|
temp_file_object,
|
|
36
45
|
tmp_file,
|
|
37
46
|
upload_file,
|
|
@@ -73,4 +82,13 @@ __all__ = [
|
|
|
73
82
|
"upload_part",
|
|
74
83
|
"upload_part_of_size",
|
|
75
84
|
"upload_part_via_url",
|
|
85
|
+
"FederatedS3Fixture",
|
|
86
|
+
"S3MultiContainerFixture",
|
|
87
|
+
"clean_federated_s3_fixture",
|
|
88
|
+
"federated_s3_fixture",
|
|
89
|
+
"get_clean_federated_s3_fixture",
|
|
90
|
+
"get_s3_multi_container_fixture",
|
|
91
|
+
"get_persistent_federated_s3_fixture",
|
|
92
|
+
"s3_multi_container_fixture",
|
|
93
|
+
"persistent_federated_s3_fixture",
|
|
76
94
|
]
|
|
@@ -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
|
)
|
|
@@ -344,3 +348,148 @@ def tmp_file() -> Generator[FileObject, None, None]:
|
|
|
344
348
|
"""A fixture that provides a temporary file."""
|
|
345
349
|
with temp_file_object() as temp_file:
|
|
346
350
|
yield temp_file
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class FederatedS3Fixture:
|
|
354
|
+
"""Fixture containing multiple S3 fixtures to simulate federated storage."""
|
|
355
|
+
|
|
356
|
+
def __init__(self, storages: dict[str, S3Fixture]):
|
|
357
|
+
self.storages = storages
|
|
358
|
+
|
|
359
|
+
def get_configs_by_alias(self) -> dict[str, S3Config]:
|
|
360
|
+
"""Get the S3Config instance for each object storage in the fixture."""
|
|
361
|
+
return {alias: self.storages[alias].config for alias in self.storages}
|
|
362
|
+
|
|
363
|
+
async def populate_dummy_items(self, alias: str, contents: dict[str, list[str]]):
|
|
364
|
+
"""Convenience function to populate a specific S3Fixture.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
- `alias`: The alias of the S3Fixture to populate.
|
|
368
|
+
- `contents`: A dictionary with bucket names as keys and lists of object names
|
|
369
|
+
as values. The buckets can be empty, and the objects are created with a size of
|
|
370
|
+
1 byte.
|
|
371
|
+
"""
|
|
372
|
+
if alias not in self.storages:
|
|
373
|
+
# This would indicate some kind of mismatch between config and fixture
|
|
374
|
+
raise RuntimeError(f"Alias '{alias}' not found in the federated S3 fixture")
|
|
375
|
+
storage = self.storages[alias]
|
|
376
|
+
|
|
377
|
+
# Populate the buckets so even empty buckets are established
|
|
378
|
+
await storage.populate_buckets([bucket for bucket in contents])
|
|
379
|
+
|
|
380
|
+
# Add the dummy items
|
|
381
|
+
for bucket, objects in contents.items():
|
|
382
|
+
for obj in objects:
|
|
383
|
+
with temp_file_object(bucket, obj, 1) as file:
|
|
384
|
+
await storage.populate_file_objects([file])
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class S3MultiContainerFixture:
|
|
388
|
+
"""Fixture for managing multiple running S3 test containers in order to mimic
|
|
389
|
+
multiple object storages.
|
|
390
|
+
|
|
391
|
+
Without this fixture, separate S3Fixture instances would access the same
|
|
392
|
+
underlying storage resources.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
def __init__(self, s3_containers: dict[str, S3ContainerFixture]):
|
|
396
|
+
self.s3_containers = s3_containers
|
|
397
|
+
|
|
398
|
+
def __enter__(self):
|
|
399
|
+
"""Enter the context manager and start the S3 containers."""
|
|
400
|
+
for container in self.s3_containers.values():
|
|
401
|
+
container.__enter__()
|
|
402
|
+
return self
|
|
403
|
+
|
|
404
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
405
|
+
"""Exit the context manager and clean up the S3 containers."""
|
|
406
|
+
for container in self.s3_containers.values():
|
|
407
|
+
container.__exit__(exc_type, exc_val, exc_tb)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _s3_multi_container_fixture(
|
|
411
|
+
request,
|
|
412
|
+
) -> Generator[S3MultiContainerFixture, None, None]:
|
|
413
|
+
"""Fixture function for getting multiple running S3 test containers."""
|
|
414
|
+
try:
|
|
415
|
+
storage_aliases = request.getfixturevalue("storage_aliases")
|
|
416
|
+
except pytest.FixtureLookupError as err:
|
|
417
|
+
raise NotImplementedError(
|
|
418
|
+
"You must provide a 'storage_aliases' fixture in your test setup."
|
|
419
|
+
+ " It must have the same scope as 's3_multi_container'"
|
|
420
|
+
+ " and it must return a list of the storage aliases to be used."
|
|
421
|
+
) from err
|
|
422
|
+
|
|
423
|
+
if not storage_aliases:
|
|
424
|
+
raise RuntimeError("The 'storage_aliases' list must not be empty.")
|
|
425
|
+
s3_containers = {alias: S3ContainerFixture() for alias in storage_aliases}
|
|
426
|
+
with S3MultiContainerFixture(s3_containers) as s3_multi_container:
|
|
427
|
+
yield s3_multi_container
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def get_s3_multi_container_fixture(
|
|
431
|
+
scope: PytestScope = "session", name: str = "s3_multi_container"
|
|
432
|
+
):
|
|
433
|
+
"""Get a fixture containing multiple LocalStack test containers.
|
|
434
|
+
|
|
435
|
+
By default, the session scope is used for LocalStack test containers.
|
|
436
|
+
Requires that a 'storage_aliases' fixture is provided in the test setup.
|
|
437
|
+
"""
|
|
438
|
+
return pytest.fixture(_s3_multi_container_fixture, scope=scope, name=name)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
s3_multi_container_fixture = get_s3_multi_container_fixture()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _persistent_federated_s3_fixture(
|
|
445
|
+
s3_multi_container: S3MultiContainerFixture,
|
|
446
|
+
) -> Generator[FederatedS3Fixture, None, None]:
|
|
447
|
+
"""Fixture function that creates a persistent FederatedS3Fixture.
|
|
448
|
+
|
|
449
|
+
The state of each S3 storage in the fixture is not cleaned up.
|
|
450
|
+
"""
|
|
451
|
+
s3_fixtures = {}
|
|
452
|
+
for alias, container in s3_multi_container.s3_containers.items():
|
|
453
|
+
config = container.s3_config
|
|
454
|
+
storage = S3ObjectStorage(config=config)
|
|
455
|
+
s3_fixtures[alias] = S3Fixture(config=config, storage=storage)
|
|
456
|
+
yield FederatedS3Fixture(s3_fixtures)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def get_persistent_federated_s3_fixture(
|
|
460
|
+
scope: PytestScope = "function", name: str = "federated_s3"
|
|
461
|
+
):
|
|
462
|
+
"""Get a federated S3 storage fixture with desired scope.
|
|
463
|
+
|
|
464
|
+
The state of the S3 storage is not cleaned up by the fixture.
|
|
465
|
+
"""
|
|
466
|
+
return pytest.fixture(_persistent_federated_s3_fixture, scope=scope, name=name)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
persistent_federated_s3_fixture = get_persistent_federated_s3_fixture()
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
async def _clean_federated_s3_fixture(
|
|
473
|
+
s3_multi_container: S3MultiContainerFixture,
|
|
474
|
+
) -> AsyncGenerator[FederatedS3Fixture, None]:
|
|
475
|
+
"""Fixture function that creates a clean FederatedS3Fixture instance.
|
|
476
|
+
|
|
477
|
+
The state of each S3 storage is cleaned up before yielding the fixture.
|
|
478
|
+
"""
|
|
479
|
+
for federated_s3_fixture in _persistent_federated_s3_fixture(s3_multi_container):
|
|
480
|
+
for s3_fixture in federated_s3_fixture.storages.values():
|
|
481
|
+
await s3_fixture.delete_buckets()
|
|
482
|
+
yield federated_s3_fixture
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def get_clean_federated_s3_fixture(
|
|
486
|
+
scope: PytestScope = "function", name: str = "federated_s3"
|
|
487
|
+
):
|
|
488
|
+
"""Get a federated S3 storage fixture with desired scope.
|
|
489
|
+
|
|
490
|
+
The state of the S3 storage is not cleaned up by the fixture.
|
|
491
|
+
"""
|
|
492
|
+
return pytest_asyncio.fixture(_clean_federated_s3_fixture, scope=scope, name=name)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
federated_s3_fixture = clean_federated_s3_fixture = get_clean_federated_s3_fixture()
|
|
@@ -44,7 +44,7 @@ class FileObject(BaseModel):
|
|
|
44
44
|
bucket_id: str
|
|
45
45
|
object_id: str
|
|
46
46
|
|
|
47
|
-
@computed_field # type: ignore [
|
|
47
|
+
@computed_field # type: ignore [prop-decorator]
|
|
48
48
|
@property
|
|
49
49
|
def content(self) -> bytes:
|
|
50
50
|
"""Extract the content from the file at the provided path"""
|
|
@@ -53,7 +53,7 @@ class FileObject(BaseModel):
|
|
|
53
53
|
with open(self.file_path, "rb") as file:
|
|
54
54
|
return file.read()
|
|
55
55
|
|
|
56
|
-
@computed_field # type: ignore [
|
|
56
|
+
@computed_field # type: ignore [prop-decorator]
|
|
57
57
|
@property
|
|
58
58
|
def md5(self) -> str:
|
|
59
59
|
"""Calculate the md5 hash of the 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
|