hexkit 3.2.2__tar.gz → 3.4.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 (57) hide show
  1. {hexkit-3.2.2/src/hexkit.egg-info → hexkit-3.4.0}/PKG-INFO +8 -8
  2. {hexkit-3.2.2 → hexkit-3.4.0}/pyproject.toml +8 -8
  3. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/log.py +30 -5
  4. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/objstorage.py +21 -18
  5. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/eventsub.py +1 -1
  6. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/testutils.py +83 -91
  7. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongodb/provider.py +1 -1
  8. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongodb/testutils.py +16 -10
  9. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongokafka/provider.py +1 -1
  10. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/provider.py +0 -12
  11. hexkit-3.4.0/src/hexkit/providers/s3/testutils/__init__.py +47 -0
  12. hexkit-3.4.0/src/hexkit/providers/s3/testutils/_fixtures.py +346 -0
  13. hexkit-3.4.0/src/hexkit/providers/s3/testutils/_typical_workflow.py +119 -0
  14. hexkit-3.4.0/src/hexkit/providers/s3/testutils/_utils.py +221 -0
  15. {hexkit-3.2.2 → hexkit-3.4.0/src/hexkit.egg-info}/PKG-INFO +8 -8
  16. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit.egg-info/SOURCES.txt +4 -1
  17. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit.egg-info/requires.txt +7 -7
  18. hexkit-3.2.2/src/hexkit/providers/s3/testutils.py +0 -557
  19. {hexkit-3.2.2 → hexkit-3.4.0}/LICENSE +0 -0
  20. {hexkit-3.2.2 → hexkit-3.4.0}/MANIFEST.in +0 -0
  21. {hexkit-3.2.2 → hexkit-3.4.0}/README.md +0 -0
  22. {hexkit-3.2.2 → hexkit-3.4.0}/setup.cfg +0 -0
  23. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/__init__.py +0 -0
  24. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/__main__.py +0 -0
  25. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/base.py +0 -0
  26. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/config.py +0 -0
  27. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/correlation.py +0 -0
  28. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/custom_types.py +0 -0
  29. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/__init__.py +0 -0
  30. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/dao.py +0 -0
  31. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/daopub.py +0 -0
  32. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/daosub.py +0 -0
  33. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/eventpub.py +0 -0
  34. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/eventsub.py +0 -0
  35. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/__init__.py +0 -0
  36. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  37. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/config.py +0 -0
  38. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
  39. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
  40. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
  41. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
  42. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
  43. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  44. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
  45. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
  46. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/__init__.py +0 -0
  47. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  48. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  49. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  50. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  51. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  52. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/testing/__init__.py +0 -0
  53. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/testing/eventpub.py +0 -0
  54. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/py.typed +0 -0
  55. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/utils.py +0 -0
  56. {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  57. {hexkit-3.2.2 → hexkit-3.4.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.2.2
3
+ Version: 3.4.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
@@ -22,22 +22,22 @@ Requires-Dist: pydantic<3,>=2
22
22
  Requires-Dist: pydantic_settings<3,>=2
23
23
  Requires-Dist: PyYAML<7,>=6.0
24
24
  Provides-Extra: akafka
25
- Requires-Dist: aiokafka~=0.10.0; extra == "akafka"
25
+ Requires-Dist: aiokafka~=0.11.0; extra == "akafka"
26
26
  Requires-Dist: jsonschema<5,>=4.22; extra == "akafka"
27
27
  Provides-Extra: s3
28
- Requires-Dist: boto3<2,>=1.34.104; extra == "s3"
29
- Requires-Dist: botocore<2,>=1.34.104; extra == "s3"
28
+ Requires-Dist: boto3<2,>=1.34.140; extra == "s3"
29
+ Requires-Dist: botocore<2,>=1.34.140; extra == "s3"
30
30
  Provides-Extra: mongodb
31
- Requires-Dist: motor<4,>=3.4.0; extra == "mongodb"
31
+ Requires-Dist: motor<4,>=3.5.0; extra == "mongodb"
32
32
  Provides-Extra: test-akafka
33
33
  Requires-Dist: hexkit[akafka]; extra == "test-akafka"
34
- Requires-Dist: testcontainers[kafka]<5,>=4.4.1; extra == "test-akafka"
34
+ Requires-Dist: testcontainers[kafka]<5,>=4.7.1; extra == "test-akafka"
35
35
  Provides-Extra: test-s3
36
36
  Requires-Dist: hexkit[s3]; extra == "test-s3"
37
- Requires-Dist: testcontainers<5,>=4.4.1; extra == "test-s3"
37
+ Requires-Dist: testcontainers<5,>=4.7.1; extra == "test-s3"
38
38
  Provides-Extra: test-mongodb
39
39
  Requires-Dist: hexkit[mongodb]; extra == "test-mongodb"
40
- Requires-Dist: testcontainers[mongo]<5,>=4.4.1; extra == "test-mongodb"
40
+ Requires-Dist: testcontainers[mongo]<5,>=4.7.1; extra == "test-mongodb"
41
41
  Provides-Extra: test
42
42
  Requires-Dist: hexkit[test-akafka,test-mongodb,test-s3]; extra == "test"
43
43
  Provides-Extra: all
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Intended Audience :: Developers",
24
24
  ]
25
25
  name = "hexkit"
26
- version = "3.2.2"
26
+ version = "3.4.0"
27
27
  description = "A Toolkit for Building Microservices using the Hexagonal Architecture"
28
28
  dependencies = [
29
29
  "pydantic >=2, <3",
@@ -36,27 +36,27 @@ text = "Apache 2.0"
36
36
 
37
37
  [project.optional-dependencies]
38
38
  akafka = [
39
- "aiokafka~=0.10.0",
39
+ "aiokafka~=0.11.0",
40
40
  "jsonschema >=4.22, <5",
41
41
  ]
42
42
  s3 = [
43
- "boto3 >=1.34.104, <2",
44
- "botocore >=1.34.104, <2",
43
+ "boto3 >=1.34.140, <2",
44
+ "botocore >=1.34.140, <2",
45
45
  ]
46
46
  mongodb = [
47
- "motor >=3.4.0, <4",
47
+ "motor >=3.5.0, <4",
48
48
  ]
49
49
  test-akafka = [
50
50
  "hexkit[akafka]",
51
- "testcontainers[kafka] >=4.4.1, <5",
51
+ "testcontainers[kafka] >=4.7.1, <5",
52
52
  ]
53
53
  test-s3 = [
54
54
  "hexkit[s3]",
55
- "testcontainers >=4.4.1, <5",
55
+ "testcontainers >=4.7.1, <5",
56
56
  ]
57
57
  test-mongodb = [
58
58
  "hexkit[mongodb]",
59
- "testcontainers[mongo] >=4.4.1, <5",
59
+ "testcontainers[mongo] >=4.7.1, <5",
60
60
  ]
61
61
  test = [
62
62
  "hexkit[test-akafka,test-s3,test-mongodb]",
@@ -17,10 +17,9 @@
17
17
  """Configurable logging tools with JSON output."""
18
18
 
19
19
  import json
20
- from collections import OrderedDict
21
20
  from datetime import datetime, timezone
22
21
  from logging import Formatter, Logger, LogRecord, StreamHandler, addLevelName, getLogger
23
- from typing import Literal, Optional
22
+ from typing import Any, Literal, Optional
24
23
 
25
24
  from pydantic import Field
26
25
  from pydantic_settings import BaseSettings
@@ -38,7 +37,7 @@ __all__ = [
38
37
 
39
38
 
40
39
  DUMMY_RECORD = LogRecord("dummy", 0, "dummy", 0, None, None, None, None, None)
41
- RESERVED_RECORD_KEYS = DUMMY_RECORD.__dict__.keys()
40
+ RESERVED_RECORD_KEYS = set(DUMMY_RECORD.__dict__)
42
41
 
43
42
  # Add TRACE log level
44
43
  addLevelName(5, "TRACE")
@@ -77,11 +76,20 @@ class LoggingConfig(BaseSettings):
77
76
  + " correlation_id, and details"
78
77
  ),
79
78
  )
79
+ log_traceback: bool = Field(
80
+ default=True,
81
+ description="Whether to include exception tracebacks in log messages.",
82
+ )
80
83
 
81
84
 
82
85
  class JsonFormatter(Formatter):
83
86
  """A formatter class that outputs logs in JSON format."""
84
87
 
88
+ def __init__(self, include_traceback=True, *args, **kwargs):
89
+ """Initialize the formatter."""
90
+ super().__init__(*args, **kwargs)
91
+ self._include_traceback = include_traceback
92
+
85
93
  def format(self, record: LogRecord) -> str:
86
94
  """Format the specified record as a JSON string.
87
95
 
@@ -94,10 +102,11 @@ class JsonFormatter(Formatter):
94
102
  - correlation_id: The correlation ID, if set, from the current context.
95
103
  - message: The message that was logged, formatted with any arguments.
96
104
  - details: Any additional values included at time of logging.
105
+ - exception: If an exception was logged, the exception info.
97
106
  """
98
107
  # Create a log record dictionary
99
108
  log_record = record.__dict__
100
- output: OrderedDict[str, str] = OrderedDict()
109
+ output: dict[str, Any] = dict()
101
110
 
102
111
  output["timestamp"] = log_record["timestamp"]
103
112
  output["service"] = log_record["service"]
@@ -108,6 +117,18 @@ class JsonFormatter(Formatter):
108
117
  output["message"] = record.getMessage() # construct msg str with any args
109
118
  output["details"] = log_record["details"]
110
119
 
120
+ if log_record["exc_info"]:
121
+ exc_type, exc_value = log_record["exc_info"][:2]
122
+ exception = {
123
+ "type": exc_type.__name__,
124
+ "message": str(exc_value),
125
+ }
126
+ if self._include_traceback:
127
+ exc_text = log_record["exc_text"]
128
+ if exc_text:
129
+ exception["traceback"] = exc_text
130
+ output["exception"] = exception
131
+
111
132
  # Convert to JSON string
112
133
  return json.dumps(output)
113
134
 
@@ -149,7 +170,11 @@ def configure_logging(*, config: LoggingConfig, logger: Optional[Logger] = None)
149
170
  logger as well. Will also log the complete configuration of the service with
150
171
  secret values hidden, if it's passed to this function and inherits from LoggingConfig.
151
172
  """
152
- formatter = Formatter(config.log_format) if config.log_format else JsonFormatter()
173
+ formatter = (
174
+ Formatter(config.log_format)
175
+ if config.log_format
176
+ else JsonFormatter(include_traceback=config.log_traceback)
177
+ )
153
178
 
154
179
  handler = RecordCompiler(config=config)
155
180
  handler.setLevel(config.log_level)
@@ -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:
@@ -124,7 +124,7 @@ class KafkaConsumerCompatible(Protocol):
124
124
  ...
125
125
 
126
126
  def __aiter__(self: KCC) -> KCC:
127
- """Returns an async iterator for iterating through events.""" #
127
+ """Returns an async iterator for iterating through events."""
128
128
  ...
129
129
 
130
130
  async def __anext__(self) -> ConsumerEvent:
@@ -24,7 +24,7 @@ from collections.abc import AsyncGenerator, Generator, Sequence
24
24
  from contextlib import asynccontextmanager
25
25
  from dataclasses import dataclass
26
26
  from functools import partial
27
- from typing import Any, Callable, Optional, Union
27
+ from typing import Any, Optional, Union
28
28
 
29
29
  try:
30
30
  from typing import Self
@@ -35,7 +35,7 @@ except ImportError: # Python < 3.11
35
35
  import pytest
36
36
  import pytest_asyncio
37
37
  from aiokafka import AIOKafkaConsumer, TopicPartition
38
- from aiokafka.admin import AIOKafkaAdminClient
38
+ from aiokafka.admin import AIOKafkaAdminClient, RecordsToDelete
39
39
  from testcontainers.kafka import KafkaContainer
40
40
 
41
41
  from hexkit.custom_types import Ascii, JsonObject, PytestScope
@@ -83,16 +83,26 @@ class ExpectedEvent(EventBase):
83
83
 
84
84
  Please note, the key type is optional. If it is set to `None` (the default), the
85
85
  event key will be ignored when compared to the recording.
86
+
87
+ The `headers` value is treated the same as `key` -- it is ignored in comparison if
88
+ it is set to `None`.
86
89
  """
87
90
 
88
91
  key: Optional[Ascii] = None
92
+ headers: Optional[dict[str, str]] = None
89
93
 
90
94
 
91
95
  @dataclass(frozen=True)
92
96
  class RecordedEvent(EventBase):
93
- """Used by the EventRecorder class to describe events recorded in a specific topic."""
97
+ """Used by the EventRecorder class to describe events recorded in a specific topic.
98
+
99
+ The event type information is stored in the header but extracted into its own field
100
+ because of its importance for the comparison. Because of this, it is also removed
101
+ from the headers.
102
+ """
94
103
 
95
104
  key: Ascii
105
+ headers: Optional[dict[str, str]] = None
96
106
 
97
107
 
98
108
  class ValidationError(RuntimeError):
@@ -134,11 +144,11 @@ def check_recorded_events(
134
144
  expected_events=expected_events,
135
145
  )
136
146
 
137
- n_recorded_events = len(recorded_events)
138
- n_expected_events = len(expected_events)
139
- if n_recorded_events != n_expected_events:
147
+ n_recorded = len(recorded_events)
148
+ n_expected = len(expected_events)
149
+ if n_recorded != n_expected:
140
150
  raise get_detailed_error(
141
- details=f"expected {n_expected_events} events but recorded {n_recorded_events}"
151
+ details=f"expected {n_expected} events but recorded {n_recorded}"
142
152
  )
143
153
 
144
154
  def get_field_mismatch_error(field, index):
@@ -156,6 +166,11 @@ def check_recorded_events(
156
166
  raise get_field_mismatch_error(field="type", index=index)
157
167
  if expected_event.key is not None and recorded_event.key != expected_event.key:
158
168
  raise get_field_mismatch_error(field="key", index=index)
169
+ if (
170
+ expected_event.headers is not None
171
+ and recorded_event.headers != expected_event.headers
172
+ ):
173
+ raise get_field_mismatch_error(field="headers", index=index)
159
174
 
160
175
 
161
176
  class EventRecorder:
@@ -186,10 +201,16 @@ class EventRecorder:
186
201
  *,
187
202
  kafka_servers: list[str],
188
203
  topic: Ascii,
204
+ capture_headers: bool = False,
189
205
  ):
190
- """Initialize with connection details."""
206
+ """Initialize with connection details.
207
+
208
+ Set `capture_headers` to `True` if you want to capture the headers of the
209
+ recorded events, otherwise they will be left empty.
210
+ """
191
211
  self._kafka_servers = kafka_servers
192
212
  self._topic = topic
213
+ self._capture_headers = capture_headers
193
214
 
194
215
  self._starting_offsets: Optional[dict[str, int]] = None
195
216
  self._recorded_events: Optional[Sequence[RecordedEvent]] = None
@@ -302,19 +323,24 @@ class EventRecorder:
302
323
 
303
324
  # consume all the available events (but no more, as this would lead to infinite
304
325
  # waiting):
305
- raw_events = [
326
+ raw_events: list[ConsumerEvent] = [
306
327
  await self._consume_event(consumer=consumer) for _ in range(event_count)
307
328
  ]
308
329
 
309
- # discard all events that do not match the key of interest:
310
- return [
311
- RecordedEvent(
330
+ recorded_events: list[RecordedEvent] = []
331
+ for raw_event in raw_events:
332
+ headers = headers_as_dict(raw_event)
333
+ type_ = get_header_value("type", headers=headers)
334
+ del headers["type"]
335
+
336
+ recorded_event = RecordedEvent(
312
337
  payload=raw_event.value,
313
- type_=get_header_value("type", headers=headers_as_dict(raw_event)),
338
+ type_=type_,
314
339
  key=raw_event.key,
340
+ headers=headers if self._capture_headers else None,
315
341
  )
316
- for raw_event in raw_events
317
- ]
342
+ recorded_events.append(recorded_event)
343
+ return recorded_events
318
344
 
319
345
  async def start_recording(self) -> None:
320
346
  """Start looking for the expected events from now on."""
@@ -374,13 +400,11 @@ class KafkaFixture:
374
400
  config: KafkaConfig,
375
401
  kafka_servers: list[str],
376
402
  publisher: KafkaEventPublisher,
377
- cmd_exec_func: Callable[[str, bool], None],
378
403
  ):
379
404
  """Initialize with connection details and a ready-to-use publisher."""
380
405
  self.config = config
381
406
  self.kafka_servers = kafka_servers
382
407
  self.publisher = publisher
383
- self._cmd_exec_func = cmd_exec_func
384
408
 
385
409
  async def publish_event(
386
410
  self, *, payload: JsonObject, type_: Ascii, topic: Ascii, key: Ascii = "test"
@@ -388,106 +412,74 @@ class KafkaFixture:
388
412
  """A convenience method to publish a test event."""
389
413
  await self.publisher.publish(payload=payload, type_=type_, key=key, topic=topic)
390
414
 
391
- def record_events(self, *, in_topic: Ascii) -> EventRecorder:
415
+ def record_events(
416
+ self, *, in_topic: Ascii, capture_headers: bool = False
417
+ ) -> EventRecorder:
392
418
  """Constructs an EventRecorder object that can be used in an async with block to
393
419
  record events in the specified topic upon __aenter__ and stops the recording
394
420
  upon __aexit__.
395
- """
396
- return EventRecorder(kafka_servers=self.kafka_servers, topic=in_topic)
397
-
398
- def _build_record_deletion_config(self, partitions: JsonObject) -> JsonObject:
399
- """Build the config required to run the kafka-delete-records script."""
400
- # The required JSON config has a schema specified as follows:
401
- deletion_config: dict[str, Union[list, int]] = {
402
- "partitions": [], # {topic:str, partition: int, offset: int}
403
- "version": 1,
404
- }
405
421
 
406
- # Add the partition offset info for each topic
407
- for item in partitions:
408
- for partition in item["partitions"]: # type: ignore
409
- deletion_config["partitions"].append( # type: ignore
410
- {
411
- "topic": item["topic"], # type: ignore
412
- "partition": partition["partition"], # type: ignore
413
- "offset": -1, # -1 instructs kafka to delete all records
414
- }
415
- )
416
-
417
- return deletion_config
418
-
419
- def _build_record_deletion_command(self, delete_config: JsonObject) -> str:
420
- """Build the command string used to run kafka-delete-records.
421
-
422
- The configuration is dumped to a file with an echo command, and then the
423
- delete command is called using that file.
422
+ Set `capture_events` to `True` if you want to capture the headers of the
423
+ recorded events, otherwise they will be left empty.
424
424
  """
425
- file_name = "record-deletion.json"
426
- json_data = json.dumps(delete_config)
427
-
428
- # Build the two command strings that write the config file and run the deletion
429
- echo_command = f"echo '{json_data}' > {file_name}"
430
- deletion_command = (
431
- "kafka-delete-records --bootstrap-server localhost:9092 "
432
- + f"--offset-json-file {file_name}"
425
+ return EventRecorder(
426
+ kafka_servers=self.kafka_servers,
427
+ topic=in_topic,
428
+ capture_headers=capture_headers,
433
429
  )
434
- return f"{echo_command} && {deletion_command}"
435
430
 
436
- async def _get_topic_description(
431
+ async def clear_topics(
437
432
  self,
438
- topics: Optional[Union[list[str], str]] = None,
433
+ *,
434
+ topics: Optional[Union[str, list[str]]] = None,
439
435
  exclude_internal: bool = True,
440
- ) -> JsonObject:
441
- """Get a decription of the given topic(s).
436
+ ):
437
+ """Clear messages from given topic(s).
442
438
 
443
- If no topics are specified, all topics will be covered, except internal topics
439
+ If no topics are specified, all topics will be cleared, except internal topics
444
440
  unless otherwise specified.
445
441
  """
446
442
  admin_client = AIOKafkaAdminClient(bootstrap_servers=self.kafka_servers)
447
443
  await admin_client.start()
448
444
  try:
449
445
  if topics is None:
450
- # if topics is None, the admin client gets all topics
451
- if exclude_internal:
452
- topics = [
453
- topic
454
- for topic in await admin_client.list_topics()
455
- if not topic.startswith("__")
456
- ]
446
+ topics = await admin_client.list_topics()
457
447
  elif isinstance(topics, str):
458
448
  topics = [topics]
459
- return await admin_client.describe_topics(topics)
449
+ if exclude_internal:
450
+ topics = [topic for topic in topics if not topic.startswith("__")]
451
+ topics_info = await admin_client.describe_topics(topics)
452
+ records_to_delete = {
453
+ TopicPartition(
454
+ topic=topic_info["topic"], partition=partition_info["partition"]
455
+ ): RecordsToDelete(before_offset=-1)
456
+ for topic_info in topics_info
457
+ for partition_info in topic_info["partitions"]
458
+ }
459
+ await admin_client.delete_records(records_to_delete, timeout_ms=10000)
460
460
  finally:
461
461
  await admin_client.close()
462
462
 
463
- async def clear_topics(
464
- self,
465
- topics: Optional[Union[str, list[str]]] = None,
466
- exclude_internal: bool = True,
467
- ):
468
- """Clear messages from given topic(s).
469
-
470
- If no topics are specified, all topics will be cleared, except internal topics
471
- unless otherwise specified.
472
- """
473
- # Get the description of the topics to be deleted
474
- partition_info = await self._get_topic_description(topics, exclude_internal)
475
-
476
- # Build the command line and then run it in a shell
477
- deletion_config = self._build_record_deletion_config(partition_info)
478
- command = self._build_record_deletion_command(deletion_config)
479
-
480
- self._cmd_exec_func(command, True)
481
-
482
463
  @asynccontextmanager
483
464
  async def expect_events(
484
- self, events: Sequence[ExpectedEvent], *, in_topic: Ascii
465
+ self,
466
+ events: Sequence[ExpectedEvent],
467
+ *,
468
+ in_topic: Ascii,
469
+ capture_headers: bool = False,
485
470
  ) -> AsyncGenerator[EventRecorder, None]:
486
471
  """Can be used in an async with block to record events in the specified topic
487
472
  (on __aenter__) and check that they match the specified sequence of expected
488
473
  events (on __aexit__).
474
+
475
+ Set `capture_headers` to `True` if you want to capture the headers of the
476
+ recorded events, otherwise they will be left empty. Providing the headers in the
477
+ expected events without setting `capture_headers` to will result in a
478
+ ValidationError.
489
479
  """
490
- async with self.record_events(in_topic=in_topic) as event_recorder:
480
+ async with self.record_events(
481
+ in_topic=in_topic, capture_headers=capture_headers
482
+ ) as event_recorder:
491
483
  yield event_recorder
492
484
 
493
485
  check_recorded_events(
@@ -529,7 +521,8 @@ class KafkaContainerFixture(KafkaContainer):
529
521
  exit_code, output = self.get_wrapped_container().exec_run(cmd)
530
522
 
531
523
  if exit_code != 0:
532
- raise RuntimeError(f"result: {exit_code}, output: {output.decode('utf-8')}")
524
+ output_str = output.decode("utf-8")
525
+ raise RuntimeError(f"result: {exit_code}, output: {output_str}")
533
526
 
534
527
 
535
528
  def _kafka_container_fixture() -> Generator[KafkaContainerFixture, None, None]:
@@ -563,7 +556,6 @@ async def _persistent_kafka_fixture(
563
556
  kafka_fixture = KafkaFixture(
564
557
  config=config,
565
558
  kafka_servers=config.kafka_servers,
566
- cmd_exec_func=kafka_container.wrapped_exec_run,
567
559
  publisher=publisher,
568
560
  )
569
561
  yield kafka_fixture
@@ -440,7 +440,7 @@ class MongoDbDaoFactory(DaoFactoryProtocol):
440
440
  self._config = config
441
441
 
442
442
  # get a database-specific client:
443
- self._client = AsyncIOMotorClient(
443
+ self._client: AsyncIOMotorClient = AsyncIOMotorClient(
444
444
  self._config.db_connection_str.get_secret_value()
445
445
  )
446
446
  self._db = self._client[self._config.db_name]
@@ -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}"
@@ -430,7 +430,7 @@ class MongoKafkaDaoPublisherFactory(DaoPublisherFactoryProtocol):
430
430
  self._config = config
431
431
 
432
432
  # get a database-specific client:
433
- self._client = AsyncIOMotorClient(
433
+ self._client: AsyncIOMotorClient = AsyncIOMotorClient(
434
434
  self._config.db_connection_str.get_secret_value()
435
435
  )
436
436
  self._db = self._client[self._config.db_name]
@@ -163,17 +163,9 @@ class S3ObjectStorage(ObjectStorageProtocol):
163
163
  config=self._advanced_config,
164
164
  )
165
165
 
166
- self._created_buckets: set[str] = set()
167
-
168
166
  def __repr__(self) -> str:
169
167
  return f"{self.__class__.__qualname__}(config={repr(self._config)})"
170
168
 
171
- async def delete_created_buckets(self):
172
- """Delete all the buckets that have been created by the provider."""
173
- buckets = self._created_buckets
174
- while buckets:
175
- await self._delete_bucket(bucket_id=buckets.pop(), delete_content=True)
176
-
177
169
  @staticmethod
178
170
  def _format_s3_error_code(error_code: str):
179
171
  """Format a message to describe an S3 error code."""
@@ -269,8 +261,6 @@ class S3ObjectStorage(ObjectStorageProtocol):
269
261
  raise self._translate_s3_client_errors(
270
262
  error, bucket_id=bucket_id
271
263
  ) from error
272
- else:
273
- self._created_buckets.add(bucket_id)
274
264
 
275
265
  async def _delete_bucket(
276
266
  self, bucket_id: str, *, delete_content: bool = False
@@ -293,8 +283,6 @@ class S3ObjectStorage(ObjectStorageProtocol):
293
283
  raise self._translate_s3_client_errors(
294
284
  error, bucket_id=bucket_id
295
285
  ) from error
296
- else:
297
- self._created_buckets.discard(bucket_id)
298
286
 
299
287
  async def _list_all_object_ids(self, *, bucket_id: str) -> list[str]:
300
288
  """Retrieve a list of IDs for all objects currently present in the specified bucket"""