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,249 @@
1
+ # pylint: disable=unused-argument, protected-access
2
+ import logging
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from cledar.logging.universal_plaintext_formatter import UniversalPlaintextFormatter
10
+
11
+
12
+ @pytest.fixture(name="formatter")
13
+ def fixture_formatter() -> UniversalPlaintextFormatter:
14
+ """Create a basic formatter instance for testing."""
15
+ return UniversalPlaintextFormatter(
16
+ fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
17
+ )
18
+
19
+
20
+ @pytest.fixture(name="log_record")
21
+ def fixture_log_record() -> logging.LogRecord:
22
+ """Create a basic log record for testing."""
23
+ return logging.LogRecord(
24
+ name="test_logger",
25
+ level=logging.INFO,
26
+ pathname="/path/to/file.py",
27
+ lineno=42,
28
+ msg="Test message",
29
+ args=(),
30
+ exc_info=None,
31
+ )
32
+
33
+
34
+ def test_basic_formatting_without_extras(
35
+ formatter: UniversalPlaintextFormatter, log_record: logging.LogRecord
36
+ ) -> None:
37
+ """Test that basic formatting works without extra attributes."""
38
+ formatted = formatter.format(log_record)
39
+ assert "Test message" in formatted
40
+ assert "test_logger" in formatted
41
+ assert "INFO" in formatted
42
+
43
+
44
+ def test_standard_attributes_excluded(
45
+ formatter: UniversalPlaintextFormatter, log_record: logging.LogRecord
46
+ ) -> None:
47
+ """Test that standard LogRecord attributes are excluded from extras."""
48
+ formatted = formatter.format(log_record)
49
+ # Standard attributes should not appear as extras
50
+ assert "pathname:" not in formatted
51
+ assert "lineno:" not in formatted
52
+ assert "levelname:" not in formatted
53
+
54
+
55
+ def test_extra_attributes_included(
56
+ formatter: UniversalPlaintextFormatter, log_record: logging.LogRecord
57
+ ) -> None:
58
+ """Test that extra attributes are included in the formatted output."""
59
+ log_record.user_id = "12345"
60
+ log_record.request_id = "abc-def-ghi"
61
+
62
+ formatted = formatter.format(log_record)
63
+
64
+ assert "user_id: 12345" in formatted
65
+ assert "request_id: abc-def-ghi" in formatted
66
+
67
+
68
+ def test_default_exclude_keys(
69
+ formatter: UniversalPlaintextFormatter, log_record: logging.LogRecord
70
+ ) -> None:
71
+ """Test that DEFAULT_EXCLUDE_KEYS (message, asctime) are excluded."""
72
+ # Add 'message' and 'asctime' as extra attributes (shouldn't appear in extras)
73
+ log_record.message = "This should be excluded"
74
+ log_record.asctime = "2025-01-01 12:00:00"
75
+
76
+ formatted = formatter.format(log_record)
77
+
78
+ # These should not appear as extras
79
+ lines = formatted.split("\n")
80
+ extra_lines = [
81
+ line for line in lines if line.strip().startswith(("message:", "asctime:"))
82
+ ]
83
+ assert len(extra_lines) == 0
84
+
85
+
86
+ def test_multiple_extras_formatting(
87
+ formatter: UniversalPlaintextFormatter, log_record: logging.LogRecord
88
+ ) -> None:
89
+ """Test formatting with multiple extra attributes."""
90
+ log_record.user_id = "12345"
91
+ log_record.session_id = "session-xyz"
92
+ log_record.ip_address = "192.168.1.1"
93
+
94
+ formatted = formatter.format(log_record)
95
+
96
+ assert "user_id: 12345" in formatted
97
+ assert "session_id: session-xyz" in formatted
98
+ assert "ip_address: 192.168.1.1" in formatted
99
+
100
+ # Check that extras are indented
101
+ lines = formatted.split("\n")
102
+ extra_lines = [
103
+ line
104
+ for line in lines
105
+ if any(key in line for key in ("user_id:", "session_id:", "ip_address:"))
106
+ ]
107
+ for line in extra_lines:
108
+ assert line.startswith(" ")
109
+
110
+
111
+ def test_config_exclude_keys_from_file(log_record: logging.LogRecord) -> None:
112
+ """Test that exclude_keys from configuration file are properly excluded."""
113
+ with tempfile.TemporaryDirectory() as tmpdir:
114
+ config_path = Path(tmpdir) / "logging.conf"
115
+ config_content = """[formatter_plaintextFormatter]
116
+ exclude_keys = custom_field, another_field
117
+ """
118
+ config_path.write_text(config_content)
119
+
120
+ # Change to temp directory to read config
121
+ original_dir = os.getcwd()
122
+ try:
123
+ os.chdir(tmpdir)
124
+ formatter = UniversalPlaintextFormatter(fmt="%(message)s")
125
+
126
+ # Add attributes that should be excluded
127
+ log_record.custom_field = "should be excluded"
128
+ log_record.another_field = "also excluded"
129
+ log_record.included_field = "should be included"
130
+
131
+ formatted = formatter.format(log_record)
132
+
133
+ assert "custom_field:" not in formatted
134
+ assert "another_field:" not in formatted
135
+ assert "included_field: should be included" in formatted
136
+ finally:
137
+ os.chdir(original_dir)
138
+
139
+
140
+ def test_config_exclude_keys_with_whitespace(
141
+ log_record: logging.LogRecord,
142
+ ) -> None:
143
+ """Test that whitespace in exclude_keys configuration is handled correctly."""
144
+ with tempfile.TemporaryDirectory() as tmpdir:
145
+ config_path = Path(tmpdir) / "logging.conf"
146
+ config_content = """[formatter_plaintextFormatter]
147
+ exclude_keys = field1 , field2 , field3
148
+ """
149
+ config_path.write_text(config_content)
150
+
151
+ original_dir = os.getcwd()
152
+ try:
153
+ os.chdir(tmpdir)
154
+ formatter = UniversalPlaintextFormatter(fmt="%(message)s")
155
+
156
+ log_record.field1 = "excluded"
157
+ log_record.field2 = "excluded"
158
+ log_record.field3 = "excluded"
159
+
160
+ formatted = formatter.format(log_record)
161
+
162
+ assert "field1:" not in formatted
163
+ assert "field2:" not in formatted
164
+ assert "field3:" not in formatted
165
+ finally:
166
+ os.chdir(original_dir)
167
+
168
+
169
+ def test_no_config_file(
170
+ formatter: UniversalPlaintextFormatter, log_record: logging.LogRecord
171
+ ) -> None:
172
+ """Test that formatter works correctly when config file doesn't exist."""
173
+ log_record.some_extra = "value"
174
+ formatted = formatter.format(log_record)
175
+
176
+ # Should still format correctly
177
+ assert "Test message" in formatted
178
+ assert "some_extra: value" in formatted
179
+
180
+
181
+ def test_empty_extras(
182
+ formatter: UniversalPlaintextFormatter, log_record: logging.LogRecord
183
+ ) -> None:
184
+ """Test formatting when there are no extra attributes."""
185
+ formatted = formatter.format(log_record)
186
+
187
+ # Should only contain the base formatted message without extra newlines
188
+ lines = formatted.split("\n")
189
+ assert len([line for line in lines if line.strip()]) == 1
190
+
191
+
192
+ def test_standard_attrs_caching(
193
+ formatter: UniversalPlaintextFormatter,
194
+ ) -> None:
195
+ """Test that standard attributes are cached after first call."""
196
+ assert formatter._standard_attrs is None
197
+
198
+ # First call should set the cache
199
+ standard_attrs = formatter._get_standard_attrs()
200
+ assert formatter._standard_attrs is not None
201
+ assert formatter._standard_attrs == standard_attrs
202
+
203
+ # Second call should return cached value
204
+ standard_attrs_2 = formatter._get_standard_attrs()
205
+ assert standard_attrs_2 is standard_attrs # Same object
206
+
207
+
208
+ def test_formatter_with_custom_format_string(
209
+ log_record: logging.LogRecord,
210
+ ) -> None:
211
+ """Test formatter with a custom format string."""
212
+ formatter = UniversalPlaintextFormatter(fmt="[%(levelname)s] %(message)s")
213
+ log_record.extra_data = "test"
214
+
215
+ formatted = formatter.format(log_record)
216
+
217
+ assert "[INFO] Test message" in formatted
218
+ assert "extra_data: test" in formatted
219
+
220
+
221
+ def test_exclude_keys_combination(log_record: logging.LogRecord) -> None:
222
+ """Test that all exclusion sources are combined correctly."""
223
+ with tempfile.TemporaryDirectory() as tmpdir:
224
+ config_path = Path(tmpdir) / "logging.conf"
225
+ config_content = """[formatter_plaintextFormatter]
226
+ exclude_keys = config_excluded
227
+ """
228
+ config_path.write_text(config_content)
229
+
230
+ original_dir = os.getcwd()
231
+ try:
232
+ os.chdir(tmpdir)
233
+ formatter = UniversalPlaintextFormatter(fmt="%(message)s")
234
+
235
+ # Add various attributes
236
+ log_record.pathname = "standard_attr" # Standard LogRecord attribute
237
+ log_record.message = "default_excluded" # DEFAULT_EXCLUDE_KEYS
238
+ log_record.config_excluded = "from_config" # From config file
239
+ log_record.should_appear = "yes" # Should appear
240
+
241
+ formatted = formatter.format(log_record)
242
+
243
+ # Only should_appear should be in extras
244
+ assert "pathname:" not in formatted # Standard attribute
245
+ assert "message:" not in formatted # DEFAULT_EXCLUDE_KEYS
246
+ assert "config_excluded:" not in formatted # Config exclude
247
+ assert "should_appear: yes" in formatted # Should be included
248
+ finally:
249
+ os.chdir(original_dir)
@@ -0,0 +1,94 @@
1
+ import configparser
2
+ import logging
3
+ from typing import Any
4
+
5
+
6
+ class UniversalPlaintextFormatter(logging.Formatter):
7
+ """
8
+ A custom formatter for logging that extends the standard logging.Formatter.
9
+
10
+ This formatter adds the ability to include extra attributes from log records while
11
+ excluding standard attributes and configurable keys.
12
+ """
13
+
14
+ # Predefined exclusions - keys that should always be excluded
15
+ DEFAULT_EXCLUDE_KEYS = {"message", "asctime"}
16
+
17
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
18
+ """
19
+ Initialize the formatter with standard formatter parameters.
20
+
21
+ Args:
22
+ *args: Variable length argument list for the parent class.
23
+ **kwargs: Arbitrary keyword arguments for the parent class.
24
+ """
25
+ super().__init__(*args, **kwargs)
26
+ self._standard_attrs: set[str] | None = None
27
+ self._config_exclude_keys = self._load_exclude_keys_from_config()
28
+
29
+ def _load_exclude_keys_from_config(self) -> set[str]:
30
+ """
31
+ Load additional keys to exclude from the configuration file.
32
+
33
+ Returns:
34
+ set: A set of keys to exclude from log records.
35
+ """
36
+ try:
37
+ config = configparser.ConfigParser()
38
+ config.read("logging.conf")
39
+ if config.has_option("formatter_plaintextFormatter", "exclude_keys"):
40
+ exclude_str = config.get("formatter_plaintextFormatter", "exclude_keys")
41
+ return set(key.strip() for key in exclude_str.split(",") if key.strip())
42
+ except (configparser.Error, FileNotFoundError, PermissionError, ValueError):
43
+ pass
44
+ return set()
45
+
46
+ def _get_standard_attrs(self) -> set[str]:
47
+ """
48
+ Get the set of standard attributes to exclude from log records.
49
+
50
+ This includes standard LogRecord attributes, predefined exclusions,
51
+ and exclusions from configuration.
52
+
53
+ Returns:
54
+ set: A set of attribute names to exclude.
55
+ """
56
+ if self._standard_attrs is None:
57
+ dummy_record = logging.LogRecord(
58
+ name="dummy",
59
+ level=logging.INFO,
60
+ pathname="",
61
+ lineno=0,
62
+ msg="",
63
+ args=(),
64
+ exc_info=None,
65
+ )
66
+ # Combine standard attributes + predefined + from configuration
67
+ all_excludes = (
68
+ set(dummy_record.__dict__.keys())
69
+ | self.DEFAULT_EXCLUDE_KEYS
70
+ | self._config_exclude_keys
71
+ )
72
+ self._standard_attrs = all_excludes
73
+ return self._standard_attrs
74
+
75
+ def format(self, record: logging.LogRecord) -> str:
76
+ """
77
+ Format the log record, adding any extra attributes not in the standard set.
78
+
79
+ Args:
80
+ record: The log record to format.
81
+
82
+ Returns:
83
+ str: The formatted log message with extra attributes appended.
84
+ """
85
+ base = super().format(record)
86
+ extras = {
87
+ k: v
88
+ for k, v in record.__dict__.items()
89
+ if k not in self._get_standard_attrs()
90
+ }
91
+ if extras:
92
+ extras_str = "\n".join(f" {k}: {v}" for k, v in extras.items())
93
+ return f"{base}\n{extras_str}"
94
+ return base
@@ -0,0 +1,71 @@
1
+ # Monitoring Service
2
+
3
+ Monitoring service provides endpoints for healthchecks and Prometheus metrics.
4
+
5
+ This module creates a monitoring server with uvicorn endpoints for managing health of applications.
6
+
7
+ ## Endpoints
8
+
9
+ - GET /healthz/liveness ->
10
+ Provides information if the app is alive.
11
+ Example return:
12
+ ```json
13
+ {"status": "ok", "checks": {}}
14
+ ```
15
+ - GET /healthz/readiness ->
16
+ Provides information if the app is ready and which components are active.
17
+ Example return:
18
+ ```json
19
+ {"status": "ok", "checks": {"kafka_alive": true, "model_ready": true, "redis_alive": true}}
20
+ ```
21
+ - GET /metrics ->
22
+ Provides metrics collected by Prometheus client, to be used in metrics visualization client f.e. Grafana.
23
+
24
+ ## Usage
25
+
26
+ In your app you have to define readiness checks - services that need to be running for app to work properly
27
+ f.e. S3, Kafka, model loading, etc.
28
+ This is usually solved by creating MonitoringContext object.
29
+
30
+ ```python
31
+ class MonitoringContext:
32
+ def __init__(self) -> None:
33
+ self.kafka_client: Optional[BaseKafkaClient] = None
34
+ self.redis_client: Optional[RedisService] = None
35
+ self._model_ready_flag: bool = False
36
+
37
+ def prepare_readiness_checks(self) -> dict[str, Callable[[], bool]]:
38
+ return {
39
+ "kafka_alive": self._kafka_check,
40
+ "model_ready": lambda: self._model_ready_flag,
41
+ "redis_alive": self._redis_alive,
42
+ }
43
+
44
+ def _redis_alive(self) -> bool:
45
+ if self.redis_client is None:
46
+ return False
47
+ return self.redis_client.is_alive()
48
+
49
+ def _kafka_check(self) -> bool:
50
+ if self.kafka_client is None:
51
+ return False
52
+ return self.kafka_client.is_alive()
53
+
54
+ def set_model_ready_flag(self, flag: bool) -> None:
55
+ self._model_ready_flag = flag
56
+ ```
57
+
58
+ Now you can prepare your monitoring server by running in __main__:
59
+
60
+ ```python
61
+ monitoring_context = MonitoringContext()
62
+ monitoring_config = MonitoringServerConfig(
63
+ monitoring_context.prepare_readiness_checks()
64
+ )
65
+ monitoring_server = MonitoringServer(
66
+ host="0.0.0.0",
67
+ port=8000,
68
+ config=monitoring_config,
69
+ )
70
+ monitoring_server.start_monitoring_server()
71
+ ```
@@ -0,0 +1,3 @@
1
+ from .monitoring_server import EndpointFilter, MonitoringServer, MonitoringServerConfig
2
+
3
+ __all__ = ["MonitoringServer", "MonitoringServerConfig", "EndpointFilter"]
@@ -0,0 +1,112 @@
1
+ import json
2
+ import logging
3
+ import logging.config
4
+ import threading
5
+ from collections.abc import Callable
6
+
7
+ import prometheus_client
8
+ import uvicorn
9
+ from fastapi import FastAPI, Response
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic.dataclasses import dataclass
12
+
13
+
14
+ def _create_app() -> FastAPI:
15
+ app = FastAPI()
16
+ app.add_middleware(
17
+ CORSMiddleware,
18
+ allow_origins=["*"],
19
+ allow_methods=["*"],
20
+ allow_headers=["*"],
21
+ )
22
+ return app
23
+
24
+
25
+ def _run_monitoring_server(host: str, port: int, app: FastAPI) -> None:
26
+ uvicorn.run(app, host=host, port=port)
27
+
28
+
29
+ @dataclass
30
+ class MonitoringServerConfig:
31
+ readiness_checks: dict[str, Callable[[], bool]]
32
+ liveness_checks: dict[str, Callable[[], bool]] | None = None
33
+
34
+
35
+ class EndpointFilter(logging.Filter):
36
+ def __init__(self, paths_excluded_for_logging: list[str]):
37
+ super().__init__()
38
+ self.paths_excluded_for_logging = paths_excluded_for_logging
39
+
40
+ def filter(self, record: logging.LogRecord) -> bool:
41
+ return not any(
42
+ path in record.getMessage() for path in self.paths_excluded_for_logging
43
+ )
44
+
45
+
46
+ class MonitoringServer:
47
+ PATHS_EXCLUDED_FOR_LOGGING = ["/healthz/readiness", "/healthz/liveness"]
48
+
49
+ def __init__(
50
+ self,
51
+ host: str,
52
+ port: int,
53
+ config: MonitoringServerConfig,
54
+ ):
55
+ self.config = config
56
+ self.host = host
57
+ self.port = port
58
+ logging.getLogger("uvicorn.access").addFilter(
59
+ EndpointFilter(self.PATHS_EXCLUDED_FOR_LOGGING)
60
+ )
61
+
62
+ def add_paths(self, app: FastAPI) -> None:
63
+ @app.get("/metrics")
64
+ async def get_metrics() -> Response:
65
+ return Response(
66
+ content=prometheus_client.generate_latest(),
67
+ media_type=prometheus_client.CONTENT_TYPE_LATEST,
68
+ )
69
+
70
+ @app.get("/healthz/liveness")
71
+ async def get_healthz_liveness() -> Response:
72
+ return await self._get_healthz_response(self.config.liveness_checks)
73
+
74
+ @app.get("/healthz/readiness")
75
+ async def get_healthz_readiness() -> Response:
76
+ return await self._get_healthz_response(self.config.readiness_checks)
77
+
78
+ async def _get_healthz_response(
79
+ self, checks: dict[str, Callable[[], bool]] | None
80
+ ) -> Response:
81
+ try:
82
+ results = (
83
+ {check_name: check_fn() for check_name, check_fn in checks.items()}
84
+ if checks
85
+ else {}
86
+ )
87
+
88
+ status = "error"
89
+ status_code = 503
90
+ if all(results.values()):
91
+ status = "ok"
92
+ status_code = 200
93
+
94
+ data = {"status": status, "checks": results}
95
+ data_json = json.dumps(data)
96
+ return Response(content=data_json, status_code=status_code)
97
+
98
+ except Exception as e:
99
+ data = {"status": "error", "message": str(e)}
100
+ data_json = json.dumps(data)
101
+ return Response(content=data_json, status_code=503)
102
+
103
+ def start_monitoring_server(self) -> None:
104
+ local_app = _create_app()
105
+ self.add_paths(local_app)
106
+ server_thread = threading.Thread(
107
+ target=_run_monitoring_server,
108
+ args=(self.host, self.port, local_app),
109
+ )
110
+ server_thread.daemon = True # to ensure it dies with the main thread
111
+ server_thread.start()
112
+ logging.info("Monitoring server listening at %s:%s.", self.host, self.port)
@@ -0,0 +1,162 @@
1
+ # pylint: disable=redefined-outer-name, unreachable
2
+ import logging
3
+ from collections.abc import Awaitable, Callable
4
+
5
+ import pytest
6
+ from fastapi import FastAPI
7
+ from httpx import ASGITransport, AsyncClient
8
+
9
+ from cledar.monitoring import EndpointFilter, MonitoringServer, MonitoringServerConfig
10
+
11
+ # ------------------------------------------------------------------------------
12
+ # Fixtures
13
+ # ------------------------------------------------------------------------------
14
+
15
+
16
+ @pytest.fixture
17
+ def test_app() -> FastAPI:
18
+ """Creates a FastAPI app with configured monitoring endpoints."""
19
+ app = FastAPI()
20
+ config = MonitoringServerConfig(
21
+ readiness_checks={"db": lambda: True},
22
+ liveness_checks={"heartbeat": lambda: True},
23
+ )
24
+ server = MonitoringServer("127.0.0.1", 8000, config)
25
+ server.add_paths(app)
26
+ return app
27
+
28
+
29
+ ASGIClientFactory = Callable[[FastAPI], Awaitable[AsyncClient]]
30
+
31
+
32
+ @pytest.fixture
33
+ def asgi_client_factory() -> ASGIClientFactory:
34
+ """Factory to create an httpx.AsyncClient for ASGI apps (httpx>=0.28)."""
35
+
36
+ async def _make_client(app: FastAPI) -> AsyncClient:
37
+ transport = ASGITransport(app=app)
38
+ return AsyncClient(transport=transport, base_url="http://test")
39
+
40
+ return _make_client
41
+
42
+
43
+ # ------------------------------------------------------------------------------
44
+ # Readiness & Liveness Endpoints
45
+ # ------------------------------------------------------------------------------
46
+
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_readiness_ok(
50
+ test_app: FastAPI, asgi_client_factory: ASGIClientFactory
51
+ ) -> None:
52
+ """Readiness endpoint returns OK when all checks pass."""
53
+ async with await asgi_client_factory(test_app) as client:
54
+ response = await client.get("/healthz/readiness")
55
+
56
+ data = response.json()
57
+ assert response.status_code == 200
58
+ assert data["status"] == "ok"
59
+ assert data["checks"]["db"] is True
60
+
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_liveness_ok(
64
+ test_app: FastAPI, asgi_client_factory: ASGIClientFactory
65
+ ) -> None:
66
+ """Liveness endpoint returns OK when all checks pass."""
67
+ async with await asgi_client_factory(test_app) as client:
68
+ response = await client.get("/healthz/liveness")
69
+
70
+ data = response.json()
71
+ assert response.status_code == 200
72
+ assert data["status"] == "ok"
73
+ assert data["checks"]["heartbeat"] is True
74
+
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_readiness_failing_check(asgi_client_factory: ASGIClientFactory) -> None:
78
+ """Readiness endpoint returns 503 and error status if a check fails."""
79
+ app = FastAPI()
80
+ config = MonitoringServerConfig(readiness_checks={"db": lambda: False})
81
+ server = MonitoringServer("127.0.0.1", 8000, config)
82
+ server.add_paths(app)
83
+
84
+ async with await asgi_client_factory(app) as client:
85
+ response = await client.get("/healthz/readiness")
86
+
87
+ data = response.json()
88
+ assert response.status_code == 503
89
+ assert data["status"] == "error"
90
+ assert data["checks"]["db"] is False
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_readiness_with_exception(asgi_client_factory: ASGIClientFactory) -> None:
95
+ """Readiness returns 503 if a check raises an exception."""
96
+
97
+ def failing_check() -> bool:
98
+ raise RuntimeError("DB not reachable")
99
+ return False
100
+
101
+ app = FastAPI()
102
+ config = MonitoringServerConfig(readiness_checks={"db": failing_check})
103
+ server = MonitoringServer("127.0.0.1", 8000, config)
104
+ server.add_paths(app)
105
+
106
+ async with await asgi_client_factory(app) as client:
107
+ response = await client.get("/healthz/readiness")
108
+
109
+ data = response.json()
110
+ assert response.status_code == 503
111
+ assert data["status"] == "error"
112
+ assert "DB not reachable" in data["message"]
113
+
114
+
115
+ # ------------------------------------------------------------------------------
116
+ # Metrics Endpoint
117
+ # ------------------------------------------------------------------------------
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_metrics_endpoint_returns_prometheus_format(
122
+ test_app: FastAPI, asgi_client_factory: ASGIClientFactory
123
+ ) -> None:
124
+ """Metrics endpoint should return valid Prometheus output."""
125
+ async with await asgi_client_factory(test_app) as client:
126
+ response = await client.get("/metrics")
127
+
128
+ assert response.status_code == 200
129
+ assert response.headers["content-type"].startswith("text/plain")
130
+ assert "python_info" in response.text # standard metric
131
+
132
+
133
+ # ------------------------------------------------------------------------------
134
+ # Logging Filter
135
+ # ------------------------------------------------------------------------------
136
+
137
+
138
+ def test_endpoint_filter_excludes_health_paths() -> None:
139
+ """EndpointFilter should exclude healthz paths from logs."""
140
+ filter_ = EndpointFilter(["/healthz/readiness", "/healthz/liveness"])
141
+
142
+ record_excluded = logging.LogRecord(
143
+ name="uvicorn.access",
144
+ level=logging.INFO,
145
+ pathname="",
146
+ lineno=0,
147
+ msg="GET /healthz/readiness 200 OK",
148
+ args=(),
149
+ exc_info=None,
150
+ )
151
+ assert filter_.filter(record_excluded) is False
152
+
153
+ record_included = logging.LogRecord(
154
+ name="uvicorn.access",
155
+ level=logging.INFO,
156
+ pathname="",
157
+ lineno=0,
158
+ msg="GET /metrics 200 OK",
159
+ args=(),
160
+ exc_info=None,
161
+ )
162
+ assert filter_.filter(record_included) is True