truthound-dashboard 1.0.0__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.
- truthound_dashboard/__init__.py +11 -0
- truthound_dashboard/__main__.py +6 -0
- truthound_dashboard/api/__init__.py +15 -0
- truthound_dashboard/api/deps.py +153 -0
- truthound_dashboard/api/drift.py +179 -0
- truthound_dashboard/api/error_handlers.py +287 -0
- truthound_dashboard/api/health.py +78 -0
- truthound_dashboard/api/history.py +62 -0
- truthound_dashboard/api/middleware.py +626 -0
- truthound_dashboard/api/notifications.py +561 -0
- truthound_dashboard/api/profile.py +52 -0
- truthound_dashboard/api/router.py +83 -0
- truthound_dashboard/api/rules.py +277 -0
- truthound_dashboard/api/schedules.py +329 -0
- truthound_dashboard/api/schemas.py +136 -0
- truthound_dashboard/api/sources.py +229 -0
- truthound_dashboard/api/validations.py +125 -0
- truthound_dashboard/cli.py +226 -0
- truthound_dashboard/config.py +132 -0
- truthound_dashboard/core/__init__.py +264 -0
- truthound_dashboard/core/base.py +185 -0
- truthound_dashboard/core/cache.py +479 -0
- truthound_dashboard/core/connections.py +331 -0
- truthound_dashboard/core/encryption.py +409 -0
- truthound_dashboard/core/exceptions.py +627 -0
- truthound_dashboard/core/logging.py +488 -0
- truthound_dashboard/core/maintenance.py +542 -0
- truthound_dashboard/core/notifications/__init__.py +56 -0
- truthound_dashboard/core/notifications/base.py +390 -0
- truthound_dashboard/core/notifications/channels.py +557 -0
- truthound_dashboard/core/notifications/dispatcher.py +453 -0
- truthound_dashboard/core/notifications/events.py +155 -0
- truthound_dashboard/core/notifications/service.py +744 -0
- truthound_dashboard/core/sampling.py +626 -0
- truthound_dashboard/core/scheduler.py +311 -0
- truthound_dashboard/core/services.py +1531 -0
- truthound_dashboard/core/truthound_adapter.py +659 -0
- truthound_dashboard/db/__init__.py +67 -0
- truthound_dashboard/db/base.py +108 -0
- truthound_dashboard/db/database.py +196 -0
- truthound_dashboard/db/models.py +732 -0
- truthound_dashboard/db/repository.py +237 -0
- truthound_dashboard/main.py +309 -0
- truthound_dashboard/schemas/__init__.py +150 -0
- truthound_dashboard/schemas/base.py +96 -0
- truthound_dashboard/schemas/drift.py +118 -0
- truthound_dashboard/schemas/history.py +74 -0
- truthound_dashboard/schemas/profile.py +91 -0
- truthound_dashboard/schemas/rule.py +199 -0
- truthound_dashboard/schemas/schedule.py +88 -0
- truthound_dashboard/schemas/schema.py +121 -0
- truthound_dashboard/schemas/source.py +138 -0
- truthound_dashboard/schemas/validation.py +192 -0
- truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
- truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
- truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
- truthound_dashboard/static/index.html +15 -0
- truthound_dashboard/static/mockServiceWorker.js +349 -0
- truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
- truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
- truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
- truthound_dashboard-1.0.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Truthound Dashboard - Open-source data quality dashboard.
|
|
2
|
+
|
|
3
|
+
A GX Cloud alternative that provides:
|
|
4
|
+
- Data source management
|
|
5
|
+
- Automated schema learning
|
|
6
|
+
- Data validation and profiling
|
|
7
|
+
- Real-time monitoring dashboard
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""API module.
|
|
2
|
+
|
|
3
|
+
This module contains all API endpoints organized by domain.
|
|
4
|
+
|
|
5
|
+
Routers:
|
|
6
|
+
- health: Health and readiness checks
|
|
7
|
+
- sources: Data source CRUD operations
|
|
8
|
+
- schemas: Schema learning and management
|
|
9
|
+
- validations: Validation execution and history
|
|
10
|
+
- profile: Data profiling
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .router import api_router
|
|
14
|
+
|
|
15
|
+
__all__ = ["api_router"]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""API dependencies for dependency injection.
|
|
2
|
+
|
|
3
|
+
This module provides FastAPI dependencies for injecting services
|
|
4
|
+
and database sessions into route handlers.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
@router.get("/sources")
|
|
8
|
+
async def list_sources(
|
|
9
|
+
service: SourceService = Depends(get_source_service)
|
|
10
|
+
):
|
|
11
|
+
return await service.list()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import AsyncGenerator
|
|
17
|
+
from typing import Annotated
|
|
18
|
+
|
|
19
|
+
from fastapi import Depends
|
|
20
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
|
+
|
|
22
|
+
from truthound_dashboard.core import (
|
|
23
|
+
DriftService,
|
|
24
|
+
HistoryService,
|
|
25
|
+
ProfileService,
|
|
26
|
+
RuleService,
|
|
27
|
+
ScheduleService,
|
|
28
|
+
SchemaService,
|
|
29
|
+
SourceService,
|
|
30
|
+
ValidationService,
|
|
31
|
+
)
|
|
32
|
+
from truthound_dashboard.db import get_db_session
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
36
|
+
"""Get database session dependency.
|
|
37
|
+
|
|
38
|
+
Yields:
|
|
39
|
+
AsyncSession for database operations.
|
|
40
|
+
"""
|
|
41
|
+
async for session in get_db_session():
|
|
42
|
+
yield session
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Type alias for session dependency
|
|
46
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def get_source_service(session: SessionDep) -> SourceService:
|
|
50
|
+
"""Get source service dependency.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
session: Database session.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
SourceService instance.
|
|
57
|
+
"""
|
|
58
|
+
return SourceService(session)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def get_validation_service(session: SessionDep) -> ValidationService:
|
|
62
|
+
"""Get validation service dependency.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
session: Database session.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
ValidationService instance.
|
|
69
|
+
"""
|
|
70
|
+
return ValidationService(session)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def get_schema_service(session: SessionDep) -> SchemaService:
|
|
74
|
+
"""Get schema service dependency.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
session: Database session.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
SchemaService instance.
|
|
81
|
+
"""
|
|
82
|
+
return SchemaService(session)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def get_profile_service(session: SessionDep) -> ProfileService:
|
|
86
|
+
"""Get profile service dependency.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
session: Database session.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
ProfileService instance.
|
|
93
|
+
"""
|
|
94
|
+
return ProfileService(session)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def get_rule_service(session: SessionDep) -> RuleService:
|
|
98
|
+
"""Get rule service dependency.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
session: Database session.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
RuleService instance.
|
|
105
|
+
"""
|
|
106
|
+
return RuleService(session)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def get_history_service(session: SessionDep) -> HistoryService:
|
|
110
|
+
"""Get history service dependency.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
session: Database session.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
HistoryService instance.
|
|
117
|
+
"""
|
|
118
|
+
return HistoryService(session)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def get_drift_service(session: SessionDep) -> DriftService:
|
|
122
|
+
"""Get drift service dependency.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
session: Database session.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
DriftService instance.
|
|
129
|
+
"""
|
|
130
|
+
return DriftService(session)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def get_schedule_service(session: SessionDep) -> ScheduleService:
|
|
134
|
+
"""Get schedule service dependency.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
session: Database session.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
ScheduleService instance.
|
|
141
|
+
"""
|
|
142
|
+
return ScheduleService(session)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Type aliases for service dependencies
|
|
146
|
+
SourceServiceDep = Annotated[SourceService, Depends(get_source_service)]
|
|
147
|
+
ValidationServiceDep = Annotated[ValidationService, Depends(get_validation_service)]
|
|
148
|
+
SchemaServiceDep = Annotated[SchemaService, Depends(get_schema_service)]
|
|
149
|
+
ProfileServiceDep = Annotated[ProfileService, Depends(get_profile_service)]
|
|
150
|
+
RuleServiceDep = Annotated[RuleService, Depends(get_rule_service)]
|
|
151
|
+
HistoryServiceDep = Annotated[HistoryService, Depends(get_history_service)]
|
|
152
|
+
DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
|
|
153
|
+
ScheduleServiceDep = Annotated[ScheduleService, Depends(get_schedule_service)]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Drift detection API endpoints.
|
|
2
|
+
|
|
3
|
+
Provides endpoints for drift comparison between datasets.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
11
|
+
|
|
12
|
+
from truthound_dashboard.core import DriftService
|
|
13
|
+
from truthound_dashboard.schemas import (
|
|
14
|
+
DriftCompareRequest,
|
|
15
|
+
DriftComparisonListResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .deps import SessionDep
|
|
19
|
+
|
|
20
|
+
router = APIRouter()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def get_drift_service(session: SessionDep) -> DriftService:
|
|
24
|
+
"""Get drift service dependency."""
|
|
25
|
+
return DriftService(session)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post(
|
|
32
|
+
"/drift/compare",
|
|
33
|
+
response_model=dict,
|
|
34
|
+
summary="Compare datasets for drift",
|
|
35
|
+
description="Compare two datasets to detect data drift.",
|
|
36
|
+
)
|
|
37
|
+
async def compare_datasets(
|
|
38
|
+
request: DriftCompareRequest,
|
|
39
|
+
service: DriftServiceDep,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Compare two datasets for drift detection.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
request: Comparison request with source IDs and options.
|
|
45
|
+
service: Drift service.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Drift comparison results.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
comparison = await service.compare(
|
|
52
|
+
baseline_source_id=request.baseline_source_id,
|
|
53
|
+
current_source_id=request.current_source_id,
|
|
54
|
+
columns=request.columns,
|
|
55
|
+
method=request.method,
|
|
56
|
+
threshold=request.threshold,
|
|
57
|
+
sample_size=request.sample_size,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"success": True,
|
|
62
|
+
"data": {
|
|
63
|
+
"id": comparison.id,
|
|
64
|
+
"baseline_source_id": comparison.baseline_source_id,
|
|
65
|
+
"current_source_id": comparison.current_source_id,
|
|
66
|
+
"has_drift": comparison.has_drift,
|
|
67
|
+
"has_high_drift": comparison.has_high_drift,
|
|
68
|
+
"total_columns": comparison.total_columns,
|
|
69
|
+
"drifted_columns": comparison.drifted_columns,
|
|
70
|
+
"drift_percentage": comparison.drift_percentage,
|
|
71
|
+
"result": comparison.result_json,
|
|
72
|
+
"config": comparison.config,
|
|
73
|
+
"created_at": (
|
|
74
|
+
comparison.created_at.isoformat() if comparison.created_at else None
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
except ValueError as e:
|
|
79
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
80
|
+
except Exception as e:
|
|
81
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.get(
|
|
85
|
+
"/drift/comparisons",
|
|
86
|
+
response_model=DriftComparisonListResponse,
|
|
87
|
+
summary="List drift comparisons",
|
|
88
|
+
description="List all drift comparisons with optional filters.",
|
|
89
|
+
)
|
|
90
|
+
async def list_comparisons(
|
|
91
|
+
service: DriftServiceDep,
|
|
92
|
+
baseline_source_id: str | None = Query(
|
|
93
|
+
None, description="Filter by baseline source"
|
|
94
|
+
),
|
|
95
|
+
current_source_id: str | None = Query(None, description="Filter by current source"),
|
|
96
|
+
limit: int = Query(20, ge=1, le=100, description="Maximum results"),
|
|
97
|
+
) -> DriftComparisonListResponse:
|
|
98
|
+
"""List drift comparisons.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
service: Drift service.
|
|
102
|
+
baseline_source_id: Optional baseline source ID filter.
|
|
103
|
+
current_source_id: Optional current source ID filter.
|
|
104
|
+
limit: Maximum results to return.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of drift comparisons.
|
|
108
|
+
"""
|
|
109
|
+
comparisons = await service.list_comparisons(
|
|
110
|
+
baseline_source_id=baseline_source_id,
|
|
111
|
+
current_source_id=current_source_id,
|
|
112
|
+
limit=limit,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return DriftComparisonListResponse(
|
|
116
|
+
success=True,
|
|
117
|
+
data=[
|
|
118
|
+
{
|
|
119
|
+
"id": c.id,
|
|
120
|
+
"baseline_source_id": c.baseline_source_id,
|
|
121
|
+
"current_source_id": c.current_source_id,
|
|
122
|
+
"has_drift": c.has_drift,
|
|
123
|
+
"has_high_drift": c.has_high_drift,
|
|
124
|
+
"total_columns": c.total_columns,
|
|
125
|
+
"drifted_columns": c.drifted_columns,
|
|
126
|
+
"drift_percentage": c.drift_percentage,
|
|
127
|
+
"created_at": c.created_at.isoformat() if c.created_at else None,
|
|
128
|
+
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
|
129
|
+
}
|
|
130
|
+
for c in comparisons
|
|
131
|
+
],
|
|
132
|
+
total=len(comparisons),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@router.get(
|
|
137
|
+
"/drift/comparisons/{comparison_id}",
|
|
138
|
+
response_model=dict,
|
|
139
|
+
summary="Get drift comparison",
|
|
140
|
+
description="Get a specific drift comparison by ID.",
|
|
141
|
+
)
|
|
142
|
+
async def get_comparison(
|
|
143
|
+
comparison_id: str,
|
|
144
|
+
service: DriftServiceDep,
|
|
145
|
+
) -> dict:
|
|
146
|
+
"""Get a drift comparison by ID.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
comparison_id: Comparison ID.
|
|
150
|
+
service: Drift service.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Drift comparison details.
|
|
154
|
+
"""
|
|
155
|
+
comparison = await service.get_comparison(comparison_id)
|
|
156
|
+
if comparison is None:
|
|
157
|
+
raise HTTPException(status_code=404, detail="Comparison not found")
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"success": True,
|
|
161
|
+
"data": {
|
|
162
|
+
"id": comparison.id,
|
|
163
|
+
"baseline_source_id": comparison.baseline_source_id,
|
|
164
|
+
"current_source_id": comparison.current_source_id,
|
|
165
|
+
"has_drift": comparison.has_drift,
|
|
166
|
+
"has_high_drift": comparison.has_high_drift,
|
|
167
|
+
"total_columns": comparison.total_columns,
|
|
168
|
+
"drifted_columns": comparison.drifted_columns,
|
|
169
|
+
"drift_percentage": comparison.drift_percentage,
|
|
170
|
+
"result": comparison.result_json,
|
|
171
|
+
"config": comparison.config,
|
|
172
|
+
"created_at": (
|
|
173
|
+
comparison.created_at.isoformat() if comparison.created_at else None
|
|
174
|
+
),
|
|
175
|
+
"updated_at": (
|
|
176
|
+
comparison.updated_at.isoformat() if comparison.updated_at else None
|
|
177
|
+
),
|
|
178
|
+
},
|
|
179
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Global error handlers for FastAPI application.
|
|
2
|
+
|
|
3
|
+
This module provides centralized error handling with consistent
|
|
4
|
+
response formats and proper logging.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Maps custom exceptions to HTTP responses
|
|
8
|
+
- Handles unexpected errors gracefully
|
|
9
|
+
- Provides consistent error response format
|
|
10
|
+
- Logs errors appropriately
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
from truthound_dashboard.api.error_handlers import setup_error_handlers
|
|
14
|
+
|
|
15
|
+
app = FastAPI()
|
|
16
|
+
setup_error_handlers(app)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import traceback
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from fastapi import FastAPI, Request
|
|
26
|
+
from fastapi.exceptions import RequestValidationError
|
|
27
|
+
from fastapi.responses import JSONResponse
|
|
28
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
29
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
30
|
+
|
|
31
|
+
from truthound_dashboard.core.exceptions import (
|
|
32
|
+
ErrorCode,
|
|
33
|
+
TruthoundDashboardError,
|
|
34
|
+
get_error_message,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_error_response(
|
|
41
|
+
code: str,
|
|
42
|
+
message: str,
|
|
43
|
+
details: dict[str, Any] | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Create standardized error response.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
code: Error code string.
|
|
49
|
+
message: Human-readable error message.
|
|
50
|
+
details: Additional error details.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Dictionary suitable for JSON response.
|
|
54
|
+
"""
|
|
55
|
+
response = {
|
|
56
|
+
"success": False,
|
|
57
|
+
"error": {
|
|
58
|
+
"code": code,
|
|
59
|
+
"message": message,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
if details:
|
|
63
|
+
response["error"]["details"] = details
|
|
64
|
+
return response
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def setup_error_handlers(app: FastAPI) -> None:
|
|
68
|
+
"""Configure global error handlers for FastAPI application.
|
|
69
|
+
|
|
70
|
+
Registers handlers for:
|
|
71
|
+
- TruthoundDashboardError and subclasses
|
|
72
|
+
- Pydantic validation errors
|
|
73
|
+
- FastAPI request validation errors
|
|
74
|
+
- Starlette HTTP exceptions
|
|
75
|
+
- Generic exceptions (catch-all)
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
app: FastAPI application instance.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
@app.exception_handler(TruthoundDashboardError)
|
|
82
|
+
async def handle_dashboard_error(
|
|
83
|
+
request: Request,
|
|
84
|
+
exc: TruthoundDashboardError,
|
|
85
|
+
) -> JSONResponse:
|
|
86
|
+
"""Handle custom dashboard exceptions.
|
|
87
|
+
|
|
88
|
+
Logs the error and returns a structured response.
|
|
89
|
+
"""
|
|
90
|
+
# Log at appropriate level based on HTTP status
|
|
91
|
+
if exc.http_status >= 500:
|
|
92
|
+
logger.error(
|
|
93
|
+
f"Dashboard error: {exc.code.value} - {exc.message}",
|
|
94
|
+
extra={"details": exc.details, "path": request.url.path},
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
logger.warning(
|
|
98
|
+
f"Dashboard error: {exc.code.value} - {exc.message}",
|
|
99
|
+
extra={"details": exc.details, "path": request.url.path},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return JSONResponse(
|
|
103
|
+
status_code=exc.http_status,
|
|
104
|
+
content=exc.to_response(),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@app.exception_handler(RequestValidationError)
|
|
108
|
+
async def handle_request_validation_error(
|
|
109
|
+
request: Request,
|
|
110
|
+
exc: RequestValidationError,
|
|
111
|
+
) -> JSONResponse:
|
|
112
|
+
"""Handle FastAPI request validation errors.
|
|
113
|
+
|
|
114
|
+
Converts Pydantic validation errors to user-friendly format.
|
|
115
|
+
"""
|
|
116
|
+
errors = []
|
|
117
|
+
for error in exc.errors():
|
|
118
|
+
location = ".".join(str(loc) for loc in error["loc"])
|
|
119
|
+
errors.append({
|
|
120
|
+
"field": location,
|
|
121
|
+
"message": error["msg"],
|
|
122
|
+
"type": error["type"],
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
logger.warning(
|
|
126
|
+
f"Request validation error: {request.url.path}",
|
|
127
|
+
extra={"errors": errors},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return JSONResponse(
|
|
131
|
+
status_code=422,
|
|
132
|
+
content=create_error_response(
|
|
133
|
+
code=ErrorCode.VALIDATION_ERROR.value,
|
|
134
|
+
message="Request validation failed",
|
|
135
|
+
details={"validation_errors": errors},
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@app.exception_handler(PydanticValidationError)
|
|
140
|
+
async def handle_pydantic_validation_error(
|
|
141
|
+
request: Request,
|
|
142
|
+
exc: PydanticValidationError,
|
|
143
|
+
) -> JSONResponse:
|
|
144
|
+
"""Handle Pydantic validation errors."""
|
|
145
|
+
errors = []
|
|
146
|
+
for error in exc.errors():
|
|
147
|
+
location = ".".join(str(loc) for loc in error["loc"])
|
|
148
|
+
errors.append({
|
|
149
|
+
"field": location,
|
|
150
|
+
"message": error["msg"],
|
|
151
|
+
"type": error["type"],
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
logger.warning(
|
|
155
|
+
f"Pydantic validation error: {request.url.path}",
|
|
156
|
+
extra={"errors": errors},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return JSONResponse(
|
|
160
|
+
status_code=422,
|
|
161
|
+
content=create_error_response(
|
|
162
|
+
code=ErrorCode.VALIDATION_ERROR.value,
|
|
163
|
+
message="Data validation failed",
|
|
164
|
+
details={"validation_errors": errors},
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@app.exception_handler(StarletteHTTPException)
|
|
169
|
+
async def handle_http_exception(
|
|
170
|
+
request: Request,
|
|
171
|
+
exc: StarletteHTTPException,
|
|
172
|
+
) -> JSONResponse:
|
|
173
|
+
"""Handle Starlette HTTP exceptions."""
|
|
174
|
+
# Map common HTTP status codes to error codes
|
|
175
|
+
status_code_map = {
|
|
176
|
+
400: ErrorCode.VALIDATION_ERROR,
|
|
177
|
+
401: ErrorCode.AUTHENTICATION_REQUIRED,
|
|
178
|
+
403: ErrorCode.AUTHORIZATION_FAILED,
|
|
179
|
+
404: ErrorCode.SOURCE_NOT_FOUND,
|
|
180
|
+
429: ErrorCode.RATE_LIMIT_EXCEEDED,
|
|
181
|
+
500: ErrorCode.INTERNAL_ERROR,
|
|
182
|
+
502: ErrorCode.SOURCE_CONNECTION_FAILED,
|
|
183
|
+
503: ErrorCode.DATABASE_CONNECTION_FAILED,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
error_code = status_code_map.get(exc.status_code, ErrorCode.UNKNOWN_ERROR)
|
|
187
|
+
message = str(exc.detail) if exc.detail else get_error_message(error_code)
|
|
188
|
+
|
|
189
|
+
if exc.status_code >= 500:
|
|
190
|
+
logger.error(
|
|
191
|
+
f"HTTP error {exc.status_code}: {message}",
|
|
192
|
+
extra={"path": request.url.path},
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
logger.warning(
|
|
196
|
+
f"HTTP error {exc.status_code}: {message}",
|
|
197
|
+
extra={"path": request.url.path},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return JSONResponse(
|
|
201
|
+
status_code=exc.status_code,
|
|
202
|
+
content=create_error_response(
|
|
203
|
+
code=error_code.value,
|
|
204
|
+
message=message,
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@app.exception_handler(ValueError)
|
|
209
|
+
async def handle_value_error(
|
|
210
|
+
request: Request,
|
|
211
|
+
exc: ValueError,
|
|
212
|
+
) -> JSONResponse:
|
|
213
|
+
"""Handle ValueError as 400 Bad Request."""
|
|
214
|
+
logger.warning(
|
|
215
|
+
f"ValueError: {exc}",
|
|
216
|
+
extra={"path": request.url.path},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return JSONResponse(
|
|
220
|
+
status_code=400,
|
|
221
|
+
content=create_error_response(
|
|
222
|
+
code=ErrorCode.VALIDATION_ERROR.value,
|
|
223
|
+
message=str(exc),
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@app.exception_handler(FileNotFoundError)
|
|
228
|
+
async def handle_file_not_found_error(
|
|
229
|
+
request: Request,
|
|
230
|
+
exc: FileNotFoundError,
|
|
231
|
+
) -> JSONResponse:
|
|
232
|
+
"""Handle FileNotFoundError as 404 Not Found."""
|
|
233
|
+
logger.warning(
|
|
234
|
+
f"FileNotFoundError: {exc}",
|
|
235
|
+
extra={"path": request.url.path},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return JSONResponse(
|
|
239
|
+
status_code=404,
|
|
240
|
+
content=create_error_response(
|
|
241
|
+
code=ErrorCode.SOURCE_NOT_FOUND.value,
|
|
242
|
+
message=str(exc),
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@app.exception_handler(PermissionError)
|
|
247
|
+
async def handle_permission_error(
|
|
248
|
+
request: Request,
|
|
249
|
+
exc: PermissionError,
|
|
250
|
+
) -> JSONResponse:
|
|
251
|
+
"""Handle PermissionError as 403 Forbidden."""
|
|
252
|
+
logger.warning(
|
|
253
|
+
f"PermissionError: {exc}",
|
|
254
|
+
extra={"path": request.url.path},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return JSONResponse(
|
|
258
|
+
status_code=403,
|
|
259
|
+
content=create_error_response(
|
|
260
|
+
code=ErrorCode.SOURCE_ACCESS_DENIED.value,
|
|
261
|
+
message="Access denied to the requested resource",
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
@app.exception_handler(Exception)
|
|
266
|
+
async def handle_generic_exception(
|
|
267
|
+
request: Request,
|
|
268
|
+
exc: Exception,
|
|
269
|
+
) -> JSONResponse:
|
|
270
|
+
"""Handle unexpected exceptions.
|
|
271
|
+
|
|
272
|
+
Logs the full traceback but returns a safe error message
|
|
273
|
+
to avoid exposing internal details.
|
|
274
|
+
"""
|
|
275
|
+
logger.error(
|
|
276
|
+
f"Unexpected error: {type(exc).__name__}: {exc}\n"
|
|
277
|
+
f"{traceback.format_exc()}",
|
|
278
|
+
extra={"path": request.url.path},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return JSONResponse(
|
|
282
|
+
status_code=500,
|
|
283
|
+
content=create_error_response(
|
|
284
|
+
code=ErrorCode.INTERNAL_ERROR.value,
|
|
285
|
+
message=get_error_message(ErrorCode.INTERNAL_ERROR),
|
|
286
|
+
),
|
|
287
|
+
)
|