cledar-sdk 2.0.2__py3-none-any.whl → 2.0.3__py3-none-any.whl
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.
- cledar/__init__.py +0 -0
- cledar/kafka/README.md +239 -0
- cledar/kafka/__init__.py +40 -0
- cledar/kafka/clients/base.py +98 -0
- cledar/kafka/clients/consumer.py +110 -0
- cledar/kafka/clients/producer.py +80 -0
- cledar/kafka/config/schemas.py +178 -0
- cledar/kafka/exceptions.py +22 -0
- cledar/kafka/handlers/dead_letter.py +82 -0
- cledar/kafka/handlers/parser.py +49 -0
- cledar/kafka/logger.py +3 -0
- cledar/kafka/models/input.py +13 -0
- cledar/kafka/models/message.py +10 -0
- cledar/kafka/models/output.py +8 -0
- cledar/kafka/tests/.env.test.kafka +3 -0
- cledar/kafka/tests/README.md +216 -0
- cledar/kafka/tests/conftest.py +104 -0
- cledar/kafka/tests/integration/__init__.py +1 -0
- cledar/kafka/tests/integration/conftest.py +78 -0
- cledar/kafka/tests/integration/helpers.py +47 -0
- cledar/kafka/tests/integration/test_consumer_integration.py +375 -0
- cledar/kafka/tests/integration/test_integration.py +394 -0
- cledar/kafka/tests/integration/test_producer_consumer_interaction.py +388 -0
- cledar/kafka/tests/integration/test_producer_integration.py +217 -0
- cledar/kafka/tests/unit/__init__.py +1 -0
- cledar/kafka/tests/unit/test_base_kafka_client.py +391 -0
- cledar/kafka/tests/unit/test_config_validation.py +609 -0
- cledar/kafka/tests/unit/test_dead_letter_handler.py +443 -0
- cledar/kafka/tests/unit/test_error_handling.py +674 -0
- cledar/kafka/tests/unit/test_input_parser.py +310 -0
- cledar/kafka/tests/unit/test_input_parser_comprehensive.py +489 -0
- cledar/kafka/tests/unit/test_utils.py +25 -0
- cledar/kafka/tests/unit/test_utils_comprehensive.py +408 -0
- cledar/kafka/utils/callbacks.py +19 -0
- cledar/kafka/utils/messages.py +28 -0
- cledar/kafka/utils/topics.py +2 -0
- cledar/kserve/README.md +352 -0
- cledar/kserve/__init__.py +3 -0
- cledar/kserve/tests/__init__.py +0 -0
- cledar/kserve/tests/test_utils.py +64 -0
- cledar/kserve/utils.py +27 -0
- cledar/logging/README.md +53 -0
- cledar/logging/__init__.py +3 -0
- cledar/logging/tests/test_universal_plaintext_formatter.py +249 -0
- cledar/logging/universal_plaintext_formatter.py +94 -0
- cledar/monitoring/README.md +71 -0
- cledar/monitoring/__init__.py +3 -0
- cledar/monitoring/monitoring_server.py +112 -0
- cledar/monitoring/tests/integration/test_monitoring_server_int.py +162 -0
- cledar/monitoring/tests/test_monitoring_server.py +59 -0
- cledar/nonce/README.md +99 -0
- cledar/nonce/__init__.py +3 -0
- cledar/nonce/nonce_service.py +36 -0
- cledar/nonce/tests/__init__.py +0 -0
- cledar/nonce/tests/test_nonce_service.py +136 -0
- cledar/redis/README.md +536 -0
- cledar/redis/__init__.py +15 -0
- cledar/redis/async_example.py +111 -0
- cledar/redis/example.py +37 -0
- cledar/redis/exceptions.py +22 -0
- cledar/redis/logger.py +3 -0
- cledar/redis/model.py +10 -0
- cledar/redis/redis.py +525 -0
- cledar/redis/redis_config_store.py +252 -0
- cledar/redis/tests/test_async_integration_redis.py +158 -0
- cledar/redis/tests/test_async_redis_service.py +380 -0
- cledar/redis/tests/test_integration_redis.py +119 -0
- cledar/redis/tests/test_redis_service.py +319 -0
- cledar/storage/README.md +529 -0
- cledar/storage/__init__.py +4 -0
- cledar/storage/constants.py +3 -0
- cledar/storage/exceptions.py +50 -0
- cledar/storage/models.py +19 -0
- cledar/storage/object_storage.py +955 -0
- cledar/storage/tests/conftest.py +18 -0
- cledar/storage/tests/test_abfs.py +164 -0
- cledar/storage/tests/test_integration_filesystem.py +359 -0
- cledar/storage/tests/test_integration_s3.py +453 -0
- cledar/storage/tests/test_local.py +384 -0
- cledar/storage/tests/test_s3.py +521 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.0.3.dist-info}/METADATA +1 -1
- cledar_sdk-2.0.3.dist-info/RECORD +84 -0
- cledar_sdk-2.0.2.dist-info/RECORD +0 -4
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.0.3.dist-info}/WHEEL +0 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.0.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
from pydantic import field_validator
|
|
4
|
+
from pydantic.dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class KafkaSecurityProtocol(str, Enum):
|
|
8
|
+
"""Supported Kafka security protocols."""
|
|
9
|
+
|
|
10
|
+
PLAINTEXT = "PLAINTEXT"
|
|
11
|
+
SSL = "SSL"
|
|
12
|
+
SASL_PLAINTEXT = "SASL_PLAINTEXT"
|
|
13
|
+
SASL_SSL = "SASL_SSL"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KafkaSaslMechanism(str, Enum):
|
|
17
|
+
"""Supported Kafka SASL mechanisms."""
|
|
18
|
+
|
|
19
|
+
PLAIN = "PLAIN"
|
|
20
|
+
SCRAM_SHA_256 = "SCRAM-SHA-256"
|
|
21
|
+
SCRAM_SHA_512 = "SCRAM-SHA-512"
|
|
22
|
+
GSSAPI = "GSSAPI"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate_kafka_servers(v: list[str] | str) -> list[str] | str:
|
|
26
|
+
"""Validate kafka_servers is not empty."""
|
|
27
|
+
if isinstance(v, str) and v.strip() == "":
|
|
28
|
+
raise ValueError("kafka_servers cannot be empty")
|
|
29
|
+
if isinstance(v, list) and len(v) == 0:
|
|
30
|
+
raise ValueError("kafka_servers cannot be empty list")
|
|
31
|
+
return v
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_non_negative(v: int) -> int:
|
|
35
|
+
"""Validate that timeout values are non-negative."""
|
|
36
|
+
if v < 0:
|
|
37
|
+
raise ValueError("timeout values must be non-negative")
|
|
38
|
+
return v
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class KafkaProducerConfig:
|
|
43
|
+
"""Configuration for Kafka Producer.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
kafka_servers: List of Kafka broker addresses or comma-separated string
|
|
47
|
+
kafka_group_id: Consumer group identifier
|
|
48
|
+
kafka_topic_prefix: Optional prefix for topic names
|
|
49
|
+
kafka_block_buffer_time_sec: Time to block when buffer is full
|
|
50
|
+
kafka_connection_check_timeout_sec: Timeout for connection health checks
|
|
51
|
+
kafka_connection_check_interval_sec: Interval between connection checks
|
|
52
|
+
kafka_partitioner: Partitioning strategy for messages
|
|
53
|
+
compression_type: Compression type for messages (gzip, snappy, lz4, zstd,
|
|
54
|
+
or None)
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
kafka_servers: list[str] | str
|
|
58
|
+
kafka_group_id: str
|
|
59
|
+
kafka_security_protocol: KafkaSecurityProtocol | None = None
|
|
60
|
+
kafka_sasl_mechanism: KafkaSaslMechanism | None = None
|
|
61
|
+
kafka_sasl_username: str | None = None
|
|
62
|
+
kafka_sasl_password: str | None = None
|
|
63
|
+
kafka_topic_prefix: str | None = None
|
|
64
|
+
kafka_block_buffer_time_sec: int = 10
|
|
65
|
+
kafka_connection_check_timeout_sec: int = 5
|
|
66
|
+
kafka_connection_check_interval_sec: int = 60
|
|
67
|
+
kafka_partitioner: str = "consistent_random"
|
|
68
|
+
compression_type: str | None = "gzip"
|
|
69
|
+
|
|
70
|
+
@field_validator("kafka_servers")
|
|
71
|
+
@classmethod
|
|
72
|
+
def validate_kafka_servers(cls, v: list[str] | str) -> list[str] | str:
|
|
73
|
+
return _validate_kafka_servers(v)
|
|
74
|
+
|
|
75
|
+
@field_validator(
|
|
76
|
+
"kafka_block_buffer_time_sec",
|
|
77
|
+
"kafka_connection_check_timeout_sec",
|
|
78
|
+
"kafka_connection_check_interval_sec",
|
|
79
|
+
)
|
|
80
|
+
@classmethod
|
|
81
|
+
def validate_positive_timeouts(cls, v: int) -> int:
|
|
82
|
+
return _validate_non_negative(v)
|
|
83
|
+
|
|
84
|
+
def to_kafka_config(self) -> dict[str, list[str] | str | None]:
|
|
85
|
+
"""Build Kafka producer configuration dictionary."""
|
|
86
|
+
config = {
|
|
87
|
+
"bootstrap.servers": self.kafka_servers,
|
|
88
|
+
"client.id": self.kafka_group_id,
|
|
89
|
+
"compression.type": self.compression_type,
|
|
90
|
+
"partitioner": self.kafka_partitioner,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Add SASL configuration if specified
|
|
94
|
+
if self.kafka_security_protocol:
|
|
95
|
+
config["security.protocol"] = self.kafka_security_protocol.value
|
|
96
|
+
if self.kafka_sasl_mechanism:
|
|
97
|
+
config["sasl.mechanism"] = self.kafka_sasl_mechanism.value
|
|
98
|
+
if self.kafka_sasl_username:
|
|
99
|
+
config["sasl.username"] = self.kafka_sasl_username
|
|
100
|
+
if self.kafka_sasl_password:
|
|
101
|
+
config["sasl.password"] = self.kafka_sasl_password
|
|
102
|
+
|
|
103
|
+
return config
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class KafkaConsumerConfig:
|
|
108
|
+
"""Configuration for Kafka Consumer.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
kafka_servers: List of Kafka broker addresses or comma-separated string
|
|
112
|
+
kafka_group_id: Consumer group identifier
|
|
113
|
+
kafka_offset: Starting offset position ('earliest', 'latest', or specific
|
|
114
|
+
offset)
|
|
115
|
+
kafka_topic_prefix: Optional prefix for topic names
|
|
116
|
+
kafka_block_consumer_time_sec: Time to block waiting for messages
|
|
117
|
+
kafka_connection_check_timeout_sec: Timeout for connection health checks
|
|
118
|
+
kafka_auto_commit_interval_ms: Interval for automatic offset commits
|
|
119
|
+
kafka_connection_check_interval_sec: Interval between connection checks
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
kafka_servers: list[str] | str
|
|
123
|
+
kafka_group_id: str
|
|
124
|
+
kafka_security_protocol: KafkaSecurityProtocol | None = None
|
|
125
|
+
kafka_sasl_mechanism: KafkaSaslMechanism | None = None
|
|
126
|
+
kafka_sasl_username: str | None = None
|
|
127
|
+
kafka_sasl_password: str | None = None
|
|
128
|
+
kafka_offset: str = "latest"
|
|
129
|
+
kafka_topic_prefix: str | None = None
|
|
130
|
+
kafka_block_consumer_time_sec: int = 2
|
|
131
|
+
kafka_connection_check_timeout_sec: int = 5
|
|
132
|
+
kafka_auto_commit_interval_ms: int = 1000
|
|
133
|
+
kafka_connection_check_interval_sec: int = 60
|
|
134
|
+
|
|
135
|
+
@field_validator("kafka_servers")
|
|
136
|
+
@classmethod
|
|
137
|
+
def validate_kafka_servers(cls, v: list[str] | str) -> list[str] | str:
|
|
138
|
+
return _validate_kafka_servers(v)
|
|
139
|
+
|
|
140
|
+
@field_validator("kafka_offset")
|
|
141
|
+
@classmethod
|
|
142
|
+
def validate_kafka_offset(cls, v: str) -> str:
|
|
143
|
+
if v.strip() == "":
|
|
144
|
+
raise ValueError("kafka_offset cannot be empty")
|
|
145
|
+
return v
|
|
146
|
+
|
|
147
|
+
@field_validator(
|
|
148
|
+
"kafka_block_consumer_time_sec",
|
|
149
|
+
"kafka_connection_check_timeout_sec",
|
|
150
|
+
"kafka_auto_commit_interval_ms",
|
|
151
|
+
"kafka_connection_check_interval_sec",
|
|
152
|
+
)
|
|
153
|
+
@classmethod
|
|
154
|
+
def validate_positive_timeouts(cls, v: int) -> int:
|
|
155
|
+
return _validate_non_negative(v)
|
|
156
|
+
|
|
157
|
+
def to_kafka_config(self) -> dict[str, int | list[str] | str]:
|
|
158
|
+
"""Build Kafka consumer configuration dictionary."""
|
|
159
|
+
config = {
|
|
160
|
+
"bootstrap.servers": self.kafka_servers,
|
|
161
|
+
"enable.auto.commit": False,
|
|
162
|
+
"enable.partition.eof": False,
|
|
163
|
+
"auto.commit.interval.ms": self.kafka_auto_commit_interval_ms,
|
|
164
|
+
"auto.offset.reset": self.kafka_offset,
|
|
165
|
+
"group.id": self.kafka_group_id,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Add SASL configuration if specified
|
|
169
|
+
if self.kafka_security_protocol:
|
|
170
|
+
config["security.protocol"] = self.kafka_security_protocol.value
|
|
171
|
+
if self.kafka_sasl_mechanism:
|
|
172
|
+
config["sasl.mechanism"] = self.kafka_sasl_mechanism.value
|
|
173
|
+
if self.kafka_sasl_username:
|
|
174
|
+
config["sasl.username"] = self.kafka_sasl_username
|
|
175
|
+
if self.kafka_sasl_password:
|
|
176
|
+
config["sasl.password"] = self.kafka_sasl_password
|
|
177
|
+
|
|
178
|
+
return config
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class KafkaProducerNotConnectedError(Exception):
|
|
2
|
+
"""
|
|
3
|
+
Custom exception for KafkaProducer to indicate it is not connected.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class KafkaConsumerNotConnectedError(Exception):
|
|
8
|
+
"""
|
|
9
|
+
Custom exception for KafkaConsumer to indicate it is not connected.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KafkaConnectionError(Exception):
|
|
14
|
+
"""
|
|
15
|
+
Custom exception to indicate connection failures.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KafkaConsumerError(Exception):
|
|
20
|
+
"""
|
|
21
|
+
Custom exception for KafkaConsumer to indicate errors.
|
|
22
|
+
"""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from ..clients.producer import KafkaProducer
|
|
4
|
+
from ..logger import logger
|
|
5
|
+
from ..models.message import KafkaMessage
|
|
6
|
+
from ..models.output import FailedMessageData
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeadLetterHandler:
|
|
10
|
+
"""
|
|
11
|
+
A Handler for handling failed messages and sending them to a DLQ topic.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, producer: KafkaProducer, dlq_topic: str) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Initialize DeadLetterHandler with a Kafka producer and DLQ topic.
|
|
17
|
+
|
|
18
|
+
:param producer: KafkaProducer instance.
|
|
19
|
+
:param dlq_topic: The name of the DLQ Kafka topic.
|
|
20
|
+
"""
|
|
21
|
+
self.producer: KafkaProducer = producer
|
|
22
|
+
self.dlq_topic: str = dlq_topic
|
|
23
|
+
|
|
24
|
+
def handle(
|
|
25
|
+
self,
|
|
26
|
+
message: KafkaMessage,
|
|
27
|
+
failures_details: list[FailedMessageData] | None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Handles a failed message by sending it to the DLQ topic.
|
|
31
|
+
|
|
32
|
+
:param message: The original Kafka message.
|
|
33
|
+
:param failures_details: A list of FailedMessageData.
|
|
34
|
+
"""
|
|
35
|
+
logger.info("Handling message for DLQ.")
|
|
36
|
+
|
|
37
|
+
kafka_headers = self._build_headers(failures_details=failures_details)
|
|
38
|
+
|
|
39
|
+
logger.info("DLQ message built successfully.")
|
|
40
|
+
self._send_message(message.value, message.key, kafka_headers)
|
|
41
|
+
|
|
42
|
+
def _build_headers(
|
|
43
|
+
self,
|
|
44
|
+
failures_details: list[FailedMessageData] | None,
|
|
45
|
+
) -> list[tuple[str, bytes]]:
|
|
46
|
+
"""
|
|
47
|
+
Builds Kafka headers containing exception details.
|
|
48
|
+
|
|
49
|
+
:param failures_details: A list of FailedMessageData.
|
|
50
|
+
:return: A list of Kafka headers.
|
|
51
|
+
"""
|
|
52
|
+
headers: list[tuple[str, bytes]] = []
|
|
53
|
+
|
|
54
|
+
if failures_details:
|
|
55
|
+
failures_json = json.dumps(
|
|
56
|
+
[failure.model_dump() for failure in failures_details]
|
|
57
|
+
)
|
|
58
|
+
headers.append(("failures_details", failures_json.encode("utf-8")))
|
|
59
|
+
|
|
60
|
+
return headers
|
|
61
|
+
|
|
62
|
+
def _send_message(
|
|
63
|
+
self,
|
|
64
|
+
message: str | None,
|
|
65
|
+
key: str | None,
|
|
66
|
+
headers: list[tuple[str, bytes]],
|
|
67
|
+
) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Sends a DLQ message to the Kafka DLQ topic with headers.
|
|
70
|
+
|
|
71
|
+
:param message: The DLQ message payload.
|
|
72
|
+
:param key: The original Kafka message key.
|
|
73
|
+
:param headers: Kafka headers containing exception details.
|
|
74
|
+
"""
|
|
75
|
+
self.producer.send(
|
|
76
|
+
topic=self.dlq_topic, value=message, key=key, headers=headers
|
|
77
|
+
)
|
|
78
|
+
logger.info(
|
|
79
|
+
"Message sent to DLQ topic successfully with key: %s and headers: %s",
|
|
80
|
+
key,
|
|
81
|
+
headers,
|
|
82
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..models.input import (
|
|
6
|
+
InputKafkaMessage,
|
|
7
|
+
)
|
|
8
|
+
from ..models.message import KafkaMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IncorrectMessageValueError(Exception):
|
|
12
|
+
"""
|
|
13
|
+
Message needs to have `value` field present in order to be parsed.
|
|
14
|
+
|
|
15
|
+
This is unless `model` is set to `None`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InputParser[Payload: BaseModel]:
|
|
20
|
+
def __init__(self, model: type[Payload]) -> None:
|
|
21
|
+
self.model: type[Payload] = model
|
|
22
|
+
|
|
23
|
+
def parse_json(self, json_str: str) -> Payload:
|
|
24
|
+
"""Parse JSON text and validate into the target Payload model.
|
|
25
|
+
|
|
26
|
+
Invalid JSON should raise IncorrectMessageValueError, while schema
|
|
27
|
+
validation errors should bubble up as ValidationError.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(json_str)
|
|
31
|
+
except json.JSONDecodeError as exc:
|
|
32
|
+
# Normalize invalid JSON into our domain-specific error
|
|
33
|
+
raise IncorrectMessageValueError from exc
|
|
34
|
+
return self.model.model_validate(data)
|
|
35
|
+
|
|
36
|
+
def parse_message(self, message: KafkaMessage) -> InputKafkaMessage[Payload]:
|
|
37
|
+
if message.value is None and self.model is not None:
|
|
38
|
+
raise IncorrectMessageValueError
|
|
39
|
+
|
|
40
|
+
obj = self.parse_json(message.value)
|
|
41
|
+
|
|
42
|
+
return InputKafkaMessage(
|
|
43
|
+
key=message.key,
|
|
44
|
+
value=message.value,
|
|
45
|
+
payload=obj,
|
|
46
|
+
topic=message.topic,
|
|
47
|
+
offset=message.offset,
|
|
48
|
+
partition=message.partition,
|
|
49
|
+
)
|
cledar/kafka/logger.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from typing import TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from .message import KafkaMessage
|
|
7
|
+
|
|
8
|
+
Payload = TypeVar("Payload", bound=BaseModel)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass
|
|
12
|
+
class InputKafkaMessage[Payload](KafkaMessage):
|
|
13
|
+
payload: Payload
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Kafka Service Tests
|
|
2
|
+
|
|
3
|
+
This directory contains the test suite for the Kafka service, organized into unit and integration tests.
|
|
4
|
+
|
|
5
|
+
## Directory Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
tests/
|
|
9
|
+
├── conftest.py # Test-wide teardown (cleans Kafka client threads)
|
|
10
|
+
├── README.md
|
|
11
|
+
├── unit/ # Unit tests (176 tests)
|
|
12
|
+
│ ├── test_base_kafka_client.py
|
|
13
|
+
│ ├── test_config_validation.py
|
|
14
|
+
│ ├── test_dead_letter_handler.py
|
|
15
|
+
│ ├── test_error_handling.py
|
|
16
|
+
│ ├── test_input_parser.py
|
|
17
|
+
│ ├── test_input_parser_comprehensive.py
|
|
18
|
+
│ ├── test_utils.py
|
|
19
|
+
│ ├── test_utils_comprehensive.py
|
|
20
|
+
│ └── requirements-test.txt
|
|
21
|
+
└── integration/ # Integration tests (41 tests)
|
|
22
|
+
├── conftest.py # Shared Kafka fixtures (container, configs, clients)
|
|
23
|
+
├── helpers.py # E2EData, consume_until, ensure_topic_and_subscribe
|
|
24
|
+
├── test_integration.py
|
|
25
|
+
├── test_producer_integration.py
|
|
26
|
+
├── test_consumer_integration.py
|
|
27
|
+
└── test_producer_consumer_interaction.py
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Test Categories
|
|
31
|
+
|
|
32
|
+
### Unit Tests (`unit/`)
|
|
33
|
+
Unit tests focus on testing individual components in isolation using mocks and stubs. They are fast, reliable, and don't require external dependencies.
|
|
34
|
+
|
|
35
|
+
- **Base Client Tests**: Test the base Kafka client functionality
|
|
36
|
+
- **Config Validation**: Test configuration validation and schema validation
|
|
37
|
+
- **Dead Letter Handler**: Test dead letter queue handling with mocked producers
|
|
38
|
+
- **Error Handling**: Test error scenarios and exception handling
|
|
39
|
+
- **Input Parser**: Test message parsing and validation
|
|
40
|
+
- **Utils**: Test utility functions and helper methods
|
|
41
|
+
|
|
42
|
+
### Integration Tests (`integration/`)
|
|
43
|
+
Integration tests use real external dependencies (like Kafka via testcontainers) to test the complete flow of the system.
|
|
44
|
+
|
|
45
|
+
- **Real Kafka Integration**: Tests with actual Kafka instance using testcontainers
|
|
46
|
+
- **Producer Integration**: Real producer operations and message sending
|
|
47
|
+
- **Consumer Integration**: Real consumer operations and message consumption
|
|
48
|
+
- **Producer-Consumer Interaction**: Real interaction patterns between producer and consumer
|
|
49
|
+
- **End-to-End Flows**: Complete producer-consumer workflows
|
|
50
|
+
- **Connection Recovery**: Real connection failure and recovery scenarios
|
|
51
|
+
- **Performance Tests**: Stress tests and large message handling
|
|
52
|
+
|
|
53
|
+
## Running Tests
|
|
54
|
+
|
|
55
|
+
### Run All Tests
|
|
56
|
+
```bash
|
|
57
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Run Only Unit Tests
|
|
61
|
+
```bash
|
|
62
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/unit/
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Run Only Integration Tests
|
|
66
|
+
```bash
|
|
67
|
+
# Run all integration tests
|
|
68
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/
|
|
69
|
+
|
|
70
|
+
# Run specific integration test file
|
|
71
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/test_producer_integration.py
|
|
72
|
+
|
|
73
|
+
# Run single test
|
|
74
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/test_integration.py::test_end_to_end_message_flow -v
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Run Specific Test Files
|
|
78
|
+
```bash
|
|
79
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/unit/test_config_validation.py
|
|
80
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/test_integration.py
|
|
81
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/test_producer_integration.py
|
|
82
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/test_consumer_integration.py
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Test Requirements
|
|
86
|
+
|
|
87
|
+
- **Unit Tests**: No external dependencies required
|
|
88
|
+
- **Integration Tests**: Requires Docker to be running for testcontainers
|
|
89
|
+
- **Slow Integration Tests**: Marked as skipped by default due to execution time (2-5 minutes each)
|
|
90
|
+
|
|
91
|
+
## Performance Notes
|
|
92
|
+
|
|
93
|
+
- **Unit Tests**: Fast execution (~10–15 seconds for all 176 tests)
|
|
94
|
+
- **Integration Tests**: Moderate execution (~2–2.5 minutes for 41 tests)
|
|
95
|
+
- Helpers reduce flakiness: `consume_until()` polls with timeout instead of fixed sleeps
|
|
96
|
+
|
|
97
|
+
## Docker Setup for Integration Tests
|
|
98
|
+
|
|
99
|
+
The integration tests use testcontainers to spin up real Kafka instances for testing. This requires Docker to be installed and running.
|
|
100
|
+
|
|
101
|
+
### Prerequisites
|
|
102
|
+
|
|
103
|
+
1. **Install Docker Desktop** (recommended):
|
|
104
|
+
- [Docker Desktop for Mac](https://docs.docker.com/desktop/mac/install/)
|
|
105
|
+
- [Docker Desktop for Windows](https://docs.docker.com/desktop/windows/install/)
|
|
106
|
+
- [Docker Desktop for Linux](https://docs.docker.com/desktop/linux/install/)
|
|
107
|
+
|
|
108
|
+
2. **Or install Docker Engine** (alternative):
|
|
109
|
+
```bash
|
|
110
|
+
# macOS (using Homebrew)
|
|
111
|
+
brew install docker
|
|
112
|
+
|
|
113
|
+
# Ubuntu/Debian
|
|
114
|
+
sudo apt-get update
|
|
115
|
+
sudo apt-get install docker.io
|
|
116
|
+
|
|
117
|
+
# CentOS/RHEL
|
|
118
|
+
sudo yum install docker
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Starting Docker
|
|
122
|
+
|
|
123
|
+
#### Docker Desktop
|
|
124
|
+
1. Launch Docker Desktop application
|
|
125
|
+
2. Wait for Docker to start (you'll see the Docker whale icon in your system tray)
|
|
126
|
+
3. Verify Docker is running:
|
|
127
|
+
```bash
|
|
128
|
+
docker --version
|
|
129
|
+
docker ps
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### Docker Engine (Linux)
|
|
133
|
+
```bash
|
|
134
|
+
# Start Docker service
|
|
135
|
+
sudo systemctl start docker
|
|
136
|
+
sudo systemctl enable docker
|
|
137
|
+
|
|
138
|
+
# Add your user to docker group (optional, to avoid sudo)
|
|
139
|
+
sudo usermod -aG docker $USER
|
|
140
|
+
# Log out and back in for group changes to take effect
|
|
141
|
+
|
|
142
|
+
# Verify Docker is running
|
|
143
|
+
docker --version
|
|
144
|
+
docker ps
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Running Integration Tests
|
|
148
|
+
|
|
149
|
+
Once Docker is running, you can execute the integration tests:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# Run all integration tests
|
|
153
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/
|
|
154
|
+
|
|
155
|
+
# Run a specific integration test
|
|
156
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/test_integration.py::test_producer_consumer_basic_flow
|
|
157
|
+
|
|
158
|
+
# Run integration tests with verbose output
|
|
159
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/ -v
|
|
160
|
+
|
|
161
|
+
# Run integration tests and show logs
|
|
162
|
+
PYTHONPATH=. uv run pytest cledar/kafka_service/tests/integration/ -s
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Troubleshooting Docker Issues
|
|
166
|
+
|
|
167
|
+
#### Docker not running
|
|
168
|
+
```bash
|
|
169
|
+
# Check if Docker is running
|
|
170
|
+
docker ps
|
|
171
|
+
# If you get "Cannot connect to the Docker daemon", Docker is not running
|
|
172
|
+
|
|
173
|
+
# Start Docker Desktop or Docker service
|
|
174
|
+
# Docker Desktop: Launch the application
|
|
175
|
+
# Docker Engine: sudo systemctl start docker
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### Permission denied errors
|
|
179
|
+
```bash
|
|
180
|
+
# Add user to docker group (Linux)
|
|
181
|
+
sudo usermod -aG docker $USER
|
|
182
|
+
# Log out and back in
|
|
183
|
+
|
|
184
|
+
# Or run with sudo (not recommended)
|
|
185
|
+
sudo docker ps
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### Port conflicts
|
|
189
|
+
If you have Kafka running locally on port 9092, the testcontainers will automatically use different ports. No action needed.
|
|
190
|
+
|
|
191
|
+
#### Resource constraints
|
|
192
|
+
If tests fail due to memory/CPU constraints:
|
|
193
|
+
```bash
|
|
194
|
+
# Check Docker resource limits in Docker Desktop settings
|
|
195
|
+
# Increase memory allocation if needed (recommended: 4GB+)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Test Container Details
|
|
199
|
+
|
|
200
|
+
The integration tests use:
|
|
201
|
+
- **Kafka Image**: `confluentinc/cp-kafka:7.4.0`
|
|
202
|
+
- **Automatic Port Assignment**: testcontainers handles port conflicts
|
|
203
|
+
- **Automatic Cleanup**: containers are removed after tests complete
|
|
204
|
+
- **Session Scope**: Kafka container is shared across all integration tests in a session
|
|
205
|
+
|
|
206
|
+
## Test Statistics
|
|
207
|
+
|
|
208
|
+
- **Total Tests**: 217
|
|
209
|
+
- **Unit Tests**: 176
|
|
210
|
+
- **Integration Tests**: 41
|
|
211
|
+
|
|
212
|
+
## Notes
|
|
213
|
+
|
|
214
|
+
- All tests use `PYTHONPATH=.` to ensure proper module imports
|
|
215
|
+
- Integration tests use shared fixtures in `integration/conftest.py` and helpers in `integration/helpers.py`
|
|
216
|
+
- Test-wide teardown in `tests/conftest.py` ensures Kafka client threads don’t block process exit
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pytest configuration and fixtures to ensure proper teardown of Kafka clients and
|
|
3
|
+
their background threads during tests.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import weakref
|
|
9
|
+
from collections.abc import Callable, Generator
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from cledar.kafka.clients.base import BaseKafkaClient
|
|
15
|
+
from cledar.kafka.logger import logger
|
|
16
|
+
|
|
17
|
+
# Weak registry of all created BaseKafkaClient instances (does not keep them alive)
|
|
18
|
+
_active_clients: "weakref.WeakSet[BaseKafkaClient]" = weakref.WeakSet()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _wrap_post_init() -> tuple[
|
|
22
|
+
Callable[[BaseKafkaClient], None], Callable[[BaseKafkaClient], None]
|
|
23
|
+
]:
|
|
24
|
+
"""Monkeypatch BaseKafkaClient.__post_init__ to register instances."""
|
|
25
|
+
original = BaseKafkaClient.__post_init__
|
|
26
|
+
|
|
27
|
+
def wrapped(self: BaseKafkaClient) -> None:
|
|
28
|
+
original(self)
|
|
29
|
+
try:
|
|
30
|
+
_active_clients.add(self)
|
|
31
|
+
except Exception:
|
|
32
|
+
# Best-effort registration only for tests
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
return original, wrapped
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _wrap_start_connection_check_thread() -> tuple[
|
|
39
|
+
Callable[[BaseKafkaClient], None], Callable[[BaseKafkaClient], None]
|
|
40
|
+
]:
|
|
41
|
+
"""Monkeypatch start_connection_check_thread to use daemon threads in tests.
|
|
42
|
+
|
|
43
|
+
This prevents non-daemon threads from blocking interpreter shutdown when tests
|
|
44
|
+
forget to call shutdown() explicitly.
|
|
45
|
+
"""
|
|
46
|
+
original = BaseKafkaClient.start_connection_check_thread
|
|
47
|
+
|
|
48
|
+
def wrapped(self: BaseKafkaClient) -> None:
|
|
49
|
+
if self.connection_check_thread is None:
|
|
50
|
+
self.connection_check_thread = threading.Thread(
|
|
51
|
+
target=self._monitor_connection
|
|
52
|
+
)
|
|
53
|
+
# Ensure test background threads never block process exit
|
|
54
|
+
self.connection_check_thread.daemon = True
|
|
55
|
+
self.connection_check_thread.start()
|
|
56
|
+
logger.info(
|
|
57
|
+
f"Started {self.__class__.__name__} connection check thread.",
|
|
58
|
+
extra={"interval": self.config.kafka_connection_check_interval_sec},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return original, wrapped
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cleanup_all_clients() -> None:
|
|
65
|
+
"""Shutdown all known clients; ignore errors during cleanup."""
|
|
66
|
+
for client in list(_active_clients):
|
|
67
|
+
try:
|
|
68
|
+
client.shutdown()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
74
|
+
def _session_monkeypatch() -> Generator[None, None, None]:
|
|
75
|
+
"""Apply monkeypatches for the entire test session and ensure final cleanup."""
|
|
76
|
+
# Monkeypatch __post_init__ to register instances
|
|
77
|
+
orig_post_init, wrapped_post_init = _wrap_post_init()
|
|
78
|
+
cast(Any, BaseKafkaClient).__post_init__ = wrapped_post_init
|
|
79
|
+
|
|
80
|
+
# Monkeypatch start_connection_check_thread to create daemon threads
|
|
81
|
+
orig_start, wrapped_start = _wrap_start_connection_check_thread()
|
|
82
|
+
cast(Any, BaseKafkaClient).start_connection_check_thread = wrapped_start
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
yield
|
|
86
|
+
finally:
|
|
87
|
+
# Restore originals
|
|
88
|
+
cast(Any, BaseKafkaClient).__post_init__ = orig_post_init
|
|
89
|
+
cast(Any, BaseKafkaClient).start_connection_check_thread = orig_start
|
|
90
|
+
|
|
91
|
+
# Final cleanup at session end
|
|
92
|
+
_cleanup_all_clients()
|
|
93
|
+
|
|
94
|
+
# Give threads a small grace period to finish
|
|
95
|
+
time.sleep(0.1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.fixture(autouse=True)
|
|
99
|
+
def _per_test_cleanup() -> Generator[None, None, None]:
|
|
100
|
+
"""Ensure all clients are shut down after each individual test."""
|
|
101
|
+
yield
|
|
102
|
+
_cleanup_all_clients()
|
|
103
|
+
# Small grace to allow quick thread exit
|
|
104
|
+
time.sleep(0.05)
|