hexkit 3.0.2__tar.gz → 3.2.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.0.2/src/hexkit.egg-info → hexkit-3.2.0}/PKG-INFO +8 -8
- {hexkit-3.0.2 → hexkit-3.2.0}/pyproject.toml +8 -8
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/protocols/daopub.py +3 -2
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/protocols/objstorage.py +4 -4
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/akafka/testcontainer.py +1 -1
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/akafka/testutils.py +128 -35
- hexkit-3.2.0/src/hexkit/providers/mongodb/testutils.py +176 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/mongokafka/provider.py +43 -18
- hexkit-3.2.0/src/hexkit/providers/mongokafka/testutils.py +73 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/s3/testutils.py +111 -15
- {hexkit-3.0.2 → hexkit-3.2.0/src/hexkit.egg-info}/PKG-INFO +8 -8
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit.egg-info/SOURCES.txt +1 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit.egg-info/requires.txt +7 -7
- hexkit-3.0.2/src/hexkit/providers/mongodb/testutils.py +0 -101
- {hexkit-3.0.2 → hexkit-3.2.0}/LICENSE +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/MANIFEST.in +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/README.md +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/setup.cfg +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/__main__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/base.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/config.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/correlation.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/custom_types.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/log.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/protocols/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/protocols/dao.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/protocols/daosub.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/protocols/eventpub.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/protocols/eventsub.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/akafka/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/akafka/config.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/mongodb/provider.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/s3/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/s3/provider.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/testing/__init__.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/providers/testing/eventpub.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/py.typed +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit/utils.py +0 -0
- {hexkit-3.0.2 → hexkit-3.2.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
- {hexkit-3.0.2 → hexkit-3.2.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.0
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: A Toolkit for Building Microservices using the Hexagonal Architecture
|
|
5
5
|
Author-email: "German Human Genome Phenome Archive (GHGA)" <contact@ghga.de>
|
|
6
6
|
License: Apache 2.0
|
|
@@ -23,21 +23,21 @@ Requires-Dist: pydantic_settings<3,>=2
|
|
|
23
23
|
Requires-Dist: PyYAML<7,>=6.0
|
|
24
24
|
Provides-Extra: akafka
|
|
25
25
|
Requires-Dist: aiokafka~=0.10.0; extra == "akafka"
|
|
26
|
-
Requires-Dist: jsonschema<5,>=4.
|
|
26
|
+
Requires-Dist: jsonschema<5,>=4.22; extra == "akafka"
|
|
27
27
|
Provides-Extra: s3
|
|
28
|
-
Requires-Dist: boto3<2,>=1.
|
|
29
|
-
Requires-Dist: botocore<2,>=1.
|
|
28
|
+
Requires-Dist: boto3<2,>=1.34.104; extra == "s3"
|
|
29
|
+
Requires-Dist: botocore<2,>=1.34.104; extra == "s3"
|
|
30
30
|
Provides-Extra: mongodb
|
|
31
|
-
Requires-Dist: motor<4,>=3.
|
|
31
|
+
Requires-Dist: motor<4,>=3.4.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.4.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.4.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.4.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.0
|
|
26
|
+
version = "3.2.0"
|
|
27
27
|
description = "A Toolkit for Building Microservices using the Hexagonal Architecture"
|
|
28
28
|
dependencies = [
|
|
29
29
|
"pydantic >=2, <3",
|
|
@@ -37,26 +37,26 @@ text = "Apache 2.0"
|
|
|
37
37
|
[project.optional-dependencies]
|
|
38
38
|
akafka = [
|
|
39
39
|
"aiokafka~=0.10.0",
|
|
40
|
-
"jsonschema >=4.
|
|
40
|
+
"jsonschema >=4.22, <5",
|
|
41
41
|
]
|
|
42
42
|
s3 = [
|
|
43
|
-
"boto3 >=1.
|
|
44
|
-
"botocore >=1.
|
|
43
|
+
"boto3 >=1.34.104, <2",
|
|
44
|
+
"botocore >=1.34.104, <2",
|
|
45
45
|
]
|
|
46
46
|
mongodb = [
|
|
47
|
-
"motor >=3.
|
|
47
|
+
"motor >=3.4.0, <4",
|
|
48
48
|
]
|
|
49
49
|
test-akafka = [
|
|
50
50
|
"hexkit[akafka]",
|
|
51
|
-
"testcontainers[kafka] >=4.
|
|
51
|
+
"testcontainers[kafka] >=4.4.1, <5",
|
|
52
52
|
]
|
|
53
53
|
test-s3 = [
|
|
54
54
|
"hexkit[s3]",
|
|
55
|
-
"testcontainers >=4.
|
|
55
|
+
"testcontainers >=4.4.1, <5",
|
|
56
56
|
]
|
|
57
57
|
test-mongodb = [
|
|
58
58
|
"hexkit[mongodb]",
|
|
59
|
-
"testcontainers[mongo] >=4.
|
|
59
|
+
"testcontainers[mongo] >=4.4.1, <5",
|
|
60
60
|
]
|
|
61
61
|
test = [
|
|
62
62
|
"hexkit[test-akafka,test-s3,test-mongodb]",
|
|
@@ -62,7 +62,7 @@ class DaoPublisherFactoryProtocol(DaoFactoryBase, ABC):
|
|
|
62
62
|
dto_model: type[Dto],
|
|
63
63
|
id_field: str,
|
|
64
64
|
fields_to_index: Optional[Collection[str]] = None,
|
|
65
|
-
dto_to_event: Callable[[Dto], JsonObject],
|
|
65
|
+
dto_to_event: Callable[[Dto], Optional[JsonObject]],
|
|
66
66
|
event_topic: str,
|
|
67
67
|
autopublish: bool = True,
|
|
68
68
|
) -> DaoPublisher[Dto]:
|
|
@@ -82,6 +82,7 @@ class DaoPublisherFactoryProtocol(DaoFactoryBase, ABC):
|
|
|
82
82
|
`id_field`. Defaults to None.
|
|
83
83
|
dto_to_event:
|
|
84
84
|
A function that takes a DTO and returns the payload for an event.
|
|
85
|
+
If the returned payload is None, the event will not be published.
|
|
85
86
|
event_topic:
|
|
86
87
|
The topic to which events should be published.
|
|
87
88
|
autopublish:
|
|
@@ -120,7 +121,7 @@ class DaoPublisherFactoryProtocol(DaoFactoryBase, ABC):
|
|
|
120
121
|
dto_model: type[Dto],
|
|
121
122
|
id_field: str,
|
|
122
123
|
fields_to_index: Optional[Collection[str]],
|
|
123
|
-
dto_to_event: Callable[[Dto], JsonObject],
|
|
124
|
+
dto_to_event: Callable[[Dto], Optional[JsonObject]],
|
|
124
125
|
event_topic: str,
|
|
125
126
|
autopublish: bool,
|
|
126
127
|
) -> DaoPublisher[Dto]:
|
|
@@ -517,16 +517,16 @@ class ObjectStorageProtocol(ABC):
|
|
|
517
517
|
"""Thrown when trying to access a bucket with an ID that doesn't exist."""
|
|
518
518
|
|
|
519
519
|
def __init__(self, bucket_id: Optional[str]):
|
|
520
|
-
|
|
521
|
-
message = f"The bucket{
|
|
520
|
+
with_id = f" with ID '{bucket_id}'" if bucket_id else ""
|
|
521
|
+
message = f"The bucket{with_id} does not exist."
|
|
522
522
|
super().__init__(message)
|
|
523
523
|
|
|
524
524
|
class BucketAlreadyExistsError(BucketError):
|
|
525
525
|
"""Thrown when trying to create a bucket with an ID that already exists."""
|
|
526
526
|
|
|
527
527
|
def __init__(self, bucket_id: Optional[str]):
|
|
528
|
-
|
|
529
|
-
message = f"The bucket{
|
|
528
|
+
with_id = f" with ID '{bucket_id}'" if bucket_id else ""
|
|
529
|
+
message = f"The bucket{with_id} already exists."
|
|
530
530
|
super().__init__(message)
|
|
531
531
|
|
|
532
532
|
class BucketNotEmptyError(BucketError):
|
|
@@ -27,7 +27,7 @@ from testcontainers.core.waiting_utils import wait_for_logs
|
|
|
27
27
|
|
|
28
28
|
__all__ = ["KafkaSSLContainer"]
|
|
29
29
|
|
|
30
|
-
DEFAULT_IMAGE = "confluentinc/cp-kafka:7.6.
|
|
30
|
+
DEFAULT_IMAGE = "confluentinc/cp-kafka:7.6.1"
|
|
31
31
|
|
|
32
32
|
DEFAULT_PORT = 9093 # default port for the Kafka container
|
|
33
33
|
BROKER_PORT = 9092 # auxiliary port for inter broker listener
|
|
@@ -20,12 +20,19 @@ Please note, only use for testing purposes.
|
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
22
|
import json
|
|
23
|
-
from collections.abc import AsyncGenerator, Sequence
|
|
23
|
+
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 Callable, Optional, Union
|
|
27
|
+
from typing import Any, Callable, Optional, Union
|
|
28
28
|
|
|
29
|
+
try:
|
|
30
|
+
from typing import Self
|
|
31
|
+
except ImportError: # Python < 3.11
|
|
32
|
+
from typing_extensions import Self
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
import pytest
|
|
29
36
|
import pytest_asyncio
|
|
30
37
|
from aiokafka import AIOKafkaConsumer, TopicPartition
|
|
31
38
|
from aiokafka.admin import AIOKafkaAdminClient
|
|
@@ -41,6 +48,26 @@ from hexkit.providers.akafka.provider import (
|
|
|
41
48
|
)
|
|
42
49
|
from hexkit.providers.akafka.testcontainer import DEFAULT_IMAGE as KAFKA_IMAGE
|
|
43
50
|
|
|
51
|
+
__all__ = [
|
|
52
|
+
"KAFKA_IMAGE",
|
|
53
|
+
"EventBase",
|
|
54
|
+
"EventRecorder",
|
|
55
|
+
"ExpectedEvent",
|
|
56
|
+
"RecordedEvent",
|
|
57
|
+
"ValidationError",
|
|
58
|
+
"KafkaConfig",
|
|
59
|
+
"KafkaContainerFixture",
|
|
60
|
+
"KafkaEventPublisher",
|
|
61
|
+
"KafkaFixture",
|
|
62
|
+
"get_kafka_container_fixture",
|
|
63
|
+
"get_clean_kafka_fixture",
|
|
64
|
+
"get_persistent_kafka_fixture",
|
|
65
|
+
"kafka_container_fixture",
|
|
66
|
+
"clean_kafka_fixture",
|
|
67
|
+
"persistent_kafka_fixture",
|
|
68
|
+
"kafka_fixture",
|
|
69
|
+
]
|
|
70
|
+
|
|
44
71
|
|
|
45
72
|
@dataclass(frozen=True)
|
|
46
73
|
class EventBase:
|
|
@@ -339,7 +366,7 @@ class EventRecorder:
|
|
|
339
366
|
|
|
340
367
|
|
|
341
368
|
class KafkaFixture:
|
|
342
|
-
"""
|
|
369
|
+
"""A fixture with utility methods for tests that use Apache Kafka."""
|
|
343
370
|
|
|
344
371
|
def __init__(
|
|
345
372
|
self,
|
|
@@ -468,49 +495,115 @@ class KafkaFixture:
|
|
|
468
495
|
)
|
|
469
496
|
|
|
470
497
|
|
|
471
|
-
|
|
472
|
-
"""
|
|
498
|
+
class KafkaContainerFixture(KafkaContainer):
|
|
499
|
+
"""Kafka test container with configuration and command execution."""
|
|
473
500
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
501
|
+
kafka_config: KafkaConfig
|
|
502
|
+
|
|
503
|
+
def __init__(self, port: int = 9093, **kwargs: Any) -> None:
|
|
504
|
+
"""Initialize the container."""
|
|
505
|
+
super().__init__(image=KAFKA_IMAGE, port=port, **kwargs)
|
|
506
|
+
|
|
507
|
+
def __enter__(self) -> Self:
|
|
508
|
+
"""Enter the container context."""
|
|
509
|
+
super().__enter__()
|
|
510
|
+
kafka_servers = [self.get_bootstrap_server()]
|
|
511
|
+
self.kafka_config = KafkaConfig(
|
|
479
512
|
service_name="test_publisher",
|
|
480
513
|
service_instance_id="001",
|
|
481
514
|
kafka_servers=kafka_servers,
|
|
482
515
|
)
|
|
516
|
+
return self
|
|
483
517
|
|
|
484
|
-
|
|
485
|
-
|
|
518
|
+
def wrapped_exec_run(self, command: str, run_in_shell: bool):
|
|
519
|
+
"""Run the given command in the kafka testcontainer.
|
|
486
520
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
521
|
+
Args:
|
|
522
|
+
- `command`: The full command to run.
|
|
523
|
+
- `run_in_shell`: If True, will run the command in a shell.
|
|
490
524
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
525
|
+
Raises:
|
|
526
|
+
- `RuntimeError`: when the exit code returned by the command is not zero.
|
|
527
|
+
"""
|
|
528
|
+
cmd = ["sh", "-c", command] if run_in_shell else command
|
|
529
|
+
exit_code, output = self.get_wrapped_container().exec_run(cmd)
|
|
496
530
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
f"result: {exit_code}, output: {output.decode('utf-8')}"
|
|
500
|
-
)
|
|
531
|
+
if exit_code != 0:
|
|
532
|
+
raise RuntimeError(f"result: {exit_code}, output: {output.decode('utf-8')}")
|
|
501
533
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
534
|
+
|
|
535
|
+
def _kafka_container_fixture() -> Generator[KafkaContainerFixture, None, None]:
|
|
536
|
+
"""Fixture function for getting a running Kafka test container."""
|
|
537
|
+
with KafkaContainerFixture() as kafka_container:
|
|
538
|
+
yield kafka_container
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def get_kafka_container_fixture(
|
|
542
|
+
scope: PytestScope = "session", name: str = "kafka_container"
|
|
543
|
+
):
|
|
544
|
+
"""Get a Kafka test container fixture with desired scope and name.
|
|
545
|
+
|
|
546
|
+
By default, the session scope is used for Kafka test containers.
|
|
547
|
+
"""
|
|
548
|
+
return pytest.fixture(_kafka_container_fixture, scope=scope, name=name)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
kafka_container_fixture = get_kafka_container_fixture()
|
|
509
552
|
|
|
510
553
|
|
|
511
|
-
def
|
|
512
|
-
|
|
513
|
-
|
|
554
|
+
async def _persistent_kafka_fixture(
|
|
555
|
+
kafka_container: KafkaContainerFixture,
|
|
556
|
+
) -> AsyncGenerator[KafkaFixture, None]:
|
|
557
|
+
"""Fixture function that gets a persistent Kafka fixture.
|
|
558
|
+
|
|
559
|
+
The state of Kafka is not cleaned up by this function.
|
|
560
|
+
"""
|
|
561
|
+
config = kafka_container.kafka_config
|
|
562
|
+
async with KafkaEventPublisher.construct(config=config) as publisher:
|
|
563
|
+
kafka_fixture = KafkaFixture(
|
|
564
|
+
config=config,
|
|
565
|
+
kafka_servers=config.kafka_servers,
|
|
566
|
+
cmd_exec_func=kafka_container.wrapped_exec_run,
|
|
567
|
+
publisher=publisher,
|
|
568
|
+
)
|
|
569
|
+
yield kafka_fixture
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def get_persistent_kafka_fixture(scope: PytestScope = "function", name: str = "kafka"):
|
|
573
|
+
"""Get a Kafka fixture with desired scope and name.
|
|
574
|
+
|
|
575
|
+
The state of the Kafka test container is persisted across tests.
|
|
576
|
+
|
|
577
|
+
By default, the function scope is used for this fixture,
|
|
578
|
+
while the session scope is used for the underlying Kafka test container.
|
|
579
|
+
"""
|
|
580
|
+
return pytest_asyncio.fixture(_persistent_kafka_fixture, scope=scope, name=name)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
persistent_kafka_fixture = get_persistent_kafka_fixture()
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
async def _clean_kafka_fixture(
|
|
587
|
+
kafka_container: KafkaContainerFixture,
|
|
588
|
+
) -> AsyncGenerator[KafkaFixture, None]:
|
|
589
|
+
"""Fixture function that gets a clean Kafka fixture.
|
|
590
|
+
|
|
591
|
+
The clean state is achieved by clearing all Kafka topics upfront.
|
|
592
|
+
"""
|
|
593
|
+
async for kafka_fixture in _persistent_kafka_fixture(kafka_container):
|
|
594
|
+
await kafka_fixture.clear_topics()
|
|
595
|
+
yield kafka_fixture
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def get_clean_kafka_fixture(scope: PytestScope = "function", name: str = "kafka"):
|
|
599
|
+
"""Get a Kafka fixture with desired scope and name.
|
|
600
|
+
|
|
601
|
+
The state of Kafka is reset by clearing all topics before running tests.
|
|
602
|
+
|
|
603
|
+
By default, the function scope is used for this fixture,
|
|
604
|
+
while the session scope is used for the underlying Kafka test container.
|
|
605
|
+
"""
|
|
606
|
+
return pytest_asyncio.fixture(_clean_kafka_fixture, scope=scope, name=name)
|
|
514
607
|
|
|
515
608
|
|
|
516
|
-
kafka_fixture =
|
|
609
|
+
kafka_fixture = clean_kafka_fixture = get_clean_kafka_fixture()
|
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
|
|
17
|
+
"""Utilities for testing code that uses the MongoDbDaoFactory provider.
|
|
18
|
+
|
|
19
|
+
Please note, only use for testing purposes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from collections.abc import Generator
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Optional, Union
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
from pydantic import SecretStr
|
|
28
|
+
from pymongo import MongoClient
|
|
29
|
+
from pymongo.errors import ExecutionTimeout, OperationFailure
|
|
30
|
+
from testcontainers.mongodb import MongoDbContainer
|
|
31
|
+
|
|
32
|
+
from hexkit.custom_types import PytestScope
|
|
33
|
+
from hexkit.providers.mongodb.provider import MongoDbConfig, MongoDbDaoFactory
|
|
34
|
+
|
|
35
|
+
MONGODB_IMAGE = "mongo:7.0.9"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"MONGODB_IMAGE",
|
|
39
|
+
"MongoClient",
|
|
40
|
+
"MongoDbConfig",
|
|
41
|
+
"MongoDbContainerFixture",
|
|
42
|
+
"MongoDbDaoFactory",
|
|
43
|
+
"MongoDbFixture",
|
|
44
|
+
"get_mongodb_container_fixture",
|
|
45
|
+
"get_persistent_mongodb_fixture",
|
|
46
|
+
"get_clean_mongodb_fixture",
|
|
47
|
+
"mongodb_container_fixture",
|
|
48
|
+
"clean_mongodb_fixture",
|
|
49
|
+
"persistent_mongodb_fixture",
|
|
50
|
+
"mongodb_fixture",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class MongoDbFixture:
|
|
56
|
+
"""A fixture with utility methods for tests that use MongoDB"""
|
|
57
|
+
|
|
58
|
+
client: MongoClient
|
|
59
|
+
config: MongoDbConfig
|
|
60
|
+
dao_factory: MongoDbDaoFactory
|
|
61
|
+
|
|
62
|
+
def empty_collections(
|
|
63
|
+
self,
|
|
64
|
+
exclude_collections: Optional[Union[str, list[str]]] = None,
|
|
65
|
+
):
|
|
66
|
+
"""Drop all mongodb collections in the database.
|
|
67
|
+
|
|
68
|
+
You can also specify collection(s) that should be excluded
|
|
69
|
+
from the operation, i.e. collections that should be kept.
|
|
70
|
+
"""
|
|
71
|
+
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
|
+
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)
|
|
82
|
+
except (ExecutionTimeout, OperationFailure) as error:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"Could not drop collection(s) of Mongo database {db_name}"
|
|
85
|
+
) from error
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MongoDbContainerFixture(MongoDbContainer):
|
|
89
|
+
"""MongoDB test container with MongoDB configuration."""
|
|
90
|
+
|
|
91
|
+
mongodb_config: MongoDbConfig
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _mongodb_container_fixture() -> Generator[MongoDbContainerFixture, None, None]:
|
|
95
|
+
"""Fixture function for getting a running MongoDB test container."""
|
|
96
|
+
with MongoDbContainerFixture(image=MONGODB_IMAGE) as mongodb_container:
|
|
97
|
+
db_connection_str = mongodb_container.get_connection_url()
|
|
98
|
+
mongodb_config = MongoDbConfig(
|
|
99
|
+
db_connection_str=SecretStr(db_connection_str), db_name="test"
|
|
100
|
+
)
|
|
101
|
+
mongodb_container.mongodb_config = mongodb_config
|
|
102
|
+
yield mongodb_container
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_mongodb_container_fixture(
|
|
106
|
+
scope: PytestScope = "session", name: str = "mongodb_container"
|
|
107
|
+
):
|
|
108
|
+
"""Get a MongoDB test container fixture with desired scope and name.
|
|
109
|
+
|
|
110
|
+
By default, the session scope is used for MongoDB test containers.
|
|
111
|
+
"""
|
|
112
|
+
return pytest.fixture(_mongodb_container_fixture, scope=scope, name=name)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
mongodb_container_fixture = get_mongodb_container_fixture()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _persistent_mongodb_fixture(
|
|
119
|
+
mongodb_container: MongoDbContainerFixture,
|
|
120
|
+
) -> Generator[MongoDbFixture, None, None]:
|
|
121
|
+
"""Fixture function that gets a persistent MongoDB fixture.
|
|
122
|
+
|
|
123
|
+
The state of the MongoDB is not cleaned up by the function.
|
|
124
|
+
"""
|
|
125
|
+
config = mongodb_container.mongodb_config
|
|
126
|
+
dao_factory = MongoDbDaoFactory(config=config)
|
|
127
|
+
client = mongodb_container.get_connection_client()
|
|
128
|
+
yield MongoDbFixture(
|
|
129
|
+
client=client,
|
|
130
|
+
config=config,
|
|
131
|
+
dao_factory=dao_factory,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
client.close()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_persistent_mongodb_fixture(
|
|
138
|
+
scope: PytestScope = "function", name: str = "mongodb"
|
|
139
|
+
):
|
|
140
|
+
"""Get a MongoDB fixture with desired scope and name.
|
|
141
|
+
|
|
142
|
+
The state of the MongoDB test container is persisted across tests.
|
|
143
|
+
|
|
144
|
+
By default, the function scope is used for this fixture,
|
|
145
|
+
while the session scope is used for the underlying MongoDB test container.
|
|
146
|
+
"""
|
|
147
|
+
return pytest.fixture(_persistent_mongodb_fixture, scope=scope, name=name)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
persistent_mongodb_fixture = get_persistent_mongodb_fixture()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _clean_mongodb_fixture(
|
|
154
|
+
mongodb_container: MongoDbContainerFixture,
|
|
155
|
+
) -> Generator[MongoDbFixture, None, None]:
|
|
156
|
+
"""Fixture function that gets a clean MongoDB fixture.
|
|
157
|
+
|
|
158
|
+
The clean state is achieved by emptying all MongoDB collections upfront.
|
|
159
|
+
"""
|
|
160
|
+
for mongodb_fixture in _persistent_mongodb_fixture(mongodb_container):
|
|
161
|
+
mongodb_fixture.empty_collections()
|
|
162
|
+
yield mongodb_fixture
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_clean_mongodb_fixture(scope: PytestScope = "function", name: str = "mongodb"):
|
|
166
|
+
"""Get a MongoDB fixture with desired scope and name.
|
|
167
|
+
|
|
168
|
+
The state of the MongoDB is reset by emptying all collections before running tests.
|
|
169
|
+
|
|
170
|
+
By default, the function scope is used for this fixture,
|
|
171
|
+
while the session scope is used for the underlying MongoDB test container.
|
|
172
|
+
"""
|
|
173
|
+
return pytest.fixture(_clean_mongodb_fixture, scope=scope, name=name)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
mongodb_fixture = clean_mongodb_fixture = get_clean_mongodb_fixture()
|
|
@@ -28,6 +28,7 @@ from aiokafka import AIOKafkaProducer
|
|
|
28
28
|
from motor.core import AgnosticCollection
|
|
29
29
|
from motor.motor_asyncio import AsyncIOMotorClient
|
|
30
30
|
|
|
31
|
+
from hexkit.correlation import get_correlation_id, set_correlation_id
|
|
31
32
|
from hexkit.custom_types import JsonObject
|
|
32
33
|
from hexkit.protocols.dao import (
|
|
33
34
|
DaoNaturalId,
|
|
@@ -86,7 +87,13 @@ def dto_to_document(
|
|
|
86
87
|
"""
|
|
87
88
|
document = json.loads(dto.model_dump_json())
|
|
88
89
|
document["_id"] = document.pop(id_field)
|
|
89
|
-
|
|
90
|
+
|
|
91
|
+
correlation_id = get_correlation_id()
|
|
92
|
+
document["__metadata__"] = {
|
|
93
|
+
"deleted": False,
|
|
94
|
+
"published": published,
|
|
95
|
+
"correlation_id": correlation_id,
|
|
96
|
+
}
|
|
90
97
|
|
|
91
98
|
return document
|
|
92
99
|
|
|
@@ -94,7 +101,7 @@ def dto_to_document(
|
|
|
94
101
|
def get_change_publish_func(
|
|
95
102
|
id_field: str,
|
|
96
103
|
event_topic: str,
|
|
97
|
-
dto_to_event: Callable[[Dto], JsonObject],
|
|
104
|
+
dto_to_event: Callable[[Dto], Optional[JsonObject]],
|
|
98
105
|
event_publisher: EventPublisherProtocol,
|
|
99
106
|
collection: AgnosticCollection,
|
|
100
107
|
) -> Callable[[Dto], Awaitable[None]]:
|
|
@@ -103,12 +110,13 @@ def get_change_publish_func(
|
|
|
103
110
|
async def publish_change(dto: Dto) -> None:
|
|
104
111
|
"""Publishes a change event and marks the change as published."""
|
|
105
112
|
payload = dto_to_event(dto)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
if payload is not None:
|
|
114
|
+
await event_publisher.publish(
|
|
115
|
+
payload=payload,
|
|
116
|
+
type_=CHANGE_EVENT_TYPE,
|
|
117
|
+
key=getattr(dto, id_field),
|
|
118
|
+
topic=event_topic,
|
|
119
|
+
)
|
|
112
120
|
|
|
113
121
|
document = dto_to_document(dto, id_field=id_field, published=True)
|
|
114
122
|
await collection.replace_one({"_id": document["_id"]}, document, upsert=True)
|
|
@@ -134,7 +142,15 @@ def get_delete_publish_func(
|
|
|
134
142
|
topic=event_topic,
|
|
135
143
|
)
|
|
136
144
|
|
|
137
|
-
|
|
145
|
+
correlation_id = get_correlation_id() # Get active correlation first
|
|
146
|
+
document = {
|
|
147
|
+
"_id": id_,
|
|
148
|
+
"__metadata__": {
|
|
149
|
+
"deleted": True,
|
|
150
|
+
"published": True,
|
|
151
|
+
"correlation_id": correlation_id,
|
|
152
|
+
},
|
|
153
|
+
}
|
|
138
154
|
await collection.replace_one({"_id": document["_id"]}, document)
|
|
139
155
|
|
|
140
156
|
return publish_deletion
|
|
@@ -233,7 +249,9 @@ class MongoKafkaDaoPublisher(Generic[Dto]):
|
|
|
233
249
|
ResourceNotFoundError:
|
|
234
250
|
when resource with the id specified in the dto was not found
|
|
235
251
|
"""
|
|
252
|
+
correlation_id = get_correlation_id()
|
|
236
253
|
document = self._dao._dto_to_document(dto)
|
|
254
|
+
document["__metadata__.correlation_id"] = correlation_id
|
|
237
255
|
result = await self._collection.replace_one(
|
|
238
256
|
{"_id": document["_id"], "__metadata__.deleted": False}, document
|
|
239
257
|
)
|
|
@@ -252,9 +270,14 @@ class MongoKafkaDaoPublisher(Generic[Dto]):
|
|
|
252
270
|
Raises:
|
|
253
271
|
ResourceNotFoundError: when resource with the specified id_ was not found
|
|
254
272
|
"""
|
|
273
|
+
correlation_id = get_correlation_id()
|
|
255
274
|
document = {
|
|
256
275
|
"_id": id_,
|
|
257
|
-
"__metadata__": {
|
|
276
|
+
"__metadata__": {
|
|
277
|
+
"deleted": True,
|
|
278
|
+
"published": False,
|
|
279
|
+
"correlation_id": correlation_id,
|
|
280
|
+
},
|
|
258
281
|
}
|
|
259
282
|
result = await self._collection.replace_one(
|
|
260
283
|
{"_id": document["_id"], "__metadata__.deleted": False}, document
|
|
@@ -343,13 +366,15 @@ class MongoKafkaDaoPublisher(Generic[Dto]):
|
|
|
343
366
|
|
|
344
367
|
async def publish_document(self, document: dict[str, Any]) -> None:
|
|
345
368
|
"""Publishes a document"""
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
369
|
+
correlation_id = document.get("__metadata__", {}).get("correlation_id", "")
|
|
370
|
+
async with set_correlation_id(correlation_id=correlation_id):
|
|
371
|
+
if document.get("__metadata__", {}).get("deleted", False):
|
|
372
|
+
await self._publish_delete(document["_id"])
|
|
373
|
+
else:
|
|
374
|
+
dto = document_to_dto(
|
|
375
|
+
document, id_field=self._id_field, dto_model=self._dto_model
|
|
376
|
+
)
|
|
377
|
+
await self._publish_change(dto)
|
|
353
378
|
|
|
354
379
|
async def publish_pending(self) -> None:
|
|
355
380
|
"""Publishes all non-published changes."""
|
|
@@ -422,7 +447,7 @@ class MongoKafkaDaoPublisherFactory(DaoPublisherFactoryProtocol):
|
|
|
422
447
|
dto_model: type[Dto],
|
|
423
448
|
id_field: str,
|
|
424
449
|
fields_to_index: Optional[Collection[str]],
|
|
425
|
-
dto_to_event: Callable[[Dto], JsonObject],
|
|
450
|
+
dto_to_event: Callable[[Dto], Optional[JsonObject]],
|
|
426
451
|
event_topic: str,
|
|
427
452
|
autopublish: bool,
|
|
428
453
|
) -> DaoPublisher[Dto]:
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
17
|
+
"""Utilities for testing code that uses the MongoKafkaDaoPublisherFactory provider.
|
|
18
|
+
|
|
19
|
+
Please note, only use for testing purposes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import NamedTuple
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
from hexkit.providers.akafka.testutils import KafkaContainerFixture, KafkaFixture
|
|
27
|
+
from hexkit.providers.mongodb.testutils import MongoDbContainerFixture, MongoDbFixture
|
|
28
|
+
from hexkit.providers.mongokafka import MongoKafkaConfig
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"MongoKafkaConfig",
|
|
32
|
+
"MongoKafkaFixture",
|
|
33
|
+
"mongo_kafka_config_fixture",
|
|
34
|
+
"mongo_kafka_fixture",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(name="mongo_kafka_config", scope="session")
|
|
39
|
+
def mongo_kafka_config_fixture(
|
|
40
|
+
mongodb_container: MongoDbContainerFixture, kafka_container: KafkaContainerFixture
|
|
41
|
+
) -> MongoKafkaConfig:
|
|
42
|
+
"""Fixture to get the combined Mongo and Kafka configuration."""
|
|
43
|
+
return MongoKafkaConfig(
|
|
44
|
+
**mongodb_container.mongodb_config.model_dump(),
|
|
45
|
+
**kafka_container.kafka_config.model_dump(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MongoKafkaFixture(NamedTuple):
|
|
50
|
+
"""Combined MongoDB and Kafka fixture"""
|
|
51
|
+
|
|
52
|
+
mongodb: MongoDbFixture
|
|
53
|
+
kafka: KafkaFixture
|
|
54
|
+
config: MongoKafkaConfig
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture(name="mongo_kafka", scope="function")
|
|
58
|
+
def mongo_kafka_fixture(
|
|
59
|
+
mongodb: MongoDbFixture, kafka: KafkaFixture
|
|
60
|
+
) -> MongoKafkaFixture:
|
|
61
|
+
"""Get a MongoKafka fixture with desired scope and name.
|
|
62
|
+
|
|
63
|
+
The state of the MongoDb and Kafka is reset by emptying all MongoDB collections
|
|
64
|
+
and clearing all Kafka topics before running tests.
|
|
65
|
+
|
|
66
|
+
By default, the function scope is used for this fixture,
|
|
67
|
+
while the session scope is used for the underlying test containers.
|
|
68
|
+
"""
|
|
69
|
+
config = MongoKafkaConfig(
|
|
70
|
+
**mongodb.config.model_dump(),
|
|
71
|
+
**kafka.config.model_dump(),
|
|
72
|
+
)
|
|
73
|
+
return MongoKafkaFixture(mongodb, kafka, config)
|
|
@@ -23,13 +23,14 @@ Please note, only use for testing purposes.
|
|
|
23
23
|
|
|
24
24
|
import hashlib
|
|
25
25
|
import os
|
|
26
|
-
from collections.abc import Generator
|
|
26
|
+
from collections.abc import AsyncGenerator, Generator
|
|
27
27
|
from contextlib import contextmanager
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
from tempfile import NamedTemporaryFile
|
|
30
30
|
from typing import Optional
|
|
31
31
|
|
|
32
32
|
import pytest
|
|
33
|
+
import pytest_asyncio
|
|
33
34
|
import requests
|
|
34
35
|
from pydantic import BaseModel, SecretStr, computed_field
|
|
35
36
|
from testcontainers.localstack import LocalStackContainer
|
|
@@ -38,7 +39,26 @@ from hexkit.custom_types import PytestScope
|
|
|
38
39
|
from hexkit.protocols.objstorage import ObjectStorageProtocol, PresignedPostURL
|
|
39
40
|
from hexkit.providers.s3.provider import S3Config, S3ObjectStorage
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
__all__ = [
|
|
43
|
+
"LOCALSTACK_IMAGE",
|
|
44
|
+
"MEBIBYTE",
|
|
45
|
+
"TEST_FILE_DIR",
|
|
46
|
+
"TEST_FILE_PATHS",
|
|
47
|
+
"calc_md5",
|
|
48
|
+
"FileObject",
|
|
49
|
+
"S3Fixture",
|
|
50
|
+
"S3ContainerFixture",
|
|
51
|
+
"get_s3_container_fixture",
|
|
52
|
+
"get_clean_s3_fixture",
|
|
53
|
+
"get_persistent_s3_fixture",
|
|
54
|
+
"clean_s3_fixture",
|
|
55
|
+
"persistent_s3_fixture",
|
|
56
|
+
"s3_fixture",
|
|
57
|
+
"file_fixture",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
LOCALSTACK_IMAGE = "localstack/localstack:3.4.0"
|
|
42
62
|
|
|
43
63
|
TEST_FILE_DIR = Path(__file__).parent.resolve() / "test_files"
|
|
44
64
|
|
|
@@ -81,7 +101,7 @@ class FileObject(BaseModel):
|
|
|
81
101
|
|
|
82
102
|
|
|
83
103
|
class S3Fixture:
|
|
84
|
-
"""
|
|
104
|
+
"""A fixture with utility methods for tests that use S3 file storage"""
|
|
85
105
|
|
|
86
106
|
def __init__(self, config: S3Config, storage: S3ObjectStorage):
|
|
87
107
|
"""Initialize with config."""
|
|
@@ -120,24 +140,100 @@ class S3Fixture:
|
|
|
120
140
|
buckets.discard(bucket)
|
|
121
141
|
|
|
122
142
|
|
|
123
|
-
|
|
124
|
-
"""
|
|
143
|
+
class S3ContainerFixture(LocalStackContainer):
|
|
144
|
+
"""LocalStack test container with S3 configuration."""
|
|
145
|
+
|
|
146
|
+
s3_config: S3Config
|
|
147
|
+
|
|
148
|
+
_created_buckets: set[str]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _s3_container_fixture() -> Generator[S3ContainerFixture, None, None]:
|
|
152
|
+
"""Fixture function for getting a running S3 test container."""
|
|
153
|
+
with S3ContainerFixture(image=LOCALSTACK_IMAGE) as s3_container:
|
|
154
|
+
s3_endpoint_url = s3_container.get_url()
|
|
155
|
+
s3_config = S3Config( # type: ignore [call-arg]
|
|
156
|
+
s3_endpoint_url=s3_endpoint_url,
|
|
157
|
+
s3_access_key_id="test",
|
|
158
|
+
s3_secret_access_key=SecretStr("test"),
|
|
159
|
+
)
|
|
160
|
+
s3_container.s3_config = s3_config
|
|
161
|
+
s3_container._created_buckets = set()
|
|
162
|
+
yield s3_container
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_s3_container_fixture(
|
|
166
|
+
scope: PytestScope = "session", name: str = "s3_container"
|
|
167
|
+
):
|
|
168
|
+
"""Get a LocalStack test container fixture with desired scope and name.
|
|
169
|
+
|
|
170
|
+
By default, the session scope is used for LocalStack test containers.
|
|
171
|
+
"""
|
|
172
|
+
return pytest.fixture(_s3_container_fixture, scope=scope, name=name)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
s3_container_fixture = get_s3_container_fixture()
|
|
176
|
+
|
|
125
177
|
|
|
126
|
-
|
|
178
|
+
def _persistent_s3_fixture(
|
|
179
|
+
s3_container: S3ContainerFixture,
|
|
180
|
+
) -> Generator[S3Fixture, None, None]:
|
|
181
|
+
"""Fixture function that gets a persistent S3 storage fixture.
|
|
182
|
+
|
|
183
|
+
The state of the S3 storage is not cleaned up by the function.
|
|
184
|
+
"""
|
|
185
|
+
config = s3_container.s3_config
|
|
186
|
+
storage = S3ObjectStorage(config=config)
|
|
187
|
+
yield S3Fixture(config=config, storage=storage)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_persistent_s3_fixture(scope: PytestScope = "function", name: str = "s3"):
|
|
191
|
+
"""Get an S3 fixture with desired scope and name.
|
|
192
|
+
|
|
193
|
+
The state of the LocalStack test container is persisted across tests.
|
|
194
|
+
|
|
195
|
+
By default, the function scope is used for this fixture,
|
|
196
|
+
while the session scope is used for the underlying LocalStack test container.
|
|
197
|
+
"""
|
|
198
|
+
return pytest.fixture(_persistent_s3_fixture, scope=scope, name=name)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
persistent_s3_fixture = get_persistent_s3_fixture()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def _clean_s3_fixture(
|
|
205
|
+
s3_container: S3ContainerFixture,
|
|
206
|
+
) -> AsyncGenerator[S3Fixture, None]:
|
|
207
|
+
"""Async fixture function that gets a clean S3 storage fixture.
|
|
208
|
+
|
|
209
|
+
The clean state is achieved by deleting all S3 buckets upfront.
|
|
127
210
|
"""
|
|
128
|
-
|
|
129
|
-
|
|
211
|
+
for s3_fixture in _persistent_s3_fixture(s3_container):
|
|
212
|
+
container_buckets = s3_container._created_buckets
|
|
213
|
+
fixture_buckets = s3_fixture.storage._created_buckets
|
|
214
|
+
fixture_buckets.update(container_buckets)
|
|
215
|
+
fixture_buckets_before = fixture_buckets.copy()
|
|
216
|
+
await s3_fixture.storage.delete_created_buckets()
|
|
217
|
+
deleted_buckets = fixture_buckets_before - fixture_buckets
|
|
218
|
+
container_buckets.difference_update(deleted_buckets)
|
|
130
219
|
|
|
131
|
-
|
|
132
|
-
yield S3Fixture(config=config, storage=storage)
|
|
220
|
+
yield s3_fixture
|
|
133
221
|
|
|
222
|
+
container_buckets.update(fixture_buckets)
|
|
134
223
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
224
|
+
|
|
225
|
+
def get_clean_s3_fixture(scope: PytestScope = "function", name: str = "s3"):
|
|
226
|
+
"""Get an S3 storage fixture with desired scope and name.
|
|
227
|
+
|
|
228
|
+
The state of the S3 storage is reset by deleting all buckets before running tests.
|
|
229
|
+
|
|
230
|
+
By default, the function scope is used for this fixture,
|
|
231
|
+
while the session scope is used for the underlying LocalStack test container.
|
|
232
|
+
"""
|
|
233
|
+
return pytest_asyncio.fixture(_clean_s3_fixture, scope=scope, name=name)
|
|
138
234
|
|
|
139
235
|
|
|
140
|
-
s3_fixture =
|
|
236
|
+
s3_fixture = clean_s3_fixture = get_clean_s3_fixture()
|
|
141
237
|
|
|
142
238
|
|
|
143
239
|
@contextmanager
|
|
@@ -146,7 +242,7 @@ def temp_file_object(
|
|
|
146
242
|
object_id: str = "default-test-object",
|
|
147
243
|
size: int = 5 * MEBIBYTE,
|
|
148
244
|
) -> Generator[FileObject, None, None]:
|
|
149
|
-
"""
|
|
245
|
+
"""Generate a file object with the specified size in bytes."""
|
|
150
246
|
chunk_size = 1024
|
|
151
247
|
chunk = b"\0" * chunk_size
|
|
152
248
|
current_size = 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: hexkit
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: A Toolkit for Building Microservices using the Hexagonal Architecture
|
|
5
5
|
Author-email: "German Human Genome Phenome Archive (GHGA)" <contact@ghga.de>
|
|
6
6
|
License: Apache 2.0
|
|
@@ -23,21 +23,21 @@ Requires-Dist: pydantic_settings<3,>=2
|
|
|
23
23
|
Requires-Dist: PyYAML<7,>=6.0
|
|
24
24
|
Provides-Extra: akafka
|
|
25
25
|
Requires-Dist: aiokafka~=0.10.0; extra == "akafka"
|
|
26
|
-
Requires-Dist: jsonschema<5,>=4.
|
|
26
|
+
Requires-Dist: jsonschema<5,>=4.22; extra == "akafka"
|
|
27
27
|
Provides-Extra: s3
|
|
28
|
-
Requires-Dist: boto3<2,>=1.
|
|
29
|
-
Requires-Dist: botocore<2,>=1.
|
|
28
|
+
Requires-Dist: boto3<2,>=1.34.104; extra == "s3"
|
|
29
|
+
Requires-Dist: botocore<2,>=1.34.104; extra == "s3"
|
|
30
30
|
Provides-Extra: mongodb
|
|
31
|
-
Requires-Dist: motor<4,>=3.
|
|
31
|
+
Requires-Dist: motor<4,>=3.4.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.4.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.4.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.4.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
|
|
@@ -38,6 +38,7 @@ src/hexkit/providers/mongodb/provider.py
|
|
|
38
38
|
src/hexkit/providers/mongodb/testutils.py
|
|
39
39
|
src/hexkit/providers/mongokafka/__init__.py
|
|
40
40
|
src/hexkit/providers/mongokafka/provider.py
|
|
41
|
+
src/hexkit/providers/mongokafka/testutils.py
|
|
41
42
|
src/hexkit/providers/s3/__init__.py
|
|
42
43
|
src/hexkit/providers/s3/provider.py
|
|
43
44
|
src/hexkit/providers/s3/testutils.py
|
|
@@ -4,29 +4,29 @@ PyYAML<7,>=6.0
|
|
|
4
4
|
|
|
5
5
|
[akafka]
|
|
6
6
|
aiokafka~=0.10.0
|
|
7
|
-
jsonschema<5,>=4.
|
|
7
|
+
jsonschema<5,>=4.22
|
|
8
8
|
|
|
9
9
|
[all]
|
|
10
10
|
hexkit[test]
|
|
11
11
|
|
|
12
12
|
[mongodb]
|
|
13
|
-
motor<4,>=3.
|
|
13
|
+
motor<4,>=3.4.0
|
|
14
14
|
|
|
15
15
|
[s3]
|
|
16
|
-
boto3<2,>=1.
|
|
17
|
-
botocore<2,>=1.
|
|
16
|
+
boto3<2,>=1.34.104
|
|
17
|
+
botocore<2,>=1.34.104
|
|
18
18
|
|
|
19
19
|
[test]
|
|
20
20
|
hexkit[test-akafka,test-mongodb,test-s3]
|
|
21
21
|
|
|
22
22
|
[test-akafka]
|
|
23
23
|
hexkit[akafka]
|
|
24
|
-
testcontainers[kafka]<5,>=4.
|
|
24
|
+
testcontainers[kafka]<5,>=4.4.1
|
|
25
25
|
|
|
26
26
|
[test-mongodb]
|
|
27
27
|
hexkit[mongodb]
|
|
28
|
-
testcontainers[mongo]<5,>=4.
|
|
28
|
+
testcontainers[mongo]<5,>=4.4.1
|
|
29
29
|
|
|
30
30
|
[test-s3]
|
|
31
31
|
hexkit[s3]
|
|
32
|
-
testcontainers<5,>=4.
|
|
32
|
+
testcontainers<5,>=4.4.1
|
|
@@ -1,101 +0,0 @@
|
|
|
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
|
-
|
|
17
|
-
"""Utilities for testing code that uses the MongoDbDaoFactory provider.
|
|
18
|
-
|
|
19
|
-
Please note, only use for testing purposes.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
from collections.abc import Generator
|
|
23
|
-
from dataclasses import dataclass
|
|
24
|
-
from typing import Optional, Union
|
|
25
|
-
|
|
26
|
-
import pytest
|
|
27
|
-
from pymongo import MongoClient
|
|
28
|
-
from pymongo.errors import ExecutionTimeout, OperationFailure
|
|
29
|
-
from testcontainers.mongodb import MongoDbContainer
|
|
30
|
-
|
|
31
|
-
from hexkit.custom_types import PytestScope
|
|
32
|
-
from hexkit.providers.mongodb.provider import MongoDbConfig, MongoDbDaoFactory
|
|
33
|
-
|
|
34
|
-
MONGODB_IMAGE = "mongo:6.0.3"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@dataclass(frozen=True)
|
|
38
|
-
class MongoDbFixture:
|
|
39
|
-
"""Yielded by the `mongodb_fixture` function"""
|
|
40
|
-
|
|
41
|
-
client: MongoClient
|
|
42
|
-
config: MongoDbConfig
|
|
43
|
-
dao_factory: MongoDbDaoFactory
|
|
44
|
-
|
|
45
|
-
def empty_collections(
|
|
46
|
-
self,
|
|
47
|
-
exclude_collections: Optional[Union[str, list[str]]] = None,
|
|
48
|
-
):
|
|
49
|
-
"""Drop all mongodb collections in the database.
|
|
50
|
-
|
|
51
|
-
You can also specify collection(s) that should be excluded
|
|
52
|
-
from the operation, i.e. collections that should be kept.
|
|
53
|
-
"""
|
|
54
|
-
db_name = self.config.db_name
|
|
55
|
-
if exclude_collections is None:
|
|
56
|
-
exclude_collections = []
|
|
57
|
-
if isinstance(exclude_collections, str):
|
|
58
|
-
exclude_collections = [exclude_collections]
|
|
59
|
-
excluded_collections = set(exclude_collections)
|
|
60
|
-
try:
|
|
61
|
-
collection_names = self.client[db_name].list_collection_names()
|
|
62
|
-
for collection_name in collection_names:
|
|
63
|
-
if collection_name not in excluded_collections:
|
|
64
|
-
self.client[db_name].drop_collection(collection_name)
|
|
65
|
-
except (ExecutionTimeout, OperationFailure) as error:
|
|
66
|
-
raise RuntimeError(
|
|
67
|
-
f"Could not drop collection(s) of Mongo database {db_name}"
|
|
68
|
-
) from error
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def config_from_mongodb_container(container: MongoDbContainer) -> MongoDbConfig:
|
|
72
|
-
"""Prepares a MongoDbConfig from an instance of a MongoDbContainer container."""
|
|
73
|
-
db_connection_str = container.get_connection_url()
|
|
74
|
-
return MongoDbConfig(db_connection_str=db_connection_str, db_name="test")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def mongodb_fixture_function() -> Generator[MongoDbFixture, None, None]:
|
|
78
|
-
"""Pytest fixture for tests depending on the MongoDbDaoFactory DAO.
|
|
79
|
-
|
|
80
|
-
**Do not call directly** Instead, use get_mongodb_fixture()
|
|
81
|
-
"""
|
|
82
|
-
with MongoDbContainer(image=MONGODB_IMAGE) as mongodb:
|
|
83
|
-
config = config_from_mongodb_container(mongodb)
|
|
84
|
-
dao_factory = MongoDbDaoFactory(config=config)
|
|
85
|
-
client = mongodb.get_connection_client()
|
|
86
|
-
|
|
87
|
-
yield MongoDbFixture(
|
|
88
|
-
client=client,
|
|
89
|
-
config=config,
|
|
90
|
-
dao_factory=dao_factory,
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
client.close()
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def get_mongodb_fixture(scope: PytestScope = "function"):
|
|
97
|
-
"""Produce a MongoDb fixture with desired scope. Default is the function scope."""
|
|
98
|
-
return pytest.fixture(mongodb_fixture_function, scope=scope)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
mongodb_fixture = get_mongodb_fixture()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|