hexkit 3.3.0__tar.gz → 3.5.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.3.0/src/hexkit.egg-info → hexkit-3.5.0}/PKG-INFO +1 -1
  2. {hexkit-3.3.0 → hexkit-3.5.0}/pyproject.toml +1 -1
  3. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/config.py +6 -29
  4. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/protocols/dao.py +1 -1
  5. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/protocols/objstorage.py +21 -18
  6. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/akafka/config.py +7 -1
  7. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/akafka/provider/eventpub.py +4 -0
  8. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/akafka/provider/eventsub.py +4 -0
  9. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/akafka/testutils.py +1 -0
  10. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/mongodb/provider.py +6 -1
  11. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/mongodb/testutils.py +16 -10
  12. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/mongokafka/provider.py +12 -0
  13. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/testutils/__init__.py +32 -3
  14. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/testutils/_fixtures.py +57 -11
  15. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/testing/eventpub.py +2 -0
  16. {hexkit-3.3.0 → hexkit-3.5.0/src/hexkit.egg-info}/PKG-INFO +1 -1
  17. {hexkit-3.3.0 → hexkit-3.5.0}/LICENSE +0 -0
  18. {hexkit-3.3.0 → hexkit-3.5.0}/MANIFEST.in +0 -0
  19. {hexkit-3.3.0 → hexkit-3.5.0}/README.md +0 -0
  20. {hexkit-3.3.0 → hexkit-3.5.0}/setup.cfg +0 -0
  21. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/__init__.py +0 -0
  22. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/__main__.py +0 -0
  23. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/base.py +0 -0
  24. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/correlation.py +0 -0
  25. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/custom_types.py +0 -0
  26. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/log.py +0 -0
  27. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/protocols/__init__.py +0 -0
  28. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/protocols/daopub.py +0 -0
  29. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/protocols/daosub.py +0 -0
  30. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/protocols/eventpub.py +0 -0
  31. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/protocols/eventsub.py +0 -0
  32. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/__init__.py +0 -0
  33. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  34. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
  35. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
  36. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
  37. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
  38. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  39. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
  40. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
  41. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/__init__.py +0 -0
  42. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/provider.py +0 -0
  43. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  44. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  45. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  46. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  47. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  48. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
  49. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
  50. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/providers/testing/__init__.py +0 -0
  51. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/py.typed +0 -0
  52. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit/utils.py +0 -0
  53. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit.egg-info/SOURCES.txt +0 -0
  54. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  55. {hexkit-3.3.0 → hexkit-3.5.0}/src/hexkit.egg-info/requires.txt +0 -0
  56. {hexkit-3.3.0 → hexkit-3.5.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.3.0
3
+ Version: 3.5.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.3.0"
26
+ version = "3.5.0"
27
27
  description = "A Toolkit for Building Microservices using the Hexagonal Architecture"
28
28
  dependencies = [
29
29
  "pydantic >=2, <3",
@@ -17,13 +17,13 @@
17
17
 
18
18
  import os
19
19
  from pathlib import Path
20
- from typing import Any, Callable, Final, Optional
20
+ from typing import Callable, Final, Optional
21
21
 
22
- import yaml
23
22
  from pydantic_settings import (
24
23
  BaseSettings,
25
24
  PydanticBaseSettingsSource,
26
25
  SettingsConfigDict,
26
+ YamlConfigSettingsSource,
27
27
  )
28
28
 
29
29
  # Default config prefix:
@@ -79,29 +79,6 @@ def get_default_config_yaml(prefix: str) -> Optional[Path]:
79
79
  return None
80
80
 
81
81
 
82
- def yaml_settings_factory(
83
- config_yaml: Optional[Path] = None,
84
- ) -> Callable[[], dict[str, Any]]:
85
- """
86
- A factory of source methods for pydantic's BaseSettings Config that load
87
- settings from a yaml file.
88
-
89
- Args:
90
- config_yaml (str, Optional):
91
- Path to the yaml file to read from.
92
- """
93
-
94
- def yaml_settings() -> dict[str, Any]:
95
- """Source method for loading pydantic BaseSettings from a yaml file"""
96
- if config_yaml is None:
97
- return {}
98
-
99
- with open(config_yaml, encoding="utf8") as yaml_file:
100
- return yaml.safe_load(yaml_file)
101
-
102
- return yaml_settings
103
-
104
-
105
82
  def config_from_yaml(
106
83
  prefix: str = DEFAULT_CONFIG_PREFIX,
107
84
  ) -> Callable:
@@ -119,7 +96,7 @@ def config_from_yaml(
119
96
 
120
97
  Args:
121
98
  prefix: (str, optional):
122
- When defining parameters via enviroment variables, all variables
99
+ When defining parameters via environment variables, all variables
123
100
  have to be prefixed with this string following this pattern
124
101
  "{prefix}_{actual_variable_name}". Moreover, this prefix is used
125
102
  to derive the default location for the config yaml file
@@ -161,7 +138,7 @@ def config_from_yaml(
161
138
  raise ConfigYamlDoesNotExist(path=config_yaml)
162
139
 
163
140
  class ModSettings(settings):
164
- """Modifies the orginal Settings class provided by the user"""
141
+ """Modifies the original Settings class provided by the user"""
165
142
 
166
143
  model_config = SettingsConfigDict(frozen=True, env_prefix=f"{prefix}_")
167
144
 
@@ -174,12 +151,12 @@ def config_from_yaml(
174
151
  dotenv_settings: PydanticBaseSettingsSource,
175
152
  file_secret_settings: PydanticBaseSettingsSource,
176
153
  ):
177
- """Add custom yaml source"""
154
+ """Add custom yaml source, but maintain the priority of sources."""
178
155
  return (
179
156
  init_settings,
180
157
  env_settings,
181
158
  file_secret_settings,
182
- yaml_settings_factory(config_yaml),
159
+ YamlConfigSettingsSource(settings_cls, config_yaml),
183
160
  )
184
161
 
185
162
  # construct settings class:
@@ -61,7 +61,7 @@ class ResourceAlreadyExistsError(RuntimeError):
61
61
  """Raised when a resource did unexpectedly exist."""
62
62
 
63
63
  def __init__(self, *, id_: str):
64
- message = f'The resource with the id "{id_}" does already exist.'
64
+ message = f'The resource with the id "{id_}" already exists.'
65
65
  super().__init__(message)
66
66
 
67
67
 
@@ -457,7 +457,10 @@ class ObjectStorageProtocol(ABC):
457
457
 
458
458
  # Validation logic for input parameter:
459
459
  # (is typically only used by the protocol but may also be used in
460
- # provider-specific code)
460
+ # provider-specific code or overwritten by the provider)
461
+
462
+ _re_bucket_id = re.compile(r"^[a-z0-9\-]{3,63}$")
463
+ _re_bucket_id_msg = "must consist of 3-63 lowercase letters, digits or hyphens"
461
464
 
462
465
  @classmethod
463
466
  def _validate_bucket_id(cls, bucket_id: str):
@@ -466,43 +469,43 @@ class ObjectStorageProtocol(ABC):
466
469
  https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html
467
470
  Raises BucketIdValidationError if not valid.
468
471
  """
469
- if not 3 <= len(bucket_id) < 64:
472
+ if not cls._re_bucket_id.match(bucket_id):
470
473
  raise cls.BucketIdValidationError(
471
474
  bucket_id=bucket_id,
472
- reason="must be between 3 and 63 characters long",
473
- )
474
- if not re.match(r"^[a-z0-9\-]*$", bucket_id):
475
- raise cls.BucketIdValidationError(
476
- bucket_id=bucket_id,
477
- reason="only lowercase letters, digits and hyphens (-) are allowed",
475
+ reason=cls._re_bucket_id_msg,
478
476
  )
479
477
  if bucket_id.startswith("-") or bucket_id.endswith("-"):
480
478
  raise cls.BucketIdValidationError(
481
479
  bucket_id=bucket_id,
482
- reason="may not start or end with a hyphen (-).",
480
+ reason="may not start or end with a hyphen",
483
481
  )
484
482
 
483
+ _re_object_id = re.compile(r"^[a-zA-Z0-9\-_.]{3,255}$")
484
+ _re_object_id_msg = (
485
+ "must consist of 3-255 letters, digits, hyphens, underscores or dots"
486
+ )
487
+
485
488
  @classmethod
486
489
  def _validate_object_id(cls, object_id: str):
487
- """Check whether a object id follows the recommended naming pattern.
490
+ """Check whether an object id follows the recommended naming pattern.
488
491
  This is roughly based on (plus some additional restrictions):
489
492
  https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
490
493
  Raises ObjectIdValidationError if not valid.
494
+
495
+ Note that the default implementation does not allow object IDs to contain
496
+ forward slashes (/) which are sometimes used to represent directories.
497
+ Providers can override this method or the above constants to allow slashes,
498
+ other characters or longer object names.
491
499
  """
492
- if not 3 <= len(object_id) < 64:
493
- raise cls.ObjectIdValidationError(
494
- object_id=object_id,
495
- reason="must be between 3 and 63 characters long",
496
- )
497
- if not re.match(r"^[a-zA-Z0-9\-\.]*$", object_id):
500
+ if not cls._re_object_id.match(object_id):
498
501
  raise cls.ObjectIdValidationError(
499
502
  object_id=object_id,
500
- reason="only letters, digits, hyphens (-) and dots (.) are allowed",
503
+ reason=cls._re_object_id_msg,
501
504
  )
502
505
  if object_id.startswith(("-", ".")) or object_id.endswith(("-", ".")):
503
506
  raise cls.ObjectIdValidationError(
504
507
  object_id=object_id,
505
- reason="may not start or end with a hyphen (-) or a dot (.).",
508
+ reason="object names may not start or end with a hyphen or a dot",
506
509
  )
507
510
 
508
511
  # Exceptions that may be used by implementation:
@@ -18,7 +18,7 @@
18
18
 
19
19
  from typing import Literal
20
20
 
21
- from pydantic import Field, SecretStr
21
+ from pydantic import Field, PositiveInt, SecretStr
22
22
  from pydantic_settings import BaseSettings
23
23
 
24
24
 
@@ -76,3 +76,9 @@ class KafkaConfig(BaseSettings):
76
76
  + " newly correlation ID will be generated and used in the event header."
77
77
  ),
78
78
  )
79
+ kafka_max_message_size: PositiveInt = Field(
80
+ default=1024 * 1024, # 1 MiB
81
+ description="The largest message size that can be transmitted, in bytes. Only"
82
+ + " services that have a need to send/receive larger messages should set this.",
83
+ examples=[1024 * 1024, 16 * 1024 * 1024],
84
+ )
@@ -54,6 +54,7 @@ class KafkaProducerCompatible(Protocol):
54
54
  client_id: str,
55
55
  key_serializer: Callable[[Any], bytes],
56
56
  value_serializer: Callable[[Any], bytes],
57
+ max_request_size: int,
57
58
  ):
58
59
  """
59
60
  Initialize the producer with some config params.
@@ -67,6 +68,8 @@ class KafkaProducerCompatible(Protocol):
67
68
  Function to serialize the keys into bytes.
68
69
  value_serializer:
69
70
  Function to serialize the values into bytes.
71
+ max_request_size:
72
+ Maximum sendable message size.
70
73
  """
71
74
  ...
72
75
 
@@ -116,6 +119,7 @@ class KafkaEventPublisher(EventPublisherProtocol):
116
119
  value_serializer=lambda event_value: json.dumps(event_value).encode(
117
120
  "ascii"
118
121
  ),
122
+ max_request_size=config.kafka_max_message_size,
119
123
  )
120
124
 
121
125
  try:
@@ -90,6 +90,7 @@ class KafkaConsumerCompatible(Protocol):
90
90
  enable_auto_commit: bool,
91
91
  key_deserializer: Callable[[bytes], str],
92
92
  value_deserializer: Callable[[bytes], str],
93
+ max_partition_fetch_bytes: int,
93
94
  ):
94
95
  """
95
96
  Initialize the consumer with some config params.
@@ -108,6 +109,8 @@ class KafkaConsumerCompatible(Protocol):
108
109
  Function to deserialize the keys into strings.
109
110
  value_serializer:
110
111
  Function to deserialize the values into strings.
112
+ max_partition_fetch_bytes:
113
+ Maximum receivable message size
111
114
  """
112
115
  ...
113
116
 
@@ -176,6 +179,7 @@ class KafkaEventSubscriber(InboundProviderBase):
176
179
  value_deserializer=lambda event_value: json.loads(
177
180
  event_value.decode("ascii")
178
181
  ),
182
+ max_partition_fetch_bytes=config.kafka_max_message_size,
179
183
  )
180
184
  try:
181
185
  await consumer.start()
@@ -430,6 +430,7 @@ class KafkaFixture:
430
430
 
431
431
  async def clear_topics(
432
432
  self,
433
+ *,
433
434
  topics: Optional[Union[str, list[str]]] = None,
434
435
  exclude_internal: bool = True,
435
436
  ):
@@ -31,6 +31,7 @@ from motor.core import AgnosticCollection
31
31
  from motor.motor_asyncio import AsyncIOMotorClient
32
32
  from pydantic import Field, SecretStr
33
33
  from pydantic_settings import BaseSettings
34
+ from pymongo.errors import DuplicateKeyError
34
35
 
35
36
  from hexkit.protocols.dao import (
36
37
  DaoFactoryProtocol,
@@ -42,6 +43,7 @@ from hexkit.protocols.dao import (
42
43
  InvalidFindMappingError,
43
44
  MultipleHitsFoundError,
44
45
  NoHitsFoundError,
46
+ ResourceAlreadyExistsError,
45
47
  ResourceNotFoundError,
46
48
  )
47
49
  from hexkit.utils import FieldNotInModelError, validate_fields_in_model
@@ -386,7 +388,10 @@ class MongoDbDaoNaturalId(MongoDbDaoBase[Dto]):
386
388
  when a resource with the ID specified in the dto does already exist.
387
389
  """
388
390
  document = self._dto_to_document(dto)
389
- await self._collection.insert_one(document)
391
+ try:
392
+ await self._collection.insert_one(document)
393
+ except DuplicateKeyError as error:
394
+ raise ResourceAlreadyExistsError(id_=document["_id"]) from error
390
395
 
391
396
  async def upsert(self, dto: Dto) -> None:
392
397
  """Update the provided resource if it already exists, create it otherwise.
@@ -61,24 +61,30 @@ class MongoDbFixture:
61
61
 
62
62
  def empty_collections(
63
63
  self,
64
+ *,
65
+ collections: Optional[Union[str, list[str]]] = None,
64
66
  exclude_collections: Optional[Union[str, list[str]]] = None,
65
67
  ):
66
- """Drop all mongodb collections in the database.
68
+ """Drop the given mongodb collection(s) in the database.
67
69
 
70
+ If no collections are specified, all collections will be dropped.
68
71
  You can also specify collection(s) that should be excluded
69
72
  from the operation, i.e. collections that should be kept.
70
73
  """
71
74
  db_name = self.config.db_name
72
- if exclude_collections is None:
73
- exclude_collections = []
74
- if isinstance(exclude_collections, str):
75
- exclude_collections = [exclude_collections]
76
- excluded_collections = set(exclude_collections)
77
75
  try:
78
- collection_names = self.client[db_name].list_collection_names()
79
- for collection_name in collection_names:
80
- if collection_name not in excluded_collections:
81
- self.client[db_name].drop_collection(collection_name)
76
+ if collections is None:
77
+ collections = self.client[db_name].list_collection_names()
78
+ elif isinstance(collections, str):
79
+ collections = [collections]
80
+ if exclude_collections is None:
81
+ exclude_collections = []
82
+ elif isinstance(exclude_collections, str):
83
+ exclude_collections = [exclude_collections]
84
+ excluded_collections = set(exclude_collections)
85
+ for collection in collections:
86
+ if collection not in excluded_collections:
87
+ self.client[db_name].drop_collection(collection)
82
88
  except (ExecutionTimeout, OperationFailure) as error:
83
89
  raise RuntimeError(
84
90
  f"Could not drop collection(s) of Mongo database {db_name}"
@@ -20,6 +20,7 @@ Require dependencies of the `akafka` and `mongodb` extras.
20
20
  """
21
21
 
22
22
  import json
23
+ import logging
23
24
  from collections.abc import AsyncIterator, Awaitable, Collection, Mapping
24
25
  from contextlib import AbstractAsyncContextManager, asynccontextmanager, contextmanager
25
26
  from typing import Any, Callable, Generic, Optional
@@ -27,6 +28,7 @@ from typing import Any, Callable, Generic, Optional
27
28
  from aiokafka import AIOKafkaProducer
28
29
  from motor.core import AgnosticCollection
29
30
  from motor.motor_asyncio import AsyncIOMotorClient
31
+ from pydantic import field_validator
30
32
 
31
33
  from hexkit.correlation import get_correlation_id, set_correlation_id
32
34
  from hexkit.custom_types import JsonObject
@@ -396,6 +398,16 @@ class MongoKafkaDaoPublisher(Generic[Dto]):
396
398
  class MongoKafkaConfig(MongoDbConfig, KafkaConfig):
397
399
  """Config parameters and their defaults."""
398
400
 
401
+ @field_validator("kafka_max_message_size", mode="after")
402
+ @classmethod
403
+ def validate_max_message_size(cls, value: int) -> int:
404
+ """Validate the maximum message size."""
405
+ if value > 2**24: # 16 MiB
406
+ logging.warning(
407
+ f"Max message size ({value}) exceeds the 16 MiB document size limit for MongoDB!"
408
+ )
409
+ return value
410
+
399
411
 
400
412
  class MongoKafkaDaoPublisherFactory(DaoPublisherFactoryProtocol):
401
413
  """A provider implementing the DaoPublisherFactoryProtocol based on MongoDB and
@@ -15,7 +15,7 @@
15
15
 
16
16
  """S3-related test fixtures and support functions."""
17
17
 
18
- from ._fixtures import ( # noqa: F401
18
+ from ._fixtures import (
19
19
  LOCALSTACK_IMAGE,
20
20
  MEBIBYTE,
21
21
  TEST_FILE_DIR,
@@ -36,8 +36,8 @@ from ._fixtures import ( # noqa: F401
36
36
  tmp_file,
37
37
  upload_file,
38
38
  )
39
- from ._typical_workflow import typical_workflow # noqa: F401
40
- from ._utils import ( # noqa: F401
39
+ from ._typical_workflow import typical_workflow
40
+ from ._utils import (
41
41
  check_part_size,
42
42
  download_and_check_test_file,
43
43
  multipart_upload_file,
@@ -45,3 +45,32 @@ from ._utils import ( # noqa: F401
45
45
  upload_part_of_size,
46
46
  upload_part_via_url,
47
47
  )
48
+
49
+ __all__ = [
50
+ "LOCALSTACK_IMAGE",
51
+ "MEBIBYTE",
52
+ "TEST_FILE_DIR",
53
+ "TEST_FILE_PATHS",
54
+ "FileObject",
55
+ "S3ContainerFixture",
56
+ "S3Fixture",
57
+ "calc_md5",
58
+ "clean_s3_fixture",
59
+ "get_clean_s3_fixture",
60
+ "get_persistent_s3_fixture",
61
+ "get_s3_container_fixture",
62
+ "persistent_s3_fixture",
63
+ "populate_storage",
64
+ "s3_container_fixture",
65
+ "s3_fixture",
66
+ "temp_file_object",
67
+ "tmp_file",
68
+ "upload_file",
69
+ "typical_workflow",
70
+ "check_part_size",
71
+ "download_and_check_test_file",
72
+ "multipart_upload_file",
73
+ "upload_part",
74
+ "upload_part_of_size",
75
+ "upload_part_via_url",
76
+ ]
@@ -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
27
+ from typing import Any, NamedTuple, Optional, Union
28
28
 
29
29
  try:
30
30
  from typing import Self
@@ -93,11 +93,10 @@ class S3Fixture:
93
93
  self.config = config
94
94
  self.storage = storage
95
95
 
96
- def get_buckets(self) -> set[str]:
96
+ def get_buckets(self) -> list[str]:
97
97
  """Return a list of the buckets currently existing in the S3 object storage."""
98
98
  response = self.storage._client.list_buckets()
99
- buckets = {bucket["Name"] for bucket in response["Buckets"]}
100
- return buckets
99
+ return [bucket["Name"] for bucket in response["Buckets"]]
101
100
 
102
101
  async def populate_buckets(self, buckets: list[str]):
103
102
  """Populate the storage with buckets."""
@@ -111,19 +110,66 @@ class S3Fixture:
111
110
  self.storage, bucket_fixtures=[], object_fixtures=file_objects
112
111
  )
113
112
 
114
- async def empty_buckets(self, buckets_to_exclude: Optional[list[str]] = None):
115
- """Clean the test artifacts or files from the populated buckets."""
116
- for bucket in self.get_buckets().difference(buckets_to_exclude or []):
113
+ async def empty_buckets(
114
+ self,
115
+ *,
116
+ buckets: Optional[Union[str, list[str]]] = None,
117
+ exclude_buckets: Optional[Union[str, list[str]]] = None,
118
+ ):
119
+ """Remove all test objects from the given bucket(s).
120
+
121
+ If no buckets are specified, all existing buckets will be emptied,
122
+ therefore we recommend to always specify a bucket list.
123
+ You can also specify bucket(s) that should be excluded
124
+ from the operation, i.e. buckets that should not be emptied.
125
+ """
126
+ if buckets is None:
127
+ buckets = self.get_buckets()
128
+ elif isinstance(buckets, str):
129
+ buckets = [buckets]
130
+ if exclude_buckets is None:
131
+ exclude_buckets = []
132
+ elif isinstance(exclude_buckets, str):
133
+ exclude_buckets = [exclude_buckets]
134
+ excluded_buckets = set(exclude_buckets)
135
+ for bucket in buckets:
136
+ if bucket in excluded_buckets:
137
+ continue
117
138
  # Get list of all objects in the bucket
118
139
  object_ids = await self.storage.list_all_object_ids(bucket_id=bucket)
119
140
  # Delete all of these objects
120
141
  for object_id in object_ids:
142
+ # Note that id validation errors can be raised here if the bucket
143
+ # was populated by other means than the S3 storage fixture.
144
+ # We intentionally do not catch these errors because they
145
+ # usually mean that you're operating on the wrong buckets.
121
146
  await self.storage.delete_object(bucket_id=bucket, object_id=object_id)
122
147
 
123
- async def delete_buckets(self, buckets_to_exclude: Optional[list[str]] = None):
124
- """Delete the populated buckets."""
125
- for bucket in self.get_buckets().difference(buckets_to_exclude or []):
126
- await self.storage.delete_bucket(bucket, delete_content=True)
148
+ async def delete_buckets(
149
+ self,
150
+ *,
151
+ buckets: Optional[Union[str, list[str]]] = None,
152
+ exclude_buckets: Optional[Union[str, list[str]]] = None,
153
+ ):
154
+ """Delete the given bucket(s).
155
+
156
+ If no buckets are specified, all existing buckets will be deleted,
157
+ therefore we recommend to always specify a bucket list.
158
+ You can also specify bucket(s) that should be excluded
159
+ from the operation, i.e. buckets that should be kept.
160
+ """
161
+ if buckets is None:
162
+ buckets = self.get_buckets()
163
+ elif isinstance(buckets, str):
164
+ buckets = [buckets]
165
+ if exclude_buckets is None:
166
+ exclude_buckets = []
167
+ elif isinstance(exclude_buckets, str):
168
+ exclude_buckets = [exclude_buckets]
169
+ excluded_buckets = set(exclude_buckets)
170
+ for bucket in buckets:
171
+ if bucket not in excluded_buckets:
172
+ await self.storage.delete_bucket(bucket, delete_content=True)
127
173
 
128
174
  async def get_initialized_upload(self) -> UploadDetails:
129
175
  """Initialize a new empty multipart upload process.
@@ -26,6 +26,8 @@ from typing import NamedTuple, Optional
26
26
  from hexkit.custom_types import JsonObject
27
27
  from hexkit.protocols.eventpub import EventPublisherProtocol
28
28
 
29
+ __all__ = ["InMemEventStore", "InMemEventPublisher", "TopicExhaustedError"]
30
+
29
31
 
30
32
  class TopicExhaustedError(RuntimeError):
31
33
  """Thrown when now more event are queued in a topic."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: hexkit
3
- Version: 3.3.0
3
+ Version: 3.5.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