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.
Files changed (56) hide show
  1. {hexkit-3.5.0/src/hexkit.egg-info → hexkit-3.7.0}/PKG-INFO +1 -1
  2. {hexkit-3.5.0 → hexkit-3.7.0}/pyproject.toml +1 -1
  3. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/config.py +1 -1
  4. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/dao.py +14 -0
  5. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/objstorage.py +31 -0
  6. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/testcontainer.py +1 -1
  7. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongodb/provider.py +12 -0
  8. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongokafka/provider.py +16 -2
  9. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/provider.py +75 -25
  10. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/testutils/__init__.py +18 -0
  11. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/testutils/_fixtures.py +156 -7
  12. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/testutils/_utils.py +2 -2
  13. {hexkit-3.5.0 → hexkit-3.7.0/src/hexkit.egg-info}/PKG-INFO +1 -1
  14. {hexkit-3.5.0 → hexkit-3.7.0}/LICENSE +0 -0
  15. {hexkit-3.5.0 → hexkit-3.7.0}/MANIFEST.in +0 -0
  16. {hexkit-3.5.0 → hexkit-3.7.0}/README.md +0 -0
  17. {hexkit-3.5.0 → hexkit-3.7.0}/setup.cfg +0 -0
  18. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/__init__.py +0 -0
  19. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/__main__.py +0 -0
  20. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/base.py +0 -0
  21. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/correlation.py +0 -0
  22. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/custom_types.py +0 -0
  23. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/log.py +0 -0
  24. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/__init__.py +0 -0
  25. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/daopub.py +0 -0
  26. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/daosub.py +0 -0
  27. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/eventpub.py +0 -0
  28. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/protocols/eventsub.py +0 -0
  29. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/__init__.py +0 -0
  30. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  31. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/config.py +0 -0
  32. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
  33. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
  34. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
  35. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
  36. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
  37. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/akafka/testutils.py +0 -0
  38. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  39. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
  40. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
  41. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
  42. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/__init__.py +0 -0
  43. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  44. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  45. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  46. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  47. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  48. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
  49. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/testing/__init__.py +0 -0
  50. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/providers/testing/eventpub.py +0 -0
  51. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/py.typed +0 -0
  52. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit/utils.py +0 -0
  53. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit.egg-info/SOURCES.txt +0 -0
  54. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  55. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit.egg-info/requires.txt +0 -0
  56. {hexkit-3.5.0 → hexkit-3.7.0}/src/hexkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: hexkit
3
- Version: 3.5.0
3
+ Version: 3.7.0
4
4
  Summary: A Toolkit for Building Microservices using the Hexagonal Architecture
5
5
  Author-email: "German Human Genome Phenome Archive (GHGA)" <contact@ghga.de>
6
6
  License: Apache 2.0
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Intended Audience :: Developers",
24
24
  ]
25
25
  name = "hexkit"
26
- version = "3.5.0"
26
+ version = "3.7.0"
27
27
  description = "A Toolkit for Building Microservices using the Hexagonal Architecture"
28
28
  dependencies = [
29
29
  "pydantic >=2, <3",
@@ -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( # noqa: PLR0913
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() # type: ignore
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
- {"_id": document["_id"], "__metadata__.deleted": False}, document
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
- {"_id": document["_id"], "__metadata__.deleted": False}, document
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
- Return `True` if it exists and `False` otherwise.
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
- bucket_list = await asyncio.to_thread(self._client.list_buckets)
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
- """Checks if the bucket with specified ID (`bucket_id`) exists and throws an
239
- BucketNotFoundError otherwise.
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
- """Checks if the bucket with specified ID (`bucket_id`) exists. If so, it throws
246
- an BucketAlreadyExistsError.
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("Md5 checking is not yet implemented.")
315
+ raise NotImplementedError("MD5 checking is not yet implemented.")
310
316
 
311
317
  try:
312
- _ = await asyncio.to_thread(
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
- return False
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 mulipart upload procedure. Returns the upload ID."""
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 Any, NamedTuple, Optional, Union
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.5.0"
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
- port: int = 4566,
226
+ image: str = LOCALSTACK_IMAGE,
227
+ edge_port: int = 4566,
227
228
  region_name: Optional[str] = None,
228
- **kwargs: Any,
229
+ **kwargs,
229
230
  ) -> None:
230
231
  """Initialize the container."""
231
- super().__init__(image=LOCALSTACK_IMAGE)
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
- s3_endpoint_url = self.get_url()
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=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 [misc]
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 [misc]
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"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: hexkit
3
- Version: 3.5.0
3
+ Version: 3.7.0
4
4
  Summary: A Toolkit for Building Microservices using the Hexagonal Architecture
5
5
  Author-email: "German Human Genome Phenome Archive (GHGA)" <contact@ghga.de>
6
6
  License: Apache 2.0
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