fairagro-middleware-shared 8.6.2__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.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: fairagro-middleware-shared
3
+ Version: 8.6.2
4
+ Summary: The FAIRagro advanced middleware shared components
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: opentelemetry-api>=1.26.0
7
+ Requires-Dist: opentelemetry-exporter-otlp>=1.26.0
8
+ Requires-Dist: opentelemetry-instrumentation-logging>=0.47b0
9
+ Requires-Dist: opentelemetry-sdk>=1.26.0
10
+ Requires-Dist: pydantic>=2.12.5
11
+ Requires-Dist: pyyaml>=6.0.3
12
+ Description-Content-Type: text/markdown
13
+
14
+ # FAIRagro Advanced Middleware - Shared Components
15
+
16
+ This package contains shared utilities and components used across the FAIRagro Advanced Middleware system.
17
+
18
+ ## Overview
19
+
20
+ The `shared` package provides:
21
+
22
+ - **Configuration Management**: Base classes and utilities for configuration handling
23
+ - **Common Models**: Pydantic models used across multiple middleware components
24
+ - **Utilities**: Helper functions and classes for common operations
25
+
26
+ ## Components
27
+
28
+ ### Configuration (`middleware.shared.config`)
29
+
30
+ Configuration utilities including:
31
+
32
+ - `ConfigWrapper`: Base class for configuration management
33
+ - Environment variable handling
34
+ - Configuration validation with Pydantic
35
+
36
+ ### Models
37
+
38
+ Shared Pydantic models for data validation and serialization across the middleware.
39
+
40
+ ## Usage
41
+
42
+ This package is used as a dependency by other middleware components:
43
+
44
+ - `api`: The main REST API
45
+ - `api_client`: Client library for API interaction
46
+ - `inspire_to_arc`: INSPIRE metadata to ARC conversion
47
+
48
+ ## Dependencies
49
+
50
+ - `pydantic>=2.12.4`: Data validation and settings management
51
+
52
+ ## Development
53
+
54
+ Install in development mode:
55
+
56
+ ```bash
57
+ uv sync --package shared
58
+ ```
59
+
60
+ Run tests:
61
+
62
+ ```bash
63
+ uv run pytest middleware/shared/tests
64
+ ```
@@ -0,0 +1,20 @@
1
+ middleware/shared/__init__.py,sha256=P1d-PbOOQXB6bJLPcR-vb66YFqze_b-VzkP-VKaRvfE,47
2
+ middleware/shared/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ middleware/shared/tracing.py,sha256=d363v8w_6nyaxefLRVZGIKjlXHihUqM5YW_m4zf03Js,6040
4
+ middleware/shared/api_models/__init__.py,sha256=fQLjOxBdZjagn-EtXNIqQJKxnXDLetKsAshUcKhayOQ,940
5
+ middleware/shared/api_models/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ middleware/shared/api_models/common/__init__.py,sha256=rDdbCPIBgQWBu9dTuW5U4U5u6FjydyGJGDR1kHMPeuc,25
7
+ middleware/shared/api_models/common/models.py,sha256=ij7lV3Xw-el7Mz4Lx8nZQlOXfHiEDJqYxjkVCcU_qAc,3115
8
+ middleware/shared/api_models/v1/__init__.py,sha256=WDN-CbCiwGIHmMBBVauRIJpMlARkkvnw2eSqGGxx3_Q,21
9
+ middleware/shared/api_models/v1/models.py,sha256=q08cUlZcJzKZugQ5Ncksjt_R_kjHVixMRzHgdDLCcBI,2712
10
+ middleware/shared/api_models/v2/__init__.py,sha256=XQCD-gAp1pa-ZablK7ARIkbyK-kmQQfZnmnGkOU_7A8,21
11
+ middleware/shared/api_models/v2/models.py,sha256=jZrshUQTk2iGO0I9pA6ZtZAoSZ1aC6M1Jad5TPtR4E0,1298
12
+ middleware/shared/api_models/v3/__init__.py,sha256=hInpKWTKW9hKvNil789NPLbHdz4pnShoATVI0ILVKZk,21
13
+ middleware/shared/api_models/v3/models.py,sha256=9PdV5Z5O5WI7gBY428fSodUTQ_1VGpq4FClLky7e2YE,3679
14
+ middleware/shared/config/__init__.py,sha256=KTiiSPDPCuOxRMsqaK-7K-yVWgA2wPMYDCdQ_wv89Cs,49
15
+ middleware/shared/config/config_base.py,sha256=DRDOUghZXfHz-gy7fhYYV20tTHBdIU0beAMoi_VR3MM,2777
16
+ middleware/shared/config/config_wrapper.py,sha256=g7C-_S8mMttQXucD8-NYKp5_7yHz0InJ8z252WiFfYk,12412
17
+ middleware/shared/config/logging.py,sha256=mTZdSLADsevWzT1ebhWs0dl749y4friSZUftAAZ0c6o,667
18
+ fairagro_middleware_shared-8.6.2.dist-info/METADATA,sha256=8rWMiNrufhbkOI3NrFIE7AxOd7rcoiXADMg9XlQZTMw,1659
19
+ fairagro_middleware_shared-8.6.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
20
+ fairagro_middleware_shared-8.6.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1 @@
1
+ """Contains same generic utility functions."""
@@ -0,0 +1,30 @@
1
+ """FAIRagro Middleware API Models package.
2
+
3
+ Backward compatibility layer for refactored shared models.
4
+ """
5
+
6
+ from .common import models as common
7
+ from .v1 import models as v1
8
+ from .v2 import models as v2
9
+
10
+ # Common / Shared
11
+ ArcStatus = common.ArcStatus
12
+ TaskStatus = common.TaskStatus
13
+ ApiResponse = common.ApiResponse
14
+ ArcResponse = common.ArcResponse # V1/V2 common
15
+
16
+ # V1 Models
17
+ LivenessResponse = v1.LivenessResponse
18
+ HealthResponse = v1.HealthResponse
19
+ CreateOrUpdateArcsRequest = v1.CreateOrUpdateArcsRequest
20
+ CreateOrUpdateArcsResponse = v1.CreateOrUpdateArcsResponse
21
+ GetTaskStatusResponse = v1.GetTaskStatusResponse
22
+ WhoamiResponse = v1.WhoamiResponse
23
+ ArcTaskTicket = v1.ArcTaskTicket
24
+
25
+ # V2 Models
26
+ HealthResponseV2 = v2.HealthResponse
27
+ CreateOrUpdateArcRequest = v2.CreateOrUpdateArcRequest
28
+ CreateOrUpdateArcResponse = v2.CreateOrUpdateArcResponse
29
+ ArcOperationResult = v2.ArcOperationResult
30
+ GetTaskStatusResponseV2 = v2.GetTaskStatusResponse
@@ -0,0 +1 @@
1
+ """Common API models."""
@@ -0,0 +1,104 @@
1
+ """Common models and enums shared across API versions."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Annotated
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class ArcStatus(StrEnum):
10
+ """Enumeration of possible ARC status values."""
11
+
12
+ CREATED = "created"
13
+ UPDATED = "updated"
14
+ DELETED = "deleted"
15
+ REQUESTED = "requested"
16
+
17
+
18
+ class TaskStatus(StrEnum):
19
+ """Enumeration of possible task states.
20
+
21
+ Values match Celery task states.
22
+ """
23
+
24
+ PENDING = "PENDING"
25
+ STARTED = "STARTED"
26
+ SUCCESS = "SUCCESS"
27
+ FAILURE = "FAILURE"
28
+ RETRY = "RETRY"
29
+ REVOKED = "REVOKED"
30
+
31
+
32
+ class ArcLifecycleStatus(StrEnum):
33
+ """ARC lifecycle status in the system."""
34
+
35
+ ACTIVE = "ACTIVE" # Normal active state
36
+ PROCESSING = "PROCESSING" # Git workflow in progress
37
+ MISSING = "MISSING" # Not seen in recent harvest
38
+ DELETED = "DELETED" # Soft-deleted (not physically removed)
39
+ INVALID = "INVALID" # Validation failed
40
+
41
+
42
+ class ArcEventType(StrEnum):
43
+ """Types of events in the ARC event log."""
44
+
45
+ # Lifecycle events
46
+ ARC_CREATED = "ARC_CREATED"
47
+ ARC_UPDATED = "ARC_UPDATED"
48
+ ARC_NOT_SEEN = "ARC_NOT_SEEN"
49
+ ARC_MARKED_MISSING = "ARC_MARKED_MISSING"
50
+ ARC_MARKED_DELETED = "ARC_MARKED_DELETED"
51
+ ARC_RESTORED = "ARC_RESTORED" # Reappeared after being marked missing/deleted
52
+ ARC_NOT_CHANGED = "ARC_NOT_CHANGED" # Explicitly tracking no-change events (if needed)
53
+
54
+ # Git workflow events
55
+ GIT_QUEUED = "GIT_QUEUED"
56
+ GIT_PROCESSING = "GIT_PROCESSING"
57
+ GIT_PUSH_SUCCESS = "GIT_PUSH_SUCCESS"
58
+ GIT_PUSH_FAILED = "GIT_PUSH_FAILED"
59
+
60
+ # Validation events
61
+ VALIDATION_WARNING = "VALIDATION_WARNING"
62
+ VALIDATION_ERROR = "VALIDATION_ERROR"
63
+ VALIDATION_SUCCESS = "VALIDATION_SUCCESS"
64
+
65
+ # Operator actions
66
+ OPERATOR_NOTE = "OPERATOR_NOTE"
67
+ MANUAL_DELETION = "MANUAL_DELETION"
68
+
69
+
70
+ class HarvestStatus(StrEnum):
71
+ """Harvest run status."""
72
+
73
+ RUNNING = "RUNNING"
74
+ COMPLETED = "COMPLETED"
75
+ FAILED = "FAILED"
76
+ CANCELLED = "CANCELLED"
77
+
78
+
79
+ class ApiResponse(BaseModel):
80
+ """Base response model for business logic operations."""
81
+
82
+ client_id: Annotated[
83
+ str | None,
84
+ Field(
85
+ description="Client identifier which is the CN from the client certificate, "
86
+ "or 'unknown' if client certificates are not required",
87
+ ),
88
+ ] = None
89
+ message: Annotated[str, Field(description="Response message")] = ""
90
+
91
+
92
+ class ArcResponse(BaseModel):
93
+ """Response model for individual ARC operations."""
94
+
95
+ id: Annotated[str, Field(description="ARC identifier, as hashed value of the original identifier and RDI")]
96
+ status: Annotated[ArcStatus, Field(description="Status of the ARC operation")]
97
+ timestamp: Annotated[str, Field(description="Timestamp of the ARC operation in ISO 8601 format")]
98
+
99
+
100
+ class ArcOperationResult(ApiResponse):
101
+ """Response model for the actual result of a single ARC operation."""
102
+
103
+ rdi: Annotated[str, Field(description="Research Data Infrastructure identifier the ARC belongs to")]
104
+ arc: Annotated[ArcResponse, Field(description="ARC response for the operation")]
File without changes
@@ -0,0 +1 @@
1
+ """V1 API models."""
@@ -0,0 +1,74 @@
1
+ """V1 API Models."""
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from ..common.models import ApiResponse, ArcResponse
8
+
9
+
10
+ class LivenessResponse(BaseModel):
11
+ """Response model for liveness check."""
12
+
13
+ message: Annotated[str, Field(description="Liveness message")] = "ok"
14
+
15
+
16
+ class HealthResponse(BaseModel):
17
+ """Response model for health check including backend status."""
18
+
19
+ status: Annotated[str, Field(description="Overall service status (ok/error)")] = "ok"
20
+ redis_reachable: Annotated[
21
+ bool,
22
+ Field(
23
+ description="[DEPRECATED] Kept for backward compatibility. Always True as Redis is no longer used.",
24
+ deprecated=True,
25
+ ),
26
+ ] = True
27
+ rabbitmq_reachable: Annotated[bool, Field(description="True if RabbitMQ is reachable")]
28
+
29
+
30
+ class WhoamiResponse(ApiResponse):
31
+ """Response model for whoami operation."""
32
+
33
+ accessible_rdis: Annotated[
34
+ list[str], Field(description="List of Research Data Infrastructures the client is authorized for")
35
+ ]
36
+
37
+
38
+ class ArcTaskTicket(ApiResponse):
39
+ """Response model for a newly created async task ticket."""
40
+
41
+ rdi: Annotated[str, Field(description="Research Data Infrastructure identifier the ARC belongs to")]
42
+ task_id: Annotated[str, Field(description="Async task ID")]
43
+
44
+
45
+ class CreateOrUpdateArcsRequest(BaseModel):
46
+ """Request model for creating or updating ARCs."""
47
+
48
+ rdi: Annotated[str, Field(description="Research Data Infrastructure identifier")]
49
+ arcs: Annotated[list[dict], Field(description="List of ARC definitions in RO-Crate JSON format")]
50
+
51
+
52
+ class CreateOrUpdateArcsResponse(ApiResponse):
53
+ """Response model for create or update ARC operations (Task Ticket or Result)."""
54
+
55
+ rdi: Annotated[str | None, Field(description="Research Data Infrastructure identifier the ARCs belong to")] = None
56
+ arcs: Annotated[list[ArcResponse], Field(description="List of ARC responses for the operation")] = Field(
57
+ default_factory=list
58
+ )
59
+
60
+ # Async task fields
61
+ task_id: Annotated[str | None, Field(description="The ID of the background task processing the ARC")] = None
62
+ status: Annotated[str | None, Field(description="The status of the task submission")] = None
63
+
64
+
65
+ class GetTaskStatusResponse(BaseModel):
66
+ """Response model for task status."""
67
+
68
+ task_id: Annotated[str, Field(description="The ID of the background task")]
69
+ status: Annotated[str, Field(description="The status of the task")]
70
+ result: Annotated[
71
+ CreateOrUpdateArcsResponse | None,
72
+ Field(description="The result of the task if completed"),
73
+ ] = None
74
+ error: Annotated[str | None, Field(description="Error message if task failed")] = None
@@ -0,0 +1 @@
1
+ """V2 API models."""
@@ -0,0 +1,38 @@
1
+ """V2 API Models."""
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from ..common.models import ApiResponse, ArcOperationResult, TaskStatus
8
+
9
+
10
+ class HealthResponse(BaseModel):
11
+ """Response model for health check v2."""
12
+
13
+ status: Annotated[str, Field(description="Overall service status (ok/error)")] = "ok"
14
+ services: Annotated[dict[str, bool], Field(description="Dictionary of service statuses")]
15
+
16
+
17
+ class CreateOrUpdateArcRequest(BaseModel):
18
+ """Request model for creating or updating a single ARC."""
19
+
20
+ rdi: Annotated[str, Field(description="Research Data Infrastructure identifier")]
21
+ arc: Annotated[dict, Field(description="ARC definition in RO-Crate JSON format")]
22
+
23
+
24
+ class CreateOrUpdateArcResponse(ApiResponse):
25
+ """Response model for create or update a single ARC operation ticket."""
26
+
27
+ task_id: Annotated[str, Field(description="The ID of the background task")]
28
+ status: Annotated[TaskStatus, Field(description="The status of the task")]
29
+
30
+
31
+ class GetTaskStatusResponse(ApiResponse):
32
+ """Response model for task status."""
33
+
34
+ status: Annotated[TaskStatus, Field(description="The status of the task")]
35
+ result: Annotated[
36
+ ArcOperationResult | None,
37
+ Field(description="The result of the task if completed"),
38
+ ] = None
@@ -0,0 +1 @@
1
+ """V3 API models."""
@@ -0,0 +1,106 @@
1
+ """V3 API Models."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Annotated
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ..common.models import ApiResponse, ArcLifecycleStatus, ArcStatus, HarvestStatus
9
+
10
+
11
+ class CreateArcRequest(BaseModel):
12
+ """Request model for creating or updating a single ARC."""
13
+
14
+ rdi: Annotated[str, Field(description="Research Data Infrastructure identifier")]
15
+ arc: Annotated[dict, Field(description="ARC definition in RO-Crate JSON format")]
16
+
17
+
18
+ class BaseStatusResponse(BaseModel):
19
+ """Base response model for status-only API responses."""
20
+
21
+ status: Annotated["StatusResponse", Field(description="Overall service status")]
22
+ services: Annotated[dict[str, bool], Field(description="Dictionary of service checks")]
23
+
24
+
25
+ class StatusResponse(StrEnum):
26
+ """Allowed status values for liveness/readiness/health responses."""
27
+
28
+ OK = "ok"
29
+ ERROR = "error"
30
+
31
+
32
+ class LivenessResponse(BaseStatusResponse):
33
+ """Response model for liveness checks."""
34
+
35
+
36
+ class ReadinessResponse(BaseStatusResponse):
37
+ """Response model for readiness checks."""
38
+
39
+
40
+ class HealthResponse(BaseStatusResponse):
41
+ """Response model for global health checks."""
42
+
43
+
44
+ class SubmitHarvestArcRequest(BaseModel):
45
+ """Request model for submitting an ARC within an ongoing harvest run.
46
+
47
+ The ``rdi`` is not required here — it is resolved automatically from the
48
+ harvest identified by the ``harvest_id`` path parameter.
49
+ """
50
+
51
+ arc: Annotated[
52
+ dict,
53
+ Field(
54
+ description=(
55
+ "ARC definition in RO-Crate JSON format. "
56
+ "The RDI is taken from the harvest run identified by the path parameter."
57
+ )
58
+ ),
59
+ ]
60
+
61
+
62
+ class ArcEventSummary(BaseModel):
63
+ """Summary of an ARC event."""
64
+
65
+ timestamp: Annotated[str, Field(description="Timestamp of the event")]
66
+ type: Annotated[str, Field(description="Type of the event")]
67
+ message: Annotated[str, Field(description="Event message")]
68
+
69
+
70
+ class ArcMetadata(BaseModel):
71
+ """Metadata summary."""
72
+
73
+ arc_hash: Annotated[str, Field(description="SHA256 hash of ARC content")]
74
+ status: Annotated[ArcLifecycleStatus, Field(description="Current lifecycle status")]
75
+ first_seen: Annotated[str, Field(description="First time ARC was seen")]
76
+ last_seen: Annotated[str, Field(description="Last time ARC was seen")]
77
+
78
+
79
+ class ArcResponse(ApiResponse):
80
+ """Result of an ARC operation, containing full details."""
81
+
82
+ arc_id: Annotated[str, Field(description="ARC identifier")]
83
+ status: Annotated[ArcStatus, Field(description="Status of the ARC operation")]
84
+ metadata: Annotated[ArcMetadata, Field(description="Summary metadata")]
85
+ events: Annotated[list[ArcEventSummary], Field(description="Summary event log")] = Field(default_factory=list)
86
+
87
+
88
+ class CreateHarvestRequest(BaseModel):
89
+ """Request model for starting a new harvest."""
90
+
91
+ rdi: Annotated[str, Field(description="Research Data Infrastructure identifier")]
92
+ expected_datasets: Annotated[
93
+ int | None,
94
+ Field(description="Optional number of datasets expected to be harvested, as reported by the client."),
95
+ ] = None
96
+
97
+
98
+ class HarvestResponse(ApiResponse):
99
+ """Response model for harvest details."""
100
+
101
+ harvest_id: Annotated[str, Field(description="Unique harvest identifier")]
102
+ rdi: Annotated[str, Field(description="RDI identifier")]
103
+ status: Annotated[HarvestStatus, Field(description="Current status")]
104
+ started_at: Annotated[str, Field(description="Start timestamp")]
105
+ completed_at: Annotated[str | None, Field(description="Completion timestamp")] = None
106
+ statistics: Annotated[dict, Field(description="Harvest statistics")]
@@ -0,0 +1 @@
1
+ """FAIRware Middleware shared config package."""
@@ -0,0 +1,96 @@
1
+ """FAIRagro Middleware base configuration module."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Annotated, Any, Literal, Self, cast
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+ from .config_wrapper import ConfigWrapper
10
+
11
+ LogLevel = Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"]
12
+
13
+
14
+ class OtelConfig(BaseModel):
15
+ """OpenTelemetry logging and tracing configuration."""
16
+
17
+ endpoint: Annotated[
18
+ str | None,
19
+ Field(
20
+ description="OpenTelemetry collector endpoint URL",
21
+ examples=["http://signoz:4318"],
22
+ ),
23
+ ] = None
24
+ log_console_spans: Annotated[
25
+ bool,
26
+ Field(description="Log OpenTelemetry spans to console"),
27
+ ] = False
28
+ log_level: Annotated[
29
+ LogLevel,
30
+ Field(description="Logging level for OTLP log export"),
31
+ ] = "INFO"
32
+
33
+
34
+ class ConfigBase(BaseModel):
35
+ """Configuration base class for the FAIRagro advanced Middleware."""
36
+
37
+ log_level: Annotated[LogLevel, Field(description="Logging level for console/stdout logging")] = "INFO"
38
+ otel: OtelConfig = Field(default_factory=OtelConfig, description="OpenTelemetry configuration")
39
+
40
+ @field_validator("otel", mode="before")
41
+ @classmethod
42
+ def validate_otel(cls, v: Any) -> Any:
43
+ """Allow None for otel and convert to empty dict for default factory."""
44
+ if v is None:
45
+ return {}
46
+ return v
47
+
48
+ @classmethod
49
+ def from_config_wrapper(cls, wrapper: ConfigWrapper) -> Self:
50
+ """Create Config from ConfigWrapper.
51
+
52
+ Args:
53
+ wrapper (ConfigWrapper): Wrapped configuration data.
54
+
55
+ Returns:
56
+ Self: Configuration instance.
57
+
58
+ """
59
+ unwrapped = wrapper.unwrap()
60
+ # Cast to satisfy MyPy's warn_return_any=true setting
61
+ return cast(Self, cls.model_validate(unwrapped))
62
+
63
+ @classmethod
64
+ def from_data(cls, data: dict) -> Self:
65
+ """Create Config from raw data dictionary.
66
+
67
+ Args:
68
+ data (dict): Raw configuration data.
69
+
70
+ Returns:
71
+ Self: Configuration instance.
72
+
73
+ """
74
+ wrapper = ConfigWrapper.from_data(data)
75
+ return cls.from_config_wrapper(wrapper)
76
+
77
+ @classmethod
78
+ def from_yaml_file(cls, path: Path) -> Self:
79
+ """Create Config from a YAML file.
80
+
81
+ Args:
82
+ path (Path): Path to the YAML config file.
83
+
84
+ Returns:
85
+ Config: Configuration instance.
86
+
87
+ Raises:
88
+ RuntimeError: If the config file is not found.
89
+
90
+ """
91
+ if path.is_file():
92
+ wrapper = ConfigWrapper.from_yaml_file(path)
93
+ return cls.from_config_wrapper(wrapper)
94
+ msg = f"Config file {path} not found."
95
+ logging.error(msg)
96
+ raise RuntimeError(msg)
@@ -0,0 +1,372 @@
1
+ """Defines the ConfigWrapper class that wraps a yaml file and supports.
2
+
3
+ Overriding single entries in the yaml tree by env vars or docker
4
+ secret files in /run/secrets.
5
+ """
6
+
7
+ import os
8
+ from abc import abstractmethod
9
+ from collections.abc import Generator
10
+ from pathlib import Path
11
+ from typing import cast
12
+
13
+ import yaml
14
+
15
+ type KeyType = str | int
16
+ type DictType = dict[str, "ValueType"]
17
+ type ListType = list["ValueType"]
18
+ type PrimitiveType = str | int | float | bool | None
19
+ type ValueType = DictType | ListType | PrimitiveType
20
+ type WrapType = "ConfigWrapper | PrimitiveType"
21
+
22
+
23
+ class ConfigWrapper:
24
+ """Wraps nested dicts and lists (aka loaded yaml).
25
+
26
+ Supports Env/Docker-Secret-Overrides.
27
+ """
28
+
29
+ def __init__(self, path: str = "") -> None:
30
+ """Initialize a ConfigWrapper with an optional path prefix.
31
+
32
+ Args:
33
+ path: The path prefix used for environment variable and secret lookups.
34
+
35
+ """
36
+ self._path = path.upper()
37
+
38
+ def _build_path(self, key: str) -> str:
39
+ return f"{self._path}_{key}" if self._path else key
40
+
41
+ def _wrap(self, value: "ValueType | None", key: str) -> WrapType:
42
+ return ConfigWrapper._from_value(value, self._build_path(key))
43
+
44
+ @staticmethod
45
+ def _from_value(value: "ValueType | None", path: str) -> WrapType:
46
+ if isinstance(value, dict):
47
+ return ConfigWrapperDict(value, path)
48
+ if isinstance(value, list):
49
+ return ConfigWrapperList(value, path)
50
+ return value
51
+
52
+ @classmethod
53
+ def from_data(cls, data: DictType | ListType, prefix: str = "") -> "ConfigWrapper":
54
+ """Create a ConfigWrapper from a dictionary or list.
55
+
56
+ Args:
57
+ data: The dictionary or list to wrap.
58
+ prefix: Optional prefix for environment variable and secret lookups.
59
+
60
+ Returns:
61
+ A new ConfigWrapper instance wrapping the provided data.
62
+
63
+ Raises:
64
+ TypeError: If data is neither a dictionary nor a list.
65
+
66
+ """
67
+ wrapped = cls._from_value(data, prefix)
68
+ if not isinstance(wrapped, ConfigWrapper):
69
+ raise TypeError(f"'ConfigWrapper' only wraps lists or dicts. You're trying to wrap a '{type(data)}'")
70
+ return wrapped
71
+
72
+ @classmethod
73
+ def from_yaml_file(cls, path: Path, prefix: str = "") -> "ConfigWrapper":
74
+ """Create a ConfigWrapper from a yaml file."""
75
+ with open(path, encoding="utf-8") as f:
76
+ data = yaml.safe_load(f) or {}
77
+ return cls.from_data(data, prefix)
78
+
79
+ @staticmethod
80
+ def _get_path_str(value: "ValueType | None", key: KeyType) -> str:
81
+ if isinstance(value, dict) and "id" in value:
82
+ return cast(str, value["id"])
83
+ return str(key)
84
+
85
+ @abstractmethod
86
+ def __getitem__(self, key: KeyType) -> WrapType:
87
+ """Return the value for the given key from the configuration.
88
+
89
+ Args:
90
+ key: The key to lookup in the configuration.
91
+
92
+ Returns:
93
+ The value associated with the key, wrapped if necessary.
94
+
95
+ Raises:
96
+ NotImplementedError: When called on the base class.
97
+
98
+ """
99
+ raise NotImplementedError("Please do not use class 'ConfigWrapper' directly, but a derived class")
100
+
101
+ def get(self, key: KeyType, default_value: "ValueType | None" = None) -> WrapType:
102
+ """Return the value of a config key.
103
+
104
+ If the value is a dict or list, it's again wrapped into to ConfigWrapper object.
105
+ """
106
+ try:
107
+ return self[key]
108
+ except KeyError:
109
+ key_str = ConfigWrapper._get_path_str(default_value, key)
110
+ return self._wrap(default_value, key_str)
111
+
112
+ @classmethod
113
+ def _unwrap(cls, wrapper: WrapType) -> ValueType:
114
+ if isinstance(wrapper, ConfigWrapperDict):
115
+ return {k: cls._unwrap(v) for k, v in wrapper.items()}
116
+ if isinstance(wrapper, ConfigWrapperList):
117
+ return [cls._unwrap(v) for _, v in wrapper.items()]
118
+ if isinstance(wrapper, (str, int, float, bool, type(None))):
119
+ return wrapper
120
+ raise TypeError(f"Cannot unwrap element of type '{type(wrapper)}'")
121
+
122
+ def unwrap(self) -> DictType | ListType:
123
+ """Convert the wrapped configuration back to a plain dictionary or list.
124
+
125
+ Returns:
126
+ The unwrapped configuration as a dictionary or list.
127
+
128
+ Raises:
129
+ TypeError: If the unwrapped value is not a dictionary or list.
130
+
131
+ """
132
+ unwrapped = ConfigWrapper._unwrap(self)
133
+ if isinstance(unwrapped, dict | list):
134
+ return unwrapped
135
+ raise TypeError(f"Unwrapped values must be of type list or dict, found '{type(unwrapped)}'")
136
+
137
+ @abstractmethod
138
+ def __iter__(self) -> Generator[KeyType, None, None]:
139
+ """Return an iterator over the configuration keys.
140
+
141
+ Returns:
142
+ A generator yielding keys of the configuration.
143
+
144
+ """
145
+ raise NotImplementedError("Please do not use class 'ConfigWrapper' directly, but a derived class")
146
+
147
+ @abstractmethod
148
+ def items(self) -> Generator[tuple[KeyType, WrapType], None, None]:
149
+ """Return an iterator over the configuration key-value pairs.
150
+
151
+ Returns:
152
+ A generator yielding tuples of (key, value) pairs from the configuration.
153
+
154
+ """
155
+ raise NotImplementedError("Please do not use class 'ConfigWrapper' directly, but a derived class")
156
+
157
+ @abstractmethod
158
+ def __len__(self) -> int:
159
+ """Return the number of items in the configuration.
160
+
161
+ Returns:
162
+ The number of items in the configuration.
163
+
164
+ """
165
+ raise NotImplementedError("Please do not use class 'ConfigWrapper' directly, but a derived class")
166
+
167
+ def _override_key_access(self, key: str) -> PrimitiveType:
168
+ """Get override value for a key from environment or Docker secrets.
169
+
170
+ Attempts to parse the override value as a primitive type (bool, int, float).
171
+ Supports the following formats:
172
+ - "true", "false" (case-insensitive) -> bool
173
+ - Numeric strings -> int or float
174
+ - Other strings -> str
175
+ - Empty string defaults to None
176
+
177
+ Args:
178
+ key: The configuration key to lookup.
179
+
180
+ Returns:
181
+ The override value as a primitive type, or None if not found.
182
+ """
183
+ # self._path should alwys be upper case
184
+ full_key = self._build_path(key.upper())
185
+
186
+ override_value = None
187
+
188
+ # 1️⃣ Check ENV
189
+ if full_key in os.environ:
190
+ override_value = os.environ[full_key]
191
+
192
+ # 2️⃣ Check Docker secret file
193
+ if override_value is None:
194
+ secret_file = Path(f"/run/secrets/{full_key.lower()}")
195
+ if secret_file.exists():
196
+ override_value = secret_file.read_text(encoding="utf-8").strip()
197
+
198
+ if override_value is None:
199
+ return None
200
+
201
+ # Parse the override value to appropriate primitive type
202
+ return self._parse_primitive_value(override_value)
203
+
204
+ @staticmethod
205
+ def _parse_primitive_value(value: str) -> PrimitiveType:
206
+ """Parse a string value into its appropriate primitive type.
207
+
208
+ Conversion rules (in order):
209
+ 1. Empty string -> None
210
+ 2. "true"/"false" (case-insensitive) -> bool
211
+ 3. Integer-like strings -> int
212
+ 4. Float-like strings -> float
213
+ 5. Everything else -> str
214
+
215
+ Args:
216
+ value: The string value to parse.
217
+
218
+ Returns:
219
+ The parsed primitive value.
220
+ """
221
+ if not value:
222
+ return None
223
+
224
+ # Try bool
225
+ if value.lower() in {"true", "false"}:
226
+ return value.lower() == "true"
227
+
228
+ # Try int
229
+ try:
230
+ return int(value)
231
+ except ValueError:
232
+ pass
233
+
234
+ # Try float
235
+ try:
236
+ return float(value)
237
+ except ValueError:
238
+ pass
239
+
240
+ # Default to string
241
+ return value
242
+
243
+
244
+ class ConfigWrapperDict(ConfigWrapper):
245
+ """A ConfigWrapper flavor that specifically wraps dicts."""
246
+
247
+ def __init__(self, data: DictType, path: str = "") -> None:
248
+ """Initialize a ConfigWrapperDict with dictionary data and an path prefix.
249
+
250
+ Args:
251
+ data: The dictionary to wrap.
252
+ path: The path prefix used for environment variable and secret lookups.
253
+
254
+ """
255
+ super().__init__(path)
256
+ self._data = data
257
+
258
+ def _all_keys(self) -> set[str]:
259
+ """All keys including discovered ENV/Secrets."""
260
+ keys = set(self._data.keys())
261
+ prefix = f"{self._path}_" if self._path else ""
262
+
263
+ # Only discover new keys from environment if we are within a sub-path
264
+ # Root level keys must be present in the YAML to be overridden
265
+ if prefix:
266
+ for env_key in os.environ:
267
+ if env_key.startswith(prefix):
268
+ key_suffix = env_key[len(prefix) :]
269
+ keys.add(key_suffix.lower())
270
+
271
+ secrets_dir = Path("/run/secrets")
272
+ path_lower = self._path.lower()
273
+ prefix_lower = f"{path_lower}_" if path_lower else ""
274
+ if secrets_dir.exists() and prefix_lower:
275
+ for secret_file in secrets_dir.iterdir():
276
+ if secret_file.name.startswith(prefix_lower):
277
+ key_suffix = secret_file.name[len(prefix_lower) :]
278
+ keys.add(key_suffix.lower())
279
+ return keys
280
+
281
+ def __getitem__(self, key: KeyType) -> WrapType:
282
+ """Return the value for the given key from the configuration.
283
+
284
+ Args:
285
+ key: The key to lookup in the configuration.
286
+
287
+ Returns:
288
+ WrapType: The value associated with the key, wrapped if necessary.
289
+
290
+ """
291
+ if not isinstance(key, str):
292
+ raise TypeError(f"ConfigWrapperDict only supports string keys, got {type(key)}")
293
+
294
+ override_value = self._override_key_access(key)
295
+ if override_value is not None:
296
+ return override_value
297
+ value = self._data[key]
298
+ return super()._wrap(value, key)
299
+
300
+ def __iter__(self) -> Generator[str, None, None]:
301
+ """Iterate over dict keys."""
302
+ yield from self._all_keys()
303
+
304
+ def items(self) -> Generator[tuple[str, WrapType], None, None]:
305
+ """Iterate over key-value pairs."""
306
+ for key in self._all_keys():
307
+ yield key, self[key]
308
+
309
+ def __len__(self) -> int:
310
+ """Return the number of keys in the configuration dictionary.
311
+
312
+ Returns:
313
+ The total count of configuration keys including environment and secret
314
+ overrides.
315
+
316
+ """
317
+ return len(self._all_keys())
318
+
319
+
320
+ class ConfigWrapperList(ConfigWrapper):
321
+ """A ConfigWrapper flavour that specifically wraps lists."""
322
+
323
+ def __init__(self, data: ListType, path: str = "") -> None:
324
+ """Initialize a ConfigWrapperList with list data and a path prefix.
325
+
326
+ Args:
327
+ data: The list to wrap.
328
+ path: The path prefix used for environment variable and secret lookups.
329
+
330
+ """
331
+ super().__init__(path)
332
+ self._data = data
333
+
334
+ def __getitem__(self, key: KeyType) -> WrapType:
335
+ """Return the value at the specified index in the list.
336
+
337
+ Args:
338
+ key: The index to lookup in the list.
339
+
340
+ Returns:
341
+ The value at the specified index, wrapped if necessary.
342
+
343
+ """
344
+ if not isinstance(key, int):
345
+ raise TypeError(f"ConfigWrapperList only supports integer keys, got {type(key)}")
346
+
347
+ value = self._data[key]
348
+ key_str = ConfigWrapper._get_path_str(value, key) # noqa: SLF001
349
+ override_value = self._override_key_access(key_str)
350
+ if override_value is not None:
351
+ return override_value
352
+ return super()._wrap(value, key_str)
353
+
354
+ def __iter__(self) -> Generator[int, None, None]:
355
+ """Iterate over list indices."""
356
+ for idx, _ in enumerate(self._data):
357
+ yield idx
358
+
359
+ def items(self) -> Generator[tuple[int, WrapType], None, None]:
360
+ """Iterate over index-value pairs."""
361
+ for idx, value in enumerate(self._data):
362
+ key_str = ConfigWrapper._get_path_str(value, idx) # noqa: SLF001
363
+ yield idx, super()._wrap(value, key_str)
364
+
365
+ def __len__(self) -> int:
366
+ """Return the number of items in the list.
367
+
368
+ Returns:
369
+ The length of the wrapped list.
370
+
371
+ """
372
+ return len(self._data)
@@ -0,0 +1,25 @@
1
+ """Logging configuration module.
2
+
3
+ This module provides functionality to configure logging levels for all handlers
4
+ and the root logger across the application.
5
+ """
6
+
7
+ import logging
8
+
9
+ from middleware.shared.config.config_base import LogLevel
10
+
11
+
12
+ def configure_logging(level: LogLevel) -> None:
13
+ """Configure logging level for all handlers.
14
+
15
+ Args:
16
+ level: Logging level to set for all handlers and root logger.
17
+ """
18
+ root = logging.getLogger()
19
+ if root.handlers:
20
+ # vorhandene Handler neu konfigurieren
21
+ for h in root.handlers:
22
+ h.setLevel(level)
23
+ root.setLevel(level)
24
+ else:
25
+ logging.basicConfig(level=level)
File without changes
@@ -0,0 +1,154 @@
1
+ """
2
+ OpenTelemetry tracing configuration for the middleware API.
3
+
4
+ This module initializes and configures OpenTelemetry for distributed tracing,
5
+ with support for FastAPI auto-instrumentation, console logging, and OTLP export to Signoz.
6
+ """
7
+
8
+ import logging
9
+ from collections.abc import Sequence
10
+ from typing import TYPE_CHECKING
11
+
12
+ from opentelemetry import trace
13
+ from opentelemetry._logs import set_logger_provider
14
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
15
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
16
+ from opentelemetry.instrumentation.logging.handler import LoggingHandler
17
+ from opentelemetry.sdk._logs import LoggerProvider
18
+ from opentelemetry.sdk._logs.export import (
19
+ BatchLogRecordProcessor,
20
+ ConsoleLogRecordExporter,
21
+ SimpleLogRecordProcessor,
22
+ )
23
+ from opentelemetry.sdk.resources import Resource
24
+ from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
25
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor, SpanExporter, SpanExportResult
26
+
27
+ if TYPE_CHECKING:
28
+ pass
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class SimpleConsoleSpanExporter(SpanExporter):
34
+ """Simple span exporter that logs to console."""
35
+
36
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: # noqa: PLR6301
37
+ """Export spans to console."""
38
+ for span in spans:
39
+ if span.end_time is not None and span.start_time is not None:
40
+ duration_ms = (span.end_time - span.start_time) / 1e6
41
+ else:
42
+ duration_ms = 0.0
43
+ logger.info(
44
+ "SPAN: %s (duration=%0.3fms)",
45
+ span.name,
46
+ duration_ms,
47
+ )
48
+ if span.attributes:
49
+ logger.info(" Attributes: %s", span.attributes)
50
+ return SpanExportResult.SUCCESS
51
+
52
+ def shutdown(self) -> None:
53
+ """Shutdown the exporter."""
54
+ pass
55
+
56
+ def force_flush(self, _timeout_millis: int = 30000) -> bool: # noqa: PLR6301
57
+ """Flush any pending spans."""
58
+ return True
59
+
60
+
61
+ def initialize_tracing(
62
+ service_name: str = "middleware-api",
63
+ otlp_endpoint: str | None = None,
64
+ log_console_spans: bool = True,
65
+ ) -> tuple[TracerProvider, trace.Tracer]:
66
+ """
67
+ Initialize OpenTelemetry tracing with console and optional OTLP exporter.
68
+
69
+ Args:
70
+ service_name: The service name for traces (default: "middleware-api")
71
+ otlp_endpoint: Optional OTLP endpoint URL (e.g. http://signoz:4318)
72
+ log_console_spans: Whether to log spans to console (default: True)
73
+
74
+ Returns:
75
+ Tuple of (TracerProvider, Tracer) for use in the application
76
+ """
77
+ # Create a resource describing this service
78
+ resource = Resource.create({
79
+ "service.name": service_name,
80
+ "service.version": "0.0.0",
81
+ })
82
+
83
+ # Create a tracer provider
84
+ tracer_provider = TracerProvider(resource=resource)
85
+
86
+ # Optionally add console exporter for development/debugging
87
+ if log_console_spans:
88
+ console_exporter = SimpleConsoleSpanExporter()
89
+ tracer_provider.add_span_processor(SimpleSpanProcessor(console_exporter))
90
+
91
+ # Optionally add OTLP exporter for Signoz/Jaeger/etc
92
+ if otlp_endpoint:
93
+ try:
94
+ otlp_exporter = OTLPSpanExporter(endpoint=f"{otlp_endpoint}/v1/traces")
95
+ tracer_provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
96
+ logger.info("OpenTelemetry OTLP exporter configured: %s", otlp_endpoint)
97
+ except (ValueError, OSError) as e:
98
+ logger.warning("Failed to configure OTLP exporter: %s", e)
99
+
100
+ # Set the global tracer provider
101
+ trace.set_tracer_provider(tracer_provider)
102
+
103
+ # Get a tracer for this module
104
+ tracer = trace.get_tracer(__name__)
105
+
106
+ logger.info(
107
+ "OpenTelemetry tracing initialized (console=%s, otlp=%s)",
108
+ log_console_spans,
109
+ bool(otlp_endpoint),
110
+ )
111
+
112
+ return tracer_provider, tracer
113
+
114
+
115
+ def initialize_logging(
116
+ service_name: str = "middleware-api",
117
+ otlp_endpoint: str | None = None,
118
+ log_console: bool = False,
119
+ log_level: int = logging.INFO,
120
+ otlp_log_level: int = logging.INFO,
121
+ ) -> LoggerProvider:
122
+ """
123
+ Initialize OpenTelemetry logging with optional OTLP exporter.
124
+
125
+ Args:
126
+ service_name: The service name for log records.
127
+ service_version: The service version for log records.
128
+ otlp_endpoint: Optional OTLP endpoint URL (e.g. http://signoz:4318).
129
+ log_console: Whether to also export logs to console via OTLP SDK exporter.
130
+ """
131
+ resource = Resource.create({"service.name": service_name, "service.version": "0.0.0"})
132
+ logger_provider = LoggerProvider(resource=resource)
133
+
134
+ if otlp_endpoint:
135
+ try:
136
+ otlp_log_exporter = OTLPLogExporter(endpoint=f"{otlp_endpoint}/v1/logs")
137
+ logger_provider.add_log_record_processor(BatchLogRecordProcessor(otlp_log_exporter))
138
+ if log_console:
139
+ logger_provider.add_log_record_processor(SimpleLogRecordProcessor(ConsoleLogRecordExporter()))
140
+ set_logger_provider(logger_provider)
141
+ root_handler = LoggingHandler(level=otlp_log_level, logger_provider=logger_provider)
142
+ root_logger = logging.getLogger()
143
+ root_logger.addHandler(root_handler)
144
+ root_logger.setLevel(min(root_logger.level or log_level, log_level))
145
+ # Prevent self-logging loops: OTEL internal logs and noisy HTTP/SSL logs at WARNING+ only
146
+ logging.getLogger("opentelemetry").setLevel(logging.WARNING)
147
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
148
+ logging.getLogger("httpx").setLevel(logging.WARNING)
149
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
150
+ logger.info("OpenTelemetry log exporter configured: %s", otlp_endpoint)
151
+ except (ValueError, OSError) as e: # pragma: no cover - defensive path
152
+ logger.warning("Failed to configure OTLP log exporter: %s", e)
153
+
154
+ return logger_provider