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.
Files changed (85) hide show
  1. cledar/__init__.py +0 -0
  2. cledar/kafka/README.md +239 -0
  3. cledar/kafka/__init__.py +40 -0
  4. cledar/kafka/clients/base.py +98 -0
  5. cledar/kafka/clients/consumer.py +110 -0
  6. cledar/kafka/clients/producer.py +80 -0
  7. cledar/kafka/config/schemas.py +178 -0
  8. cledar/kafka/exceptions.py +22 -0
  9. cledar/kafka/handlers/dead_letter.py +82 -0
  10. cledar/kafka/handlers/parser.py +49 -0
  11. cledar/kafka/logger.py +3 -0
  12. cledar/kafka/models/input.py +13 -0
  13. cledar/kafka/models/message.py +10 -0
  14. cledar/kafka/models/output.py +8 -0
  15. cledar/kafka/tests/.env.test.kafka +3 -0
  16. cledar/kafka/tests/README.md +216 -0
  17. cledar/kafka/tests/conftest.py +104 -0
  18. cledar/kafka/tests/integration/__init__.py +1 -0
  19. cledar/kafka/tests/integration/conftest.py +78 -0
  20. cledar/kafka/tests/integration/helpers.py +47 -0
  21. cledar/kafka/tests/integration/test_consumer_integration.py +375 -0
  22. cledar/kafka/tests/integration/test_integration.py +394 -0
  23. cledar/kafka/tests/integration/test_producer_consumer_interaction.py +388 -0
  24. cledar/kafka/tests/integration/test_producer_integration.py +217 -0
  25. cledar/kafka/tests/unit/__init__.py +1 -0
  26. cledar/kafka/tests/unit/test_base_kafka_client.py +391 -0
  27. cledar/kafka/tests/unit/test_config_validation.py +609 -0
  28. cledar/kafka/tests/unit/test_dead_letter_handler.py +443 -0
  29. cledar/kafka/tests/unit/test_error_handling.py +674 -0
  30. cledar/kafka/tests/unit/test_input_parser.py +310 -0
  31. cledar/kafka/tests/unit/test_input_parser_comprehensive.py +489 -0
  32. cledar/kafka/tests/unit/test_utils.py +25 -0
  33. cledar/kafka/tests/unit/test_utils_comprehensive.py +408 -0
  34. cledar/kafka/utils/callbacks.py +19 -0
  35. cledar/kafka/utils/messages.py +28 -0
  36. cledar/kafka/utils/topics.py +2 -0
  37. cledar/kserve/README.md +352 -0
  38. cledar/kserve/__init__.py +3 -0
  39. cledar/kserve/tests/__init__.py +0 -0
  40. cledar/kserve/tests/test_utils.py +64 -0
  41. cledar/kserve/utils.py +27 -0
  42. cledar/logging/README.md +53 -0
  43. cledar/logging/__init__.py +3 -0
  44. cledar/logging/tests/test_universal_plaintext_formatter.py +249 -0
  45. cledar/logging/universal_plaintext_formatter.py +94 -0
  46. cledar/monitoring/README.md +71 -0
  47. cledar/monitoring/__init__.py +3 -0
  48. cledar/monitoring/monitoring_server.py +112 -0
  49. cledar/monitoring/tests/integration/test_monitoring_server_int.py +162 -0
  50. cledar/monitoring/tests/test_monitoring_server.py +59 -0
  51. cledar/nonce/README.md +99 -0
  52. cledar/nonce/__init__.py +3 -0
  53. cledar/nonce/nonce_service.py +36 -0
  54. cledar/nonce/tests/__init__.py +0 -0
  55. cledar/nonce/tests/test_nonce_service.py +136 -0
  56. cledar/redis/README.md +536 -0
  57. cledar/redis/__init__.py +15 -0
  58. cledar/redis/async_example.py +111 -0
  59. cledar/redis/example.py +37 -0
  60. cledar/redis/exceptions.py +22 -0
  61. cledar/redis/logger.py +3 -0
  62. cledar/redis/model.py +10 -0
  63. cledar/redis/redis.py +525 -0
  64. cledar/redis/redis_config_store.py +252 -0
  65. cledar/redis/tests/test_async_integration_redis.py +158 -0
  66. cledar/redis/tests/test_async_redis_service.py +380 -0
  67. cledar/redis/tests/test_integration_redis.py +119 -0
  68. cledar/redis/tests/test_redis_service.py +319 -0
  69. cledar/storage/README.md +529 -0
  70. cledar/storage/__init__.py +4 -0
  71. cledar/storage/constants.py +3 -0
  72. cledar/storage/exceptions.py +50 -0
  73. cledar/storage/models.py +19 -0
  74. cledar/storage/object_storage.py +955 -0
  75. cledar/storage/tests/conftest.py +18 -0
  76. cledar/storage/tests/test_abfs.py +164 -0
  77. cledar/storage/tests/test_integration_filesystem.py +359 -0
  78. cledar/storage/tests/test_integration_s3.py +453 -0
  79. cledar/storage/tests/test_local.py +384 -0
  80. cledar/storage/tests/test_s3.py +521 -0
  81. {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.0.3.dist-info}/METADATA +1 -1
  82. cledar_sdk-2.0.3.dist-info/RECORD +84 -0
  83. cledar_sdk-2.0.2.dist-info/RECORD +0 -4
  84. {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.0.3.dist-info}/WHEEL +0 -0
  85. {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,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("kafka_service")
@@ -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,10 @@
1
+ from pydantic.dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class KafkaMessage:
6
+ topic: str
7
+ value: str | None
8
+ key: str | None
9
+ offset: int | None
10
+ partition: int | None
@@ -0,0 +1,8 @@
1
+ import pydantic
2
+
3
+
4
+ class FailedMessageData(pydantic.BaseModel):
5
+ raised_at: str
6
+ exception_message: str | None
7
+ exception_trace: str | None
8
+ failure_reason: str | None
@@ -0,0 +1,3 @@
1
+ KAFKA_SERVERS="localhost:9092"
2
+ KAFKA_GROUP_ID="test-group"
3
+ KAFKA_TOPIC_PREFIX="test."
@@ -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)