hexkit 3.2.1__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.
- {hexkit-3.2.1/src/hexkit.egg-info → hexkit-3.3.0}/PKG-INFO +8 -8
- {hexkit-3.2.1 → hexkit-3.3.0}/pyproject.toml +8 -8
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/log.py +30 -5
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/eventsub.py +1 -1
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/akafka/testutils.py +82 -91
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/mongodb/provider.py +1 -1
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/mongokafka/provider.py +2 -2
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/s3/provider.py +0 -12
- hexkit-3.3.0/src/hexkit/providers/s3/testutils/__init__.py +47 -0
- hexkit-3.3.0/src/hexkit/providers/s3/testutils/_fixtures.py +300 -0
- hexkit-3.3.0/src/hexkit/providers/s3/testutils/_typical_workflow.py +119 -0
- hexkit-3.3.0/src/hexkit/providers/s3/testutils/_utils.py +221 -0
- {hexkit-3.2.1 → hexkit-3.3.0/src/hexkit.egg-info}/PKG-INFO +8 -8
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit.egg-info/SOURCES.txt +4 -1
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit.egg-info/requires.txt +7 -7
- hexkit-3.2.1/src/hexkit/providers/s3/testutils.py +0 -557
- {hexkit-3.2.1 → hexkit-3.3.0}/LICENSE +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/MANIFEST.in +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/README.md +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/setup.cfg +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/__main__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/base.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/config.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/correlation.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/custom_types.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/protocols/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/protocols/dao.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/protocols/daopub.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/protocols/daosub.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/protocols/eventpub.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/protocols/eventsub.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/protocols/objstorage.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/akafka/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/akafka/config.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/s3/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/testing/__init__.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/providers/testing/eventpub.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/py.typed +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit/utils.py +0 -0
- {hexkit-3.2.1 → hexkit-3.3.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
- {hexkit-3.2.1 → 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.
|
|
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.
|
|
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.
|
|
29
|
-
Requires-Dist: botocore<2,>=1.34.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
39
|
+
"aiokafka~=0.11.0",
|
|
40
40
|
"jsonschema >=4.22, <5",
|
|
41
41
|
]
|
|
42
42
|
s3 = [
|
|
43
|
-
"boto3 >=1.34.
|
|
44
|
-
"botocore >=1.34.
|
|
43
|
+
"boto3 >=1.34.140, <2",
|
|
44
|
+
"botocore >=1.34.140, <2",
|
|
45
45
|
]
|
|
46
46
|
mongodb = [
|
|
47
|
-
"motor >=3.
|
|
47
|
+
"motor >=3.5.0, <4",
|
|
48
48
|
]
|
|
49
49
|
test-akafka = [
|
|
50
50
|
"hexkit[akafka]",
|
|
51
|
-
"testcontainers[kafka] >=4.
|
|
51
|
+
"testcontainers[kafka] >=4.7.1, <5",
|
|
52
52
|
]
|
|
53
53
|
test-s3 = [
|
|
54
54
|
"hexkit[s3]",
|
|
55
|
-
"testcontainers >=4.
|
|
55
|
+
"testcontainers >=4.7.1, <5",
|
|
56
56
|
]
|
|
57
57
|
test-mongodb = [
|
|
58
58
|
"hexkit[mongodb]",
|
|
59
|
-
"testcontainers[mongo] >=4.
|
|
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__
|
|
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:
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
if
|
|
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 {
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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_=
|
|
338
|
+
type_=type_,
|
|
314
339
|
key=raw_event.key,
|
|
340
|
+
headers=headers if self._capture_headers else None,
|
|
315
341
|
)
|
|
316
|
-
|
|
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(
|
|
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
|
-
|
|
407
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
431
|
+
async def clear_topics(
|
|
437
432
|
self,
|
|
438
|
-
topics: Optional[Union[
|
|
433
|
+
topics: Optional[Union[str, list[str]]] = None,
|
|
439
434
|
exclude_internal: bool = True,
|
|
440
|
-
)
|
|
441
|
-
"""
|
|
435
|
+
):
|
|
436
|
+
"""Clear messages from given topic(s).
|
|
442
437
|
|
|
443
|
-
If no topics are specified, all topics will be
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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]
|
|
@@ -251,7 +251,7 @@ class MongoKafkaDaoPublisher(Generic[Dto]):
|
|
|
251
251
|
"""
|
|
252
252
|
correlation_id = get_correlation_id()
|
|
253
253
|
document = self._dao._dto_to_document(dto)
|
|
254
|
-
document["
|
|
254
|
+
document.setdefault("__metadata__", {})["correlation_id"] = correlation_id
|
|
255
255
|
result = await self._collection.replace_one(
|
|
256
256
|
{"_id": document["_id"], "__metadata__.deleted": False}, document
|
|
257
257
|
)
|
|
@@ -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
|
+
)
|