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.
- {hexkit-3.2.2/src/hexkit.egg-info → hexkit-3.4.0}/PKG-INFO +8 -8
- {hexkit-3.2.2 → hexkit-3.4.0}/pyproject.toml +8 -8
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/log.py +30 -5
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/objstorage.py +21 -18
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/eventsub.py +1 -1
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/testutils.py +83 -91
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongodb/provider.py +1 -1
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongodb/testutils.py +16 -10
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongokafka/provider.py +1 -1
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/provider.py +0 -12
- hexkit-3.4.0/src/hexkit/providers/s3/testutils/__init__.py +47 -0
- hexkit-3.4.0/src/hexkit/providers/s3/testutils/_fixtures.py +346 -0
- hexkit-3.4.0/src/hexkit/providers/s3/testutils/_typical_workflow.py +119 -0
- hexkit-3.4.0/src/hexkit/providers/s3/testutils/_utils.py +221 -0
- {hexkit-3.2.2 → hexkit-3.4.0/src/hexkit.egg-info}/PKG-INFO +8 -8
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit.egg-info/SOURCES.txt +4 -1
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit.egg-info/requires.txt +7 -7
- hexkit-3.2.2/src/hexkit/providers/s3/testutils.py +0 -557
- {hexkit-3.2.2 → hexkit-3.4.0}/LICENSE +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/MANIFEST.in +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/README.md +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/setup.cfg +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/__main__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/base.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/config.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/correlation.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/custom_types.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/dao.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/daopub.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/daosub.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/eventpub.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/protocols/eventsub.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/config.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/testing/__init__.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/providers/testing/eventpub.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/py.typed +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit/utils.py +0 -0
- {hexkit-3.2.2 → hexkit-3.4.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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.
|
|
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.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.
|
|
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)
|
|
@@ -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
|
|
472
|
+
if not cls._re_bucket_id.match(bucket_id):
|
|
470
473
|
raise cls.BucketIdValidationError(
|
|
471
474
|
bucket_id=bucket_id,
|
|
472
|
-
reason=
|
|
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
|
|
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
|
|
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=
|
|
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
|
|
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,
|
|
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,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(
|
|
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
|
-
|
|
433
|
+
*,
|
|
434
|
+
topics: Optional[Union[str, list[str]]] = None,
|
|
439
435
|
exclude_internal: bool = True,
|
|
440
|
-
)
|
|
441
|
-
"""
|
|
436
|
+
):
|
|
437
|
+
"""Clear messages from given topic(s).
|
|
442
438
|
|
|
443
|
-
If no topics are specified, all topics will be
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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"""
|