cuneus 0.2.1__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,230 @@
1
+ """
2
+ cuneus - The wedge stone that locks the arch together.
3
+
4
+ Lightweight lifespan management for FastAPI applications.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import tomllib
11
+ import uuid
12
+ from contextlib import AsyncExitStack, asynccontextmanager
13
+ from pathlib import Path
14
+ from typing import Any, AsyncContextManager, AsyncIterator, Protocol, runtime_checkable
15
+
16
+ import svcs
17
+ from fastapi import FastAPI, Request
18
+ from pydantic import model_validator
19
+ from pydantic_settings import BaseSettings, SettingsConfigDict
20
+ from starlette.types import ASGIApp
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ DEFAULT_TOOL_NAME = "cuneus"
25
+
26
+
27
+ def load_pyproject_config(
28
+ tool_name: str = DEFAULT_TOOL_NAME,
29
+ path: Path | None = None,
30
+ ) -> dict[str, Any]:
31
+ """Load configuration from pyproject.toml under [tool.{tool_name}]."""
32
+ if path is None:
33
+ path = Path.cwd()
34
+
35
+ for parent in [path, *path.parents]:
36
+ pyproject = parent / "pyproject.toml"
37
+ if pyproject.exists():
38
+ with open(pyproject, "rb") as f:
39
+ data = tomllib.load(f)
40
+ return data.get("tool", {}).get(tool_name, {})
41
+
42
+ return {}
43
+
44
+
45
+ class Settings(BaseSettings):
46
+ """
47
+ Base settings that loads from:
48
+ 1. pyproject.toml [tool.cuneus] (lowest priority)
49
+ 2. .env file
50
+ 3. Environment variables (highest priority)
51
+ """
52
+
53
+ model_config = SettingsConfigDict(
54
+ env_file=".env",
55
+ env_file_encoding="utf-8",
56
+ extra="ignore",
57
+ )
58
+
59
+ app_name: str = "app"
60
+ app_module: str = "app.main:app"
61
+ debug: bool = False
62
+ version: str | None = None
63
+
64
+ # logging
65
+ log_level: str = "INFO"
66
+ log_json: bool = False
67
+ log_server_errors: bool = True
68
+
69
+ # health
70
+ health_enabled: bool = True
71
+ health_prefix: str = "/healthz"
72
+
73
+ @model_validator(mode="before")
74
+ @classmethod
75
+ def load_from_pyproject(cls, data: dict[str, Any]) -> dict[str, Any]:
76
+ pyproject_config = load_pyproject_config()
77
+ return {**pyproject_config, **data}
78
+
79
+
80
+ @runtime_checkable
81
+ class Extension(Protocol):
82
+ """
83
+ Protocol for extensions that hook into app lifecycle.
84
+
85
+ Extensions can:
86
+ - Register services with svcs
87
+ - Add routes via app.include_router()
88
+ - Add exception handlers via app.add_exception_handler()
89
+ - Return state to merge into lifespan state
90
+ """
91
+
92
+ def register(
93
+ self, registry: svcs.Registry, app: FastAPI
94
+ ) -> AsyncContextManager[dict[str, Any]]:
95
+ """
96
+ Async context manager for lifecycle.
97
+
98
+ - Enter: startup (register services, add routes, etc.)
99
+ - Yield: dict of state to merge into lifespan state
100
+ - Exit: shutdown (cleanup resources)
101
+ """
102
+ ...
103
+
104
+
105
+ class BaseExtension:
106
+ """
107
+ Base class for extensions with explicit startup/shutdown hooks.
108
+
109
+ For simple extensions, override startup() and shutdown().
110
+ For full control, override register() directly.
111
+ """
112
+
113
+ async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
114
+ """
115
+ Override to setup resources during app startup.
116
+
117
+ You can call app.include_router(), app.add_exception_handler(), etc.
118
+ Returns a dict of state to merge into lifespan state.
119
+ """
120
+ return {}
121
+
122
+ async def shutdown(self, app: FastAPI) -> None:
123
+ """Override to cleanup resources during app shutdown."""
124
+ pass
125
+
126
+ @asynccontextmanager
127
+ async def register(
128
+ self, registry: svcs.Registry, app: FastAPI
129
+ ) -> AsyncIterator[dict[str, Any]]:
130
+ """Wraps startup/shutdown into async context manager."""
131
+ state = await self.startup(registry, app)
132
+ try:
133
+ yield state
134
+ finally:
135
+ await self.shutdown(app)
136
+
137
+
138
+ def build_lifespan(settings: Settings, *extensions: Extension):
139
+ """
140
+ Create a lifespan context manager for FastAPI.
141
+
142
+ The returned lifespan has a `.registry` attribute for testing overrides.
143
+
144
+ Usage:
145
+ from cuneus import build_lifespan, Settings
146
+ from myapp.extensions import DatabaseExtension
147
+
148
+ settings = Settings()
149
+ lifespan = build_lifespan(
150
+ settings,
151
+ DatabaseExtension(settings),
152
+ )
153
+
154
+ app = FastAPI(lifespan=lifespan, title="My App")
155
+
156
+ Testing:
157
+ from myapp import app, lifespan
158
+
159
+ def test_with_mock_db(client):
160
+ mock_db = Mock(spec=Database)
161
+ lifespan.registry.register_value(Database, mock_db)
162
+ # ...
163
+ """
164
+
165
+ @svcs.fastapi.lifespan
166
+ @asynccontextmanager
167
+ async def lifespan(
168
+ app: FastAPI, registry: svcs.Registry
169
+ ) -> AsyncIterator[dict[str, Any]]:
170
+ async with AsyncExitStack() as stack:
171
+ state: dict[str, Any] = {"settings": settings}
172
+
173
+ for ext in extensions:
174
+ ext_state = await stack.enter_async_context(ext.register(registry, app))
175
+ if ext_state:
176
+ if overlap := state.keys() & ext_state.keys():
177
+ raise ValueError(f"Extension state key collision: {overlap}")
178
+ state.update(ext_state)
179
+
180
+ yield state
181
+
182
+ return lifespan
183
+
184
+
185
+ class RequestIDMiddleware:
186
+ """
187
+ Middleware that adds a unique request_id to each request.
188
+
189
+ Access via request.state.request_id
190
+ """
191
+
192
+ def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID"):
193
+ self.app = app
194
+ self.header_name = header_name
195
+
196
+ async def __call__(self, scope, receive, send):
197
+ if scope["type"] != "http":
198
+ await self.app(scope, receive, send)
199
+ return
200
+
201
+ # Check for existing request ID in headers, or generate new one
202
+ headers = dict(scope.get("headers", []))
203
+ request_id = headers.get(
204
+ self.header_name.lower().encode(), str(uuid.uuid4())[:8].encode()
205
+ ).decode()
206
+
207
+ # Store in scope state
208
+ if "state" not in scope:
209
+ scope["state"] = {}
210
+ scope["state"]["request_id"] = request_id
211
+
212
+ # Add request ID to response headers
213
+ async def send_with_request_id(message):
214
+ if message["type"] == "http.response.start":
215
+ headers = list(message.get("headers", []))
216
+ headers.append((self.header_name.encode(), request_id.encode()))
217
+ message["headers"] = headers
218
+ await send(message)
219
+
220
+ await self.app(scope, receive, send_with_request_id)
221
+
222
+
223
+ def get_settings(request: Request) -> Settings:
224
+ """Get settings from request state."""
225
+ return request.state.settings
226
+
227
+
228
+ def get_request_id(request: Request) -> str:
229
+ """Get the request ID from request state."""
230
+ return request.state.request_id
@@ -0,0 +1,214 @@
1
+ """
2
+ Exception handling with consistent API responses.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ import structlog
10
+ import svcs
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.responses import JSONResponse
13
+ from pydantic import BaseModel
14
+
15
+ from cuneus.core.application import BaseExtension, Settings
16
+
17
+ log = structlog.get_logger()
18
+
19
+
20
+ class ErrorDetails(BaseModel):
21
+ status: int
22
+ code: str
23
+ message: str
24
+ request_id: str | None = None
25
+ details: Any = None
26
+
27
+
28
+ class ErrorResponse(BaseModel):
29
+ error: ErrorDetails
30
+
31
+
32
+ class AppException(Exception):
33
+ """
34
+ Base exception for application errors.
35
+
36
+ Subclass this for domain-specific errors.
37
+ """
38
+
39
+ status_code: int = 500
40
+ error_code: str = "internal_error"
41
+ message: str = "An unexpected error occurred"
42
+
43
+ def __init__(
44
+ self,
45
+ message: str | None = None,
46
+ *,
47
+ error_code: str | None = None,
48
+ status_code: int | None = None,
49
+ details: dict[str, Any] | None = None,
50
+ ) -> None:
51
+ self.message = message or self.message
52
+ self.error_code = error_code or self.error_code
53
+ self.status_code = status_code or self.status_code
54
+ self.details = details or {}
55
+ super().__init__(self.message)
56
+
57
+ def to_response(self, request_id: str | None = None) -> ErrorResponse:
58
+ error_detail = ErrorDetails(
59
+ status=self.status_code,
60
+ code=self.error_code,
61
+ message=self.message,
62
+ request_id=request_id,
63
+ details=self.details,
64
+ )
65
+ return ErrorResponse(error=error_detail)
66
+
67
+
68
+ # === Common HTTP Exceptions ===
69
+
70
+
71
+ class BadRequest(AppException):
72
+ status_code = 400
73
+ error_code = "bad_request"
74
+ message = "Invalid request"
75
+
76
+
77
+ class Unauthorized(AppException):
78
+ status_code = 401
79
+ error_code = "unauthorized"
80
+ message = "Authentication required"
81
+
82
+
83
+ class Forbidden(AppException):
84
+ status_code = 403
85
+ error_code = "forbidden"
86
+ message = "Access denied"
87
+
88
+
89
+ class NotFound(AppException):
90
+ status_code = 404
91
+ error_code = "not_found"
92
+ message = "Resource not found"
93
+
94
+
95
+ class Conflict(AppException):
96
+ status_code = 409
97
+ error_code = "conflict"
98
+ message = "Resource conflict"
99
+
100
+
101
+ class RateLimited(AppException):
102
+ status_code = 429
103
+ error_code = "rate_limited"
104
+ message = "Too many requests"
105
+
106
+ def __init__(self, retry_after: int | None = None, **kwargs):
107
+ super().__init__(**kwargs)
108
+ self.retry_after = retry_after
109
+
110
+
111
+ class ServiceUnavailable(AppException):
112
+ status_code = 503
113
+ error_code = "service_unavailable"
114
+ message = "Service temporarily unavailable"
115
+
116
+
117
+ # === Infrastructure Exceptions ===
118
+
119
+
120
+ class DatabaseError(AppException):
121
+ status_code = 503
122
+ error_code = "database_error"
123
+ message = "Database operation failed"
124
+
125
+
126
+ class RedisError(AppException):
127
+ status_code = 503
128
+ error_code = "cache_error"
129
+ message = "Cache operation failed"
130
+
131
+
132
+ class ExternalServiceError(AppException):
133
+ status_code = 502
134
+ error_code = "external_service_error"
135
+ message = "External service request failed"
136
+
137
+
138
+ def error_responses(*excptions: AppException) -> dict[int, dict[str, Any]]:
139
+ responses: dict[int, dict[str, Any]] = {}
140
+ for exception in excptions:
141
+ responses[exception.status_code] = {
142
+ "model": ErrorResponse,
143
+ "description": exception.message,
144
+ }
145
+ return responses
146
+
147
+
148
+ class ExceptionExtension(BaseExtension):
149
+ """
150
+ Exception handling extension.
151
+
152
+ Catches AppException subclasses and converts to JSON responses.
153
+ Catches unexpected exceptions and returns generic 500s.
154
+
155
+ Usage:
156
+ from qtip import build_app
157
+ from qtip.core.exceptions import ExceptionExtension, ExceptionSettings
158
+
159
+ app = build_app(
160
+ settings,
161
+ extensions=[ExceptionExtension(settings)],
162
+ )
163
+ """
164
+
165
+ def __init__(self, settings: Settings) -> None:
166
+ self.settings = settings
167
+
168
+ async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
169
+ app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[]
170
+ app.add_exception_handler(Exception, self._handle_unexpected_exception)
171
+ return {}
172
+
173
+ def _handle_app_exception(
174
+ self, request: Request, exc: AppException
175
+ ) -> JSONResponse:
176
+ if exc.status_code >= 500 and self.settings.log_server_errors:
177
+ log.exception("server_error", error_code=exc.error_code)
178
+ else:
179
+ log.warning("client_error", error_code=exc.error_code, message=exc.message)
180
+
181
+ response = exc.to_response(request.state.get("request_id", None))
182
+
183
+ headers = {}
184
+ if isinstance(exc, RateLimited) and exc.retry_after:
185
+ headers["Retry-After"] = str(exc.retry_after)
186
+
187
+ return JSONResponse(
188
+ status_code=exc.status_code,
189
+ content=response.model_dump(exclude_none=True, mode="json"),
190
+ headers=headers,
191
+ )
192
+
193
+ def _handle_unexpected_exception(
194
+ self, request: Request, exc: Exception
195
+ ) -> JSONResponse:
196
+ log.exception("unexpected_error")
197
+
198
+ response: dict[str, Any] = {
199
+ "error": {
200
+ "code": "internal_error",
201
+ "message": "An unexpected error occurred",
202
+ }
203
+ }
204
+
205
+ if hasattr(request.state, "request_id"):
206
+ response["error"]["request_id"] = request.state.request_id
207
+
208
+ if self.settings.debug:
209
+ response["error"]["details"] = {
210
+ "exception": type(exc).__name__,
211
+ "message": str(exc),
212
+ }
213
+
214
+ return JSONResponse(status_code=500, content=response)
cuneus/ext/__init__.py ADDED
File without changes
cuneus/ext/health.py ADDED
@@ -0,0 +1,128 @@
1
+ """
2
+ Health check endpoints using svcs ping capabilities.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ import structlog
11
+ import svcs
12
+ from fastapi import APIRouter, FastAPI, Request
13
+ from pydantic import BaseModel
14
+
15
+ from cuneus.core.application import BaseExtension, Settings
16
+
17
+ log = structlog.get_logger()
18
+
19
+
20
+ class HealthStatus(str, Enum):
21
+ HEALTHY = "healthy"
22
+ UNHEALTHY = "unhealthy"
23
+
24
+
25
+ class ServiceHealth(BaseModel):
26
+ name: str
27
+ status: HealthStatus
28
+ message: str | None = None
29
+
30
+
31
+ class HealthResponse(BaseModel):
32
+ status: HealthStatus
33
+ version: str | None = None
34
+ services: list[ServiceHealth] = []
35
+
36
+
37
+ class HealthExtension(BaseExtension):
38
+ """
39
+ Health check extension using svcs pings.
40
+
41
+ Adds:
42
+ GET /health - Full health check using svcs pings
43
+ GET /health/live - Liveness probe (always 200)
44
+ GET /health/ready - Readiness probe (503 if unhealthy)
45
+
46
+ Usage:
47
+ from qtip import build_app
48
+ from qtip.ext.health import HealthExtension, HealthSettings
49
+
50
+ app = build_app(
51
+ settings,
52
+ extensions=[HealthExtension(settings)],
53
+ )
54
+ """
55
+
56
+ def __init__(self, settings: Settings) -> None:
57
+ self.settings = settings
58
+
59
+ async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
60
+ if not self.settings.health_enabled:
61
+ return {}
62
+
63
+ router = APIRouter(prefix=self.settings.health_prefix, tags=["health"])
64
+ version = self.settings.version
65
+
66
+ @router.get("", response_model=HealthResponse)
67
+ async def health(services: svcs.fastapi.DepContainer) -> HealthResponse:
68
+ """Full health check - pings all registered services."""
69
+ pings = services.get_pings()
70
+
71
+ _services: list[ServiceHealth] = []
72
+ overall_healthy = True
73
+
74
+ for ping in pings:
75
+ try:
76
+ await ping.aping()
77
+ _services.append(
78
+ ServiceHealth(
79
+ name=ping.name,
80
+ status=HealthStatus.HEALTHY,
81
+ )
82
+ )
83
+ except Exception as e:
84
+ log.warning("health_check_failed", service=ping.name, error=str(e))
85
+ _services.append(
86
+ ServiceHealth(
87
+ name=ping.name,
88
+ status=HealthStatus.UNHEALTHY,
89
+ message=str(e),
90
+ )
91
+ )
92
+ overall_healthy = False
93
+
94
+ return HealthResponse(
95
+ status=(
96
+ HealthStatus.HEALTHY if overall_healthy else HealthStatus.UNHEALTHY
97
+ ),
98
+ version=version,
99
+ services=_services,
100
+ )
101
+
102
+ @router.get("/live")
103
+ async def liveness() -> dict[str, str]:
104
+ """Liveness probe - is the process running?"""
105
+ return {"status": "ok"}
106
+
107
+ @router.get("/ready")
108
+ async def readiness(services: svcs.fastapi.DepContainer) -> dict[str, str]:
109
+ """Readiness probe - can we serve traffic?"""
110
+ from fastapi import HTTPException
111
+
112
+ pings = services.get_pings()
113
+
114
+ for ping in pings:
115
+ try:
116
+ await ping.aping()
117
+ except Exception as e:
118
+ log.warning(
119
+ "readiness_check_failed", service=ping.name, error=str(e)
120
+ )
121
+ raise HTTPException(
122
+ status_code=503, detail=f"{ping.name} unhealthy"
123
+ )
124
+
125
+ return {"status": "ok"}
126
+
127
+ app.include_router(router)
128
+ return {}
File without changes