hexkit 3.2.2__tar.gz → 3.3.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.3.0}/PKG-INFO +8 -8
  2. {hexkit-3.2.2 → hexkit-3.3.0}/pyproject.toml +8 -8
  3. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/log.py +30 -5
  4. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/eventsub.py +1 -1
  5. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/akafka/testutils.py +82 -91
  6. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/mongodb/provider.py +1 -1
  7. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/mongokafka/provider.py +1 -1
  8. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/s3/provider.py +0 -12
  9. hexkit-3.3.0/src/hexkit/providers/s3/testutils/__init__.py +47 -0
  10. hexkit-3.3.0/src/hexkit/providers/s3/testutils/_fixtures.py +300 -0
  11. hexkit-3.3.0/src/hexkit/providers/s3/testutils/_typical_workflow.py +119 -0
  12. hexkit-3.3.0/src/hexkit/providers/s3/testutils/_utils.py +221 -0
  13. {hexkit-3.2.2 → hexkit-3.3.0/src/hexkit.egg-info}/PKG-INFO +8 -8
  14. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit.egg-info/SOURCES.txt +4 -1
  15. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit.egg-info/requires.txt +7 -7
  16. hexkit-3.2.2/src/hexkit/providers/s3/testutils.py +0 -557
  17. {hexkit-3.2.2 → hexkit-3.3.0}/LICENSE +0 -0
  18. {hexkit-3.2.2 → hexkit-3.3.0}/MANIFEST.in +0 -0
  19. {hexkit-3.2.2 → hexkit-3.3.0}/README.md +0 -0
  20. {hexkit-3.2.2 → hexkit-3.3.0}/setup.cfg +0 -0
  21. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/__init__.py +0 -0
  22. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/__main__.py +0 -0
  23. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/base.py +0 -0
  24. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/config.py +0 -0
  25. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/correlation.py +0 -0
  26. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/custom_types.py +0 -0
  27. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/protocols/__init__.py +0 -0
  28. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/protocols/dao.py +0 -0
  29. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/protocols/daopub.py +0 -0
  30. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/protocols/daosub.py +0 -0
  31. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/protocols/eventpub.py +0 -0
  32. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/protocols/eventsub.py +0 -0
  33. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/protocols/objstorage.py +0 -0
  34. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/__init__.py +0 -0
  35. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  36. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/akafka/config.py +0 -0
  37. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
  38. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
  39. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
  40. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
  41. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
  42. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  43. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
  44. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
  45. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
  46. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/s3/__init__.py +0 -0
  47. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  48. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  49. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  50. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  51. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  52. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/testing/__init__.py +0 -0
  53. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/providers/testing/eventpub.py +0 -0
  54. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/py.typed +0 -0
  55. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit/utils.py +0 -0
  56. {hexkit-3.2.2 → hexkit-3.3.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  57. {hexkit-3.2.2 → hexkit-3.3.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.3.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.3.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)
@@ -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,73 @@ 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
+ topics: Optional[Union[str, list[str]]] = None,
439
434
  exclude_internal: bool = True,
440
- ) -> JsonObject:
441
- """Get a decription of the given topic(s).
435
+ ):
436
+ """Clear messages from given topic(s).
442
437
 
443
- If no topics are specified, all topics will be covered, except internal topics
438
+ If no topics are specified, all topics will be cleared, except internal topics
444
439
  unless otherwise specified.
445
440
  """
446
441
  admin_client = AIOKafkaAdminClient(bootstrap_servers=self.kafka_servers)
447
442
  await admin_client.start()
448
443
  try:
449
444
  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
- ]
445
+ topics = await admin_client.list_topics()
457
446
  elif isinstance(topics, str):
458
447
  topics = [topics]
459
- return await admin_client.describe_topics(topics)
448
+ if exclude_internal:
449
+ topics = [topic for topic in topics if not topic.startswith("__")]
450
+ topics_info = await admin_client.describe_topics(topics)
451
+ records_to_delete = {
452
+ TopicPartition(
453
+ topic=topic_info["topic"], partition=partition_info["partition"]
454
+ ): RecordsToDelete(before_offset=-1)
455
+ for topic_info in topics_info
456
+ for partition_info in topic_info["partitions"]
457
+ }
458
+ await admin_client.delete_records(records_to_delete, timeout_ms=10000)
460
459
  finally:
461
460
  await admin_client.close()
462
461
 
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
462
  @asynccontextmanager
483
463
  async def expect_events(
484
- self, events: Sequence[ExpectedEvent], *, in_topic: Ascii
464
+ self,
465
+ events: Sequence[ExpectedEvent],
466
+ *,
467
+ in_topic: Ascii,
468
+ capture_headers: bool = False,
485
469
  ) -> AsyncGenerator[EventRecorder, None]:
486
470
  """Can be used in an async with block to record events in the specified topic
487
471
  (on __aenter__) and check that they match the specified sequence of expected
488
472
  events (on __aexit__).
473
+
474
+ Set `capture_headers` to `True` if you want to capture the headers of the
475
+ recorded events, otherwise they will be left empty. Providing the headers in the
476
+ expected events without setting `capture_headers` to will result in a
477
+ ValidationError.
489
478
  """
490
- async with self.record_events(in_topic=in_topic) as event_recorder:
479
+ async with self.record_events(
480
+ in_topic=in_topic, capture_headers=capture_headers
481
+ ) as event_recorder:
491
482
  yield event_recorder
492
483
 
493
484
  check_recorded_events(
@@ -529,7 +520,8 @@ class KafkaContainerFixture(KafkaContainer):
529
520
  exit_code, output = self.get_wrapped_container().exec_run(cmd)
530
521
 
531
522
  if exit_code != 0:
532
- raise RuntimeError(f"result: {exit_code}, output: {output.decode('utf-8')}")
523
+ output_str = output.decode("utf-8")
524
+ raise RuntimeError(f"result: {exit_code}, output: {output_str}")
533
525
 
534
526
 
535
527
  def _kafka_container_fixture() -> Generator[KafkaContainerFixture, None, None]:
@@ -563,7 +555,6 @@ async def _persistent_kafka_fixture(
563
555
  kafka_fixture = KafkaFixture(
564
556
  config=config,
565
557
  kafka_servers=config.kafka_servers,
566
- cmd_exec_func=kafka_container.wrapped_exec_run,
567
558
  publisher=publisher,
568
559
  )
569
560
  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]
@@ -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"""
@@ -0,0 +1,47 @@
1
+ # Copyright 2021 - 2024 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2
+ # for the German Human Genome-Phenome Archive (GHGA)
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """S3-related test fixtures and support functions."""
17
+
18
+ from ._fixtures import ( # noqa: F401
19
+ LOCALSTACK_IMAGE,
20
+ MEBIBYTE,
21
+ TEST_FILE_DIR,
22
+ TEST_FILE_PATHS,
23
+ FileObject,
24
+ S3ContainerFixture,
25
+ S3Fixture,
26
+ calc_md5,
27
+ clean_s3_fixture,
28
+ get_clean_s3_fixture,
29
+ get_persistent_s3_fixture,
30
+ get_s3_container_fixture,
31
+ persistent_s3_fixture,
32
+ populate_storage,
33
+ s3_container_fixture,
34
+ s3_fixture,
35
+ temp_file_object,
36
+ tmp_file,
37
+ upload_file,
38
+ )
39
+ from ._typical_workflow import typical_workflow # noqa: F401
40
+ from ._utils import ( # noqa: F401
41
+ check_part_size,
42
+ download_and_check_test_file,
43
+ multipart_upload_file,
44
+ upload_part,
45
+ upload_part_of_size,
46
+ upload_part_via_url,
47
+ )