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.
- fairagro_middleware_shared-8.6.2.dist-info/METADATA +64 -0
- fairagro_middleware_shared-8.6.2.dist-info/RECORD +20 -0
- fairagro_middleware_shared-8.6.2.dist-info/WHEEL +4 -0
- middleware/shared/__init__.py +1 -0
- middleware/shared/api_models/__init__.py +30 -0
- middleware/shared/api_models/common/__init__.py +1 -0
- middleware/shared/api_models/common/models.py +104 -0
- middleware/shared/api_models/py.typed +0 -0
- middleware/shared/api_models/v1/__init__.py +1 -0
- middleware/shared/api_models/v1/models.py +74 -0
- middleware/shared/api_models/v2/__init__.py +1 -0
- middleware/shared/api_models/v2/models.py +38 -0
- middleware/shared/api_models/v3/__init__.py +1 -0
- middleware/shared/api_models/v3/models.py +106 -0
- middleware/shared/config/__init__.py +1 -0
- middleware/shared/config/config_base.py +96 -0
- middleware/shared/config/config_wrapper.py +372 -0
- middleware/shared/config/logging.py +25 -0
- middleware/shared/py.typed +0 -0
- middleware/shared/tracing.py +154 -0
|
@@ -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 @@
|
|
|
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
|