cuneus 0.2.9__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.
- cuneus/__init__.py +63 -0
- cuneus/cli.py +53 -0
- cuneus/core/__init__.py +0 -0
- cuneus/core/application.py +143 -0
- cuneus/core/exceptions.py +203 -0
- cuneus/core/extensions.py +110 -0
- cuneus/core/logging.py +154 -0
- cuneus/core/settings.py +83 -0
- cuneus/ext/__init__.py +0 -0
- cuneus/ext/health.py +128 -0
- cuneus/ext/server.py +54 -0
- cuneus/py.typed +0 -0
- cuneus/utils.py +14 -0
- cuneus-0.2.9.dist-info/METADATA +234 -0
- cuneus-0.2.9.dist-info/RECORD +17 -0
- cuneus-0.2.9.dist-info/WHEEL +4 -0
- cuneus-0.2.9.dist-info/entry_points.txt +2 -0
cuneus/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtip - A wrapper for FastAPI applications, like the artist.
|
|
3
|
+
|
|
4
|
+
Example:
|
|
5
|
+
from qtip import Application, Settings
|
|
6
|
+
from qtip.ext.database import DatabaseExtension
|
|
7
|
+
|
|
8
|
+
class AppSettings(Settings):
|
|
9
|
+
database_url: str
|
|
10
|
+
|
|
11
|
+
settings = AppSettings()
|
|
12
|
+
app = Application(settings)
|
|
13
|
+
app.add_extension(DatabaseExtension(settings))
|
|
14
|
+
|
|
15
|
+
fastapi_app = app.build()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .core.application import build_app
|
|
19
|
+
from .core.exceptions import (
|
|
20
|
+
AppException,
|
|
21
|
+
BadRequest,
|
|
22
|
+
Conflict,
|
|
23
|
+
DatabaseError,
|
|
24
|
+
ErrorResponse,
|
|
25
|
+
ExceptionExtension,
|
|
26
|
+
ExternalServiceError,
|
|
27
|
+
Forbidden,
|
|
28
|
+
NotFound,
|
|
29
|
+
RateLimited,
|
|
30
|
+
RedisError,
|
|
31
|
+
ServiceUnavailable,
|
|
32
|
+
Unauthorized,
|
|
33
|
+
error_responses,
|
|
34
|
+
)
|
|
35
|
+
from .core.extensions import BaseExtension, Extension
|
|
36
|
+
from .core.settings import Settings
|
|
37
|
+
|
|
38
|
+
__version__ = "0.2.1"
|
|
39
|
+
__all__ = [
|
|
40
|
+
# Core exported functions
|
|
41
|
+
# Application
|
|
42
|
+
"build_app",
|
|
43
|
+
# Extension
|
|
44
|
+
"BaseExtension",
|
|
45
|
+
"Extension",
|
|
46
|
+
# Settings
|
|
47
|
+
"Settings",
|
|
48
|
+
# Exceptions
|
|
49
|
+
"AppException",
|
|
50
|
+
"BadRequest",
|
|
51
|
+
"Conflict",
|
|
52
|
+
"DatabaseError",
|
|
53
|
+
"ErrorResponse",
|
|
54
|
+
"ExceptionExtension",
|
|
55
|
+
"ExternalServiceError",
|
|
56
|
+
"Forbidden",
|
|
57
|
+
"NotFound",
|
|
58
|
+
"RateLimited",
|
|
59
|
+
"RedisError",
|
|
60
|
+
"ServiceUnavailable",
|
|
61
|
+
"Unauthorized",
|
|
62
|
+
"error_responses",
|
|
63
|
+
]
|
cuneus/cli.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Cuneus CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .core.settings import Settings, ensure_project_in_path
|
|
8
|
+
from .utils import import_from_string
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_user_cli(config: Settings = Settings()) -> click.Group | None:
|
|
12
|
+
"""Load CLI from config."""
|
|
13
|
+
try:
|
|
14
|
+
return cast(click.Group, import_from_string(config.cli_module))
|
|
15
|
+
except (ImportError, AttributeError) as e:
|
|
16
|
+
click.echo(
|
|
17
|
+
f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True
|
|
18
|
+
)
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CuneusCLI(click.Group):
|
|
23
|
+
"""Delegates to the app's CLI from config."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
26
|
+
super().__init__(*args, **kwargs)
|
|
27
|
+
self._user_cli: click.Group | None = None
|
|
28
|
+
self._user_cli_loaded = False
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def user_cli(self) -> click.Group | None:
|
|
32
|
+
if not self._user_cli_loaded:
|
|
33
|
+
self._user_cli = get_user_cli()
|
|
34
|
+
self._user_cli_loaded = True
|
|
35
|
+
return self._user_cli
|
|
36
|
+
|
|
37
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
38
|
+
if self.user_cli:
|
|
39
|
+
return self.user_cli.list_commands(ctx)
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
43
|
+
if self.user_cli:
|
|
44
|
+
return self.user_cli.get_command(ctx, cmd_name)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ensure_project_in_path()
|
|
49
|
+
main = CuneusCLI(help="Cuneus CLI - FastAPI application framework")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
cuneus/core/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
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 inspect
|
|
10
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
11
|
+
from typing import Any, AsyncIterator, Callable
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import structlog
|
|
15
|
+
import svcs
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from starlette.middleware import Middleware
|
|
18
|
+
|
|
19
|
+
from .settings import Settings
|
|
20
|
+
from .exceptions import ExceptionExtension
|
|
21
|
+
from .logging import LoggingExtension
|
|
22
|
+
from .extensions import Extension, HasCLI, HasExceptionHandler, HasMiddleware, HasRoutes
|
|
23
|
+
from ..ext.health import HealthExtension
|
|
24
|
+
from ..ext.server import ServerExtension
|
|
25
|
+
|
|
26
|
+
logger = structlog.stdlib.get_logger("cuneus")
|
|
27
|
+
|
|
28
|
+
type ExtensionInput = Extension | Callable[..., Extension]
|
|
29
|
+
|
|
30
|
+
DEFAULTS = (
|
|
31
|
+
LoggingExtension,
|
|
32
|
+
HealthExtension,
|
|
33
|
+
ExceptionExtension,
|
|
34
|
+
ServerExtension,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExtensionConflictError(Exception):
|
|
39
|
+
"""Raised when extensions have conflicting state keys."""
|
|
40
|
+
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _instantiate_extension(
|
|
45
|
+
ext: ExtensionInput, settings: Settings | None = None
|
|
46
|
+
) -> Extension:
|
|
47
|
+
if isinstance(ext, type) or callable(ext):
|
|
48
|
+
try:
|
|
49
|
+
return ext(settings=settings)
|
|
50
|
+
except TypeError:
|
|
51
|
+
return ext()
|
|
52
|
+
|
|
53
|
+
return ext
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_app(
|
|
57
|
+
*extensions: ExtensionInput,
|
|
58
|
+
settings: Settings | None = None,
|
|
59
|
+
include_defaults: bool = True,
|
|
60
|
+
**fastapi_kwargs: Any,
|
|
61
|
+
) -> tuple[FastAPI, click.Group, svcs.fastapi.lifespan]:
|
|
62
|
+
"""
|
|
63
|
+
Build a FastAPI with extensions preconfigured.
|
|
64
|
+
|
|
65
|
+
The returned lifespan has a `.registry` attribute for testing overrides.
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
from cuneus import build_app, Settings, SettingsExtension
|
|
69
|
+
from myapp.extensions import DatabaseExtension
|
|
70
|
+
|
|
71
|
+
settings = Settings()
|
|
72
|
+
app, cli = build_app(
|
|
73
|
+
SettingsExtension(settings),
|
|
74
|
+
DatabaseExtension(settings),
|
|
75
|
+
title="Args are passed to FastAPI",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
__all__ = ["app", "cli"]
|
|
79
|
+
|
|
80
|
+
Testing:
|
|
81
|
+
from myapp import app, lifespan
|
|
82
|
+
|
|
83
|
+
def test_with_mock_db(client):
|
|
84
|
+
mock_db = Mock(spec=Database)
|
|
85
|
+
lifespan.registry.register_value(Database, mock_db)
|
|
86
|
+
"""
|
|
87
|
+
if "lifespan" in fastapi_kwargs:
|
|
88
|
+
raise AttributeError("cannot set lifespan with build_app")
|
|
89
|
+
if "middleware" in fastapi_kwargs:
|
|
90
|
+
raise AttributeError("cannot set middleware with build_app")
|
|
91
|
+
|
|
92
|
+
settings = settings or Settings()
|
|
93
|
+
|
|
94
|
+
all_inputs = (*DEFAULTS, *extensions) if include_defaults else extensions
|
|
95
|
+
|
|
96
|
+
all_extensions = [_instantiate_extension(ext, settings) for ext in all_inputs]
|
|
97
|
+
|
|
98
|
+
@svcs.fastapi.lifespan
|
|
99
|
+
@asynccontextmanager
|
|
100
|
+
async def lifespan(
|
|
101
|
+
app: FastAPI, registry: svcs.Registry
|
|
102
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
103
|
+
async with AsyncExitStack() as stack:
|
|
104
|
+
state: dict[str, Any] = {}
|
|
105
|
+
|
|
106
|
+
for ext in all_extensions:
|
|
107
|
+
ext_name = ext.__class__.__name__
|
|
108
|
+
ext_state = await stack.enter_async_context(ext.register(registry, app))
|
|
109
|
+
if ext_state:
|
|
110
|
+
if overlap := state.keys() & ext_state.keys():
|
|
111
|
+
msg = f"Extension {ext_name} state key collision: {overlap}"
|
|
112
|
+
logger.error(msg, ext=ext_name, overlap=overlap)
|
|
113
|
+
raise ExtensionConflictError(msg).with_traceback(None) from None
|
|
114
|
+
state.update(ext_state)
|
|
115
|
+
|
|
116
|
+
yield state
|
|
117
|
+
|
|
118
|
+
# Parse extensions for middleware and cli commands
|
|
119
|
+
middleware: list[Middleware] = []
|
|
120
|
+
app_cli = click.Group()
|
|
121
|
+
|
|
122
|
+
for ext in all_extensions:
|
|
123
|
+
ext_name = ext.__class__.__name__
|
|
124
|
+
if isinstance(ext, HasMiddleware):
|
|
125
|
+
logger.debug(f"Loading middleware from {ext_name}")
|
|
126
|
+
middleware.extend(ext.middleware())
|
|
127
|
+
if isinstance(ext, HasCLI):
|
|
128
|
+
logger.debug(f"Adding cli commands from {ext_name}")
|
|
129
|
+
ext.register_cli(app_cli)
|
|
130
|
+
|
|
131
|
+
app = FastAPI(lifespan=lifespan, middleware=middleware, **fastapi_kwargs)
|
|
132
|
+
|
|
133
|
+
# Preform post app initialization extension customization
|
|
134
|
+
for ext in all_extensions:
|
|
135
|
+
ext_name = ext.__class__.__name__
|
|
136
|
+
if isinstance(ext, HasExceptionHandler):
|
|
137
|
+
logger.debug(f"Loading exception handlers from {ext_name}")
|
|
138
|
+
ext.add_exception_handler(app)
|
|
139
|
+
if isinstance(ext, HasRoutes):
|
|
140
|
+
logger.debug(f"Loading routes from {ext_name}")
|
|
141
|
+
ext.add_routes(app)
|
|
142
|
+
|
|
143
|
+
return app, app_cli, lifespan
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
from fastapi import FastAPI, Request
|
|
11
|
+
from fastapi.responses import JSONResponse
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from .extensions import BaseExtension
|
|
15
|
+
from .settings import 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: Any) -> None:
|
|
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
|
+
|
|
156
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
157
|
+
self.settings = settings or Settings()
|
|
158
|
+
|
|
159
|
+
def add_exception_handler(self, app: FastAPI) -> None:
|
|
160
|
+
app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[arg-type]
|
|
161
|
+
app.add_exception_handler(Exception, self._handle_unexpected_exception)
|
|
162
|
+
|
|
163
|
+
def _handle_app_exception(
|
|
164
|
+
self, request: Request, exc: AppException
|
|
165
|
+
) -> JSONResponse:
|
|
166
|
+
if exc.status_code >= 500 and self.settings.log_server_errors:
|
|
167
|
+
log.exception("server_error", error_code=exc.error_code)
|
|
168
|
+
else:
|
|
169
|
+
log.warning("client_error", error_code=exc.error_code, message=exc.message)
|
|
170
|
+
|
|
171
|
+
response = exc.to_response(getattr(request.state, "request_id", None))
|
|
172
|
+
|
|
173
|
+
headers = {}
|
|
174
|
+
if isinstance(exc, RateLimited) and exc.retry_after:
|
|
175
|
+
headers["Retry-After"] = str(exc.retry_after)
|
|
176
|
+
|
|
177
|
+
return JSONResponse(
|
|
178
|
+
status_code=exc.status_code,
|
|
179
|
+
content=response.model_dump(exclude_none=True, mode="json"),
|
|
180
|
+
headers=headers,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _handle_unexpected_exception(
|
|
184
|
+
self, request: Request, exc: Exception
|
|
185
|
+
) -> JSONResponse:
|
|
186
|
+
log.exception("unexpected_error", exc_info=exc)
|
|
187
|
+
response: dict[str, Any] = {
|
|
188
|
+
"error": {
|
|
189
|
+
"code": "internal_error",
|
|
190
|
+
"message": "An unexpected error occurred",
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if hasattr(request.state, "request_id"): # pragma: no branch
|
|
195
|
+
response["error"]["request_id"] = request.state.request_id
|
|
196
|
+
|
|
197
|
+
if self.settings.debug:
|
|
198
|
+
response["error"]["details"] = {
|
|
199
|
+
"exception": type(exc).__name__,
|
|
200
|
+
"message": str(exc),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return JSONResponse(status_code=500, content=response)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import (
|
|
7
|
+
Any,
|
|
8
|
+
AsyncContextManager,
|
|
9
|
+
AsyncIterator,
|
|
10
|
+
Protocol,
|
|
11
|
+
runtime_checkable,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
import svcs
|
|
15
|
+
from click import Group
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from starlette.middleware import Middleware
|
|
18
|
+
|
|
19
|
+
from .settings import Settings
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class Extension(Protocol):
|
|
26
|
+
"""
|
|
27
|
+
Protocol for extensions that hook into app lifecycle.
|
|
28
|
+
|
|
29
|
+
Extensions can:
|
|
30
|
+
- Register services with svcs
|
|
31
|
+
- Add routes via app.include_router()
|
|
32
|
+
- Add exception handlers via app.add_exception_handler()
|
|
33
|
+
- Return state to merge into lifespan state
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def register(
|
|
37
|
+
self, registry: svcs.Registry, app: FastAPI
|
|
38
|
+
) -> AsyncContextManager[dict[str, Any]]: # pragma: no cover
|
|
39
|
+
"""
|
|
40
|
+
Async context manager for lifecycle.
|
|
41
|
+
|
|
42
|
+
- Enter: startup (register services, add routes, etc.)
|
|
43
|
+
- Yield: dict of state to merge into lifespan state
|
|
44
|
+
- Exit: shutdown (cleanup resources)
|
|
45
|
+
"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class HasMiddleware(Protocol):
|
|
51
|
+
"""Extension that provides middleware."""
|
|
52
|
+
|
|
53
|
+
def middleware(self) -> list[Middleware]: ... # pragma: no cover
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@runtime_checkable
|
|
57
|
+
class HasCLI(Protocol):
|
|
58
|
+
"""Extension that provides CLI commands."""
|
|
59
|
+
|
|
60
|
+
def register_cli(self, cli_group: Group) -> None: ... # pragma: no cover
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class HasExceptionHandler(Protocol):
|
|
65
|
+
"""Extension that provides exception handlers."""
|
|
66
|
+
|
|
67
|
+
def add_exception_handler(self, app: FastAPI) -> None: ... # pragma: no cover
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@runtime_checkable
|
|
71
|
+
class HasRoutes(Protocol):
|
|
72
|
+
"""Extension that provides routes."""
|
|
73
|
+
|
|
74
|
+
def add_routes(self, app: FastAPI) -> None: ... # pragma: no cover
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BaseExtension:
|
|
78
|
+
"""
|
|
79
|
+
Base class for extensions with explicit startup/shutdown hooks.
|
|
80
|
+
|
|
81
|
+
For simple extensions, override startup() and shutdown().
|
|
82
|
+
For full control, override register() directly.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, settings: Settings | None = None):
|
|
86
|
+
self.settings = settings or Settings()
|
|
87
|
+
|
|
88
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
89
|
+
"""
|
|
90
|
+
Override to setup resources during app startup.
|
|
91
|
+
|
|
92
|
+
You can call app.include_router(), app.add_exception_handler(), etc.
|
|
93
|
+
Returns a dict of state to merge into lifespan state.
|
|
94
|
+
"""
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
async def shutdown(self, app: FastAPI) -> None:
|
|
98
|
+
"""Override to cleanup resources during app shutdown."""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
@asynccontextmanager
|
|
102
|
+
async def register(
|
|
103
|
+
self, registry: svcs.Registry, app: FastAPI
|
|
104
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
105
|
+
"""Wraps startup/shutdown into async context manager."""
|
|
106
|
+
state = await self.startup(registry, app)
|
|
107
|
+
try:
|
|
108
|
+
yield state
|
|
109
|
+
finally:
|
|
110
|
+
await self.shutdown(app)
|
cuneus/core/logging.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging with structlog and request context.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import shutil
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
import svcs
|
|
15
|
+
from fastapi import FastAPI, Request, Response
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.middleware import Middleware
|
|
18
|
+
from starlette.types import ASGIApp
|
|
19
|
+
|
|
20
|
+
from .extensions import BaseExtension
|
|
21
|
+
from .settings import Settings
|
|
22
|
+
|
|
23
|
+
logger = structlog.stdlib.get_logger("cuneus")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def configure_structlog(settings: Settings | None = None) -> None:
|
|
27
|
+
log_settings = settings or Settings()
|
|
28
|
+
|
|
29
|
+
# Shared processors
|
|
30
|
+
shared_processors: list[structlog.types.Processor] = [
|
|
31
|
+
structlog.contextvars.merge_contextvars,
|
|
32
|
+
structlog.stdlib.add_log_level,
|
|
33
|
+
structlog.stdlib.add_logger_name,
|
|
34
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
35
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
36
|
+
structlog.processors.StackInfoRenderer(),
|
|
37
|
+
structlog.processors.UnicodeDecoder(),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
renderer = structlog.processors.JSONRenderer()
|
|
41
|
+
|
|
42
|
+
if not log_settings.log_json: # pragma: no branch
|
|
43
|
+
term_width = shutil.get_terminal_size().columns
|
|
44
|
+
pad_event = term_width - 36
|
|
45
|
+
renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(
|
|
46
|
+
colors=True, pad_event_to=pad_event
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Configure structlog
|
|
50
|
+
structlog.configure(
|
|
51
|
+
processors=shared_processors
|
|
52
|
+
+ [
|
|
53
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
54
|
+
],
|
|
55
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
56
|
+
cache_logger_on_first_use=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Create formatter for stdlib
|
|
60
|
+
formatter = structlog.stdlib.ProcessorFormatter(
|
|
61
|
+
foreign_pre_chain=shared_processors,
|
|
62
|
+
processors=[
|
|
63
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
64
|
+
renderer,
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Configure root logger
|
|
69
|
+
handler = logging.StreamHandler()
|
|
70
|
+
handler.setFormatter(formatter)
|
|
71
|
+
|
|
72
|
+
root_logger = logging.getLogger()
|
|
73
|
+
root_logger.handlers.clear()
|
|
74
|
+
root_logger.addHandler(handler)
|
|
75
|
+
root_logger.setLevel(log_settings.log_level.upper())
|
|
76
|
+
|
|
77
|
+
# Quiet noisy loggers
|
|
78
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LoggingExtension(BaseExtension):
|
|
82
|
+
"""
|
|
83
|
+
Structured logging extension using structlog.
|
|
84
|
+
|
|
85
|
+
Integrates with stdlib logging so uvicorn and other libraries
|
|
86
|
+
also output through structlog.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
90
|
+
self.settings = settings or Settings()
|
|
91
|
+
configure_structlog(settings)
|
|
92
|
+
|
|
93
|
+
def middleware(self) -> list[Middleware]:
|
|
94
|
+
return [
|
|
95
|
+
Middleware(
|
|
96
|
+
LoggingMiddleware,
|
|
97
|
+
header_name=self.settings.request_id_header,
|
|
98
|
+
),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LoggingMiddleware(BaseHTTPMiddleware):
|
|
103
|
+
"""
|
|
104
|
+
Middleware that:
|
|
105
|
+
- Generates request_id
|
|
106
|
+
- Binds it to structlog context
|
|
107
|
+
- Logs request start/end
|
|
108
|
+
- Adds request_id to response headers
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
|
|
112
|
+
self.header_name = header_name
|
|
113
|
+
super().__init__(app)
|
|
114
|
+
|
|
115
|
+
async def dispatch(
|
|
116
|
+
self, request: Request, call_next: Callable[..., Awaitable[Response]]
|
|
117
|
+
) -> Response:
|
|
118
|
+
path = request.url.path
|
|
119
|
+
# Exclude health routes as these are just noise
|
|
120
|
+
# TODO(rmyers): make this configurable
|
|
121
|
+
if path.startswith("/health"):
|
|
122
|
+
return await call_next(request)
|
|
123
|
+
|
|
124
|
+
request_id = request.headers.get(self.header_name) or str(uuid.uuid4())[:8]
|
|
125
|
+
|
|
126
|
+
structlog.contextvars.clear_contextvars()
|
|
127
|
+
structlog.contextvars.bind_contextvars(
|
|
128
|
+
request_id=request_id,
|
|
129
|
+
method=request.method,
|
|
130
|
+
path=path,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
request.state.request_id = request_id
|
|
134
|
+
|
|
135
|
+
log = structlog.stdlib.get_logger("cuneus")
|
|
136
|
+
start_time = time.perf_counter()
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
response = await call_next(request)
|
|
140
|
+
|
|
141
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
142
|
+
log.info(
|
|
143
|
+
f"{request.method} {request.url.path} {response.status_code}",
|
|
144
|
+
status_code=response.status_code,
|
|
145
|
+
duration_ms=round(duration_ms, 2),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
response.headers[self.header_name] = request_id
|
|
149
|
+
return response
|
|
150
|
+
|
|
151
|
+
except Exception:
|
|
152
|
+
raise
|
|
153
|
+
finally:
|
|
154
|
+
structlog.contextvars.clear_contextvars()
|
cuneus/core/settings.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
5
|
+
from pydantic_settings import (
|
|
6
|
+
BaseSettings,
|
|
7
|
+
PydanticBaseSettingsSource,
|
|
8
|
+
PyprojectTomlConfigSettingsSource,
|
|
9
|
+
SettingsConfigDict,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
DEFAULT_TOOL_NAME = "cuneus"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CuneusBaseSettings(BaseSettings):
|
|
16
|
+
"""
|
|
17
|
+
Base settings that loads from:
|
|
18
|
+
1. pyproject.toml [tool.cuneus] (lowest priority)
|
|
19
|
+
2. .env file
|
|
20
|
+
3. Environment variables (highest priority)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def settings_customise_sources(
|
|
25
|
+
cls,
|
|
26
|
+
settings_cls: type[BaseSettings],
|
|
27
|
+
init_settings: PydanticBaseSettingsSource,
|
|
28
|
+
env_settings: PydanticBaseSettingsSource,
|
|
29
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
30
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
31
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
32
|
+
return (
|
|
33
|
+
init_settings,
|
|
34
|
+
PyprojectTomlConfigSettingsSource(settings_cls),
|
|
35
|
+
env_settings,
|
|
36
|
+
dotenv_settings,
|
|
37
|
+
file_secret_settings,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Settings(CuneusBaseSettings):
|
|
42
|
+
|
|
43
|
+
model_config = SettingsConfigDict(
|
|
44
|
+
env_file=".env",
|
|
45
|
+
env_file_encoding="utf-8",
|
|
46
|
+
extra="allow",
|
|
47
|
+
pyproject_toml_depth=2,
|
|
48
|
+
pyproject_toml_table_header=("tool", DEFAULT_TOOL_NAME),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
app_name: str = "app"
|
|
52
|
+
app_module: str = "app.main:app"
|
|
53
|
+
cli_module: str = "app.main:cli"
|
|
54
|
+
debug: bool = False
|
|
55
|
+
version: str | None = None
|
|
56
|
+
|
|
57
|
+
# logging
|
|
58
|
+
log_level: str = "INFO"
|
|
59
|
+
log_json: bool = False
|
|
60
|
+
log_server_errors: bool = True
|
|
61
|
+
request_id_header: str = "X-Request-ID"
|
|
62
|
+
|
|
63
|
+
# health
|
|
64
|
+
health_enabled: bool = True
|
|
65
|
+
health_prefix: str = "/healthz"
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def get_project_root(cls) -> pathlib.Path:
|
|
69
|
+
"""
|
|
70
|
+
Get the project root by inspecting where pydantic-settings
|
|
71
|
+
found the pyproject.toml file.
|
|
72
|
+
"""
|
|
73
|
+
source = PyprojectTomlConfigSettingsSource(
|
|
74
|
+
cls,
|
|
75
|
+
)
|
|
76
|
+
return source.toml_file_path.parent
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ensure_project_in_path() -> None:
|
|
80
|
+
"""Add project root to sys.path if not already present."""
|
|
81
|
+
project_root = str(Settings.get_project_root())
|
|
82
|
+
if project_root not in sys.path: # pragma: no branch
|
|
83
|
+
sys.path.insert(0, project_root)
|
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 ..core.extensions import BaseExtension
|
|
16
|
+
from ..core.settings import Settings
|
|
17
|
+
|
|
18
|
+
log = structlog.get_logger()
|
|
19
|
+
health_router = APIRouter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HealthStatus(str, Enum):
|
|
23
|
+
HEALTHY = "healthy"
|
|
24
|
+
UNHEALTHY = "unhealthy"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ServiceHealth(BaseModel):
|
|
28
|
+
name: str
|
|
29
|
+
status: HealthStatus
|
|
30
|
+
message: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HealthResponse(BaseModel):
|
|
34
|
+
status: HealthStatus
|
|
35
|
+
version: str | None = None
|
|
36
|
+
services: list[ServiceHealth] = []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@health_router.get("", response_model=HealthResponse)
|
|
40
|
+
async def health(
|
|
41
|
+
services: svcs.fastapi.DepContainer, request: Request
|
|
42
|
+
) -> HealthResponse:
|
|
43
|
+
"""Full health check - pings all registered services."""
|
|
44
|
+
pings = services.get_pings()
|
|
45
|
+
|
|
46
|
+
_services: list[ServiceHealth] = []
|
|
47
|
+
overall_healthy = True
|
|
48
|
+
|
|
49
|
+
for ping in pings:
|
|
50
|
+
try:
|
|
51
|
+
await ping.aping()
|
|
52
|
+
_services.append(
|
|
53
|
+
ServiceHealth(
|
|
54
|
+
name=ping.name,
|
|
55
|
+
status=HealthStatus.HEALTHY,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
log.warning("health_check_failed", service=ping.name, error=str(e))
|
|
60
|
+
_services.append(
|
|
61
|
+
ServiceHealth(
|
|
62
|
+
name=ping.name,
|
|
63
|
+
status=HealthStatus.UNHEALTHY,
|
|
64
|
+
message=str(e),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
overall_healthy = False
|
|
68
|
+
|
|
69
|
+
return HealthResponse(
|
|
70
|
+
status=(HealthStatus.HEALTHY if overall_healthy else HealthStatus.UNHEALTHY),
|
|
71
|
+
version=getattr(request.state, "version", "NA"),
|
|
72
|
+
services=_services,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@health_router.get("/live")
|
|
77
|
+
async def liveness() -> dict[str, str]:
|
|
78
|
+
"""Liveness probe - is the process running?"""
|
|
79
|
+
return {"status": "ok"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@health_router.get("/ready")
|
|
83
|
+
async def readiness(services: svcs.fastapi.DepContainer) -> dict[str, str]:
|
|
84
|
+
"""Readiness probe - can we serve traffic?"""
|
|
85
|
+
from fastapi import HTTPException
|
|
86
|
+
|
|
87
|
+
pings = services.get_pings()
|
|
88
|
+
|
|
89
|
+
for ping in pings:
|
|
90
|
+
try:
|
|
91
|
+
await ping.aping()
|
|
92
|
+
except Exception as e:
|
|
93
|
+
log.warning("readiness_check_failed", service=ping.name, error=str(e))
|
|
94
|
+
raise HTTPException(status_code=503, detail=f"{ping.name} unhealthy")
|
|
95
|
+
|
|
96
|
+
return {"status": "ok"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class HealthExtension(BaseExtension):
|
|
100
|
+
"""
|
|
101
|
+
Health check extension using svcs pings.
|
|
102
|
+
|
|
103
|
+
Adds:
|
|
104
|
+
GET /healthz - Full health check using svcs pings
|
|
105
|
+
GET /healthz/live - Liveness probe (always 200)
|
|
106
|
+
GET /healthz/ready - Readiness probe (503 if unhealthy)
|
|
107
|
+
|
|
108
|
+
Settings:
|
|
109
|
+
- `health_enabled`: bool to disable health routes, default True
|
|
110
|
+
- `health_prefix`: prefix to include routes default: '/healthz'
|
|
111
|
+
- `version`: used to display in the response
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
115
|
+
self.settings = settings or Settings()
|
|
116
|
+
|
|
117
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
118
|
+
state = await super().startup(registry, app)
|
|
119
|
+
state["version"] = self.settings.version
|
|
120
|
+
return state
|
|
121
|
+
|
|
122
|
+
def add_routes(self, app: FastAPI) -> None:
|
|
123
|
+
if self.settings.health_enabled:
|
|
124
|
+
app.include_router(
|
|
125
|
+
health_router,
|
|
126
|
+
prefix=self.settings.health_prefix,
|
|
127
|
+
tags=["health"],
|
|
128
|
+
)
|
cuneus/ext/server.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from ..core.extensions import BaseExtension
|
|
4
|
+
from ..utils import import_from_string
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ServerExtension(BaseExtension):
|
|
8
|
+
"""Provides dev/prod/routes CLI commands."""
|
|
9
|
+
|
|
10
|
+
def register_cli(self, cli_group: click.Group) -> None:
|
|
11
|
+
settings = self.settings
|
|
12
|
+
|
|
13
|
+
@cli_group.command()
|
|
14
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
15
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
16
|
+
def dev(host: str, port: int) -> None:
|
|
17
|
+
"""Run the application in development mode with reload."""
|
|
18
|
+
import uvicorn
|
|
19
|
+
|
|
20
|
+
uvicorn.run(
|
|
21
|
+
settings.app_module,
|
|
22
|
+
host=host,
|
|
23
|
+
port=port,
|
|
24
|
+
reload=True,
|
|
25
|
+
log_config=None,
|
|
26
|
+
server_header=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@cli_group.command()
|
|
30
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
31
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
32
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
33
|
+
def prod(host: str, port: int, workers: int) -> None:
|
|
34
|
+
"""Run the application in production mode."""
|
|
35
|
+
import uvicorn
|
|
36
|
+
|
|
37
|
+
uvicorn.run(
|
|
38
|
+
settings.app_module,
|
|
39
|
+
host=host,
|
|
40
|
+
port=port,
|
|
41
|
+
workers=workers,
|
|
42
|
+
log_config=None,
|
|
43
|
+
server_header=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@cli_group.command()
|
|
47
|
+
def routes() -> None:
|
|
48
|
+
"""List all registered routes."""
|
|
49
|
+
app = import_from_string(settings.app_module)
|
|
50
|
+
|
|
51
|
+
for route in app.routes:
|
|
52
|
+
if hasattr(route, "methods"): # pragma: no branch
|
|
53
|
+
methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
|
|
54
|
+
click.echo(f"{methods:8} {route.path}")
|
cuneus/py.typed
ADDED
|
File without changes
|
cuneus/utils.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def import_from_string(import_str: str) -> typing.Any:
|
|
6
|
+
"""Import an object from a module:attribute string."""
|
|
7
|
+
module_path, _, attr = import_str.partition(":")
|
|
8
|
+
if not attr:
|
|
9
|
+
raise ValueError(
|
|
10
|
+
f"module_path missing function {import_str} expecting 'module.path:name'"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
module = importlib.import_module(module_path)
|
|
14
|
+
return getattr(module, attr)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cuneus
|
|
3
|
+
Version: 0.2.9
|
|
4
|
+
Summary: ASGI application wrapper
|
|
5
|
+
Project-URL: Homepage, https://github.com/rmyers/cuneus
|
|
6
|
+
Project-URL: Documentation, https://github.com/rmyers/cuneus#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/rmyers/cuneus
|
|
8
|
+
Author-email: Robert Myers <robert@julython.org>
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: click>=8.0
|
|
11
|
+
Requires-Dist: fastapi>=0.109.0
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
13
|
+
Requires-Dist: pydantic>=2.0
|
|
14
|
+
Requires-Dist: structlog>=24.1.0
|
|
15
|
+
Requires-Dist: svcs>=24.1.0
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.27.0
|
|
17
|
+
Provides-Extra: all
|
|
18
|
+
Requires-Dist: alembic>=1.13.0; extra == 'all'
|
|
19
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'all'
|
|
20
|
+
Requires-Dist: redis>=5.0; extra == 'all'
|
|
21
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'all'
|
|
22
|
+
Provides-Extra: database
|
|
23
|
+
Requires-Dist: alembic>=1.13.0; extra == 'database'
|
|
24
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'database'
|
|
25
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'database'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: alembic>=1.13.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: asgi-lifespan>=2.1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-mock; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: redis>=5.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff>=0.3; extra == 'dev'
|
|
38
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'dev'
|
|
39
|
+
Provides-Extra: redis
|
|
40
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# cuneus
|
|
44
|
+
|
|
45
|
+
> _The wedge stone that locks the arch together_
|
|
46
|
+
|
|
47
|
+
**cuneus** is a lightweight lifespan manager for FastAPI applications. It provides a simple pattern for composing extensions that handle startup/shutdown and service registration.
|
|
48
|
+
|
|
49
|
+
The name comes from Roman architecture: a _cuneus_ is the wedge-shaped stone in a Roman arch. Each stone is simple on its own, but together they lock under pressure to create structures that have stood for millennia—no rebar required.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv add cuneus
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
or
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install cuneus
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
# app/main.py
|
|
67
|
+
from fastapi import FastAPI
|
|
68
|
+
from cuneus import build_app, Settings
|
|
69
|
+
|
|
70
|
+
from myapp.extensions import DatabaseExtension
|
|
71
|
+
|
|
72
|
+
class MyAppSettings(Settings):
|
|
73
|
+
my_mood: str = "extatic"
|
|
74
|
+
|
|
75
|
+
app, cli = build_app(
|
|
76
|
+
DatabaseExtension,
|
|
77
|
+
settings=MyAppSettings(),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
app.include_router(my_router)
|
|
81
|
+
|
|
82
|
+
__all__ = ["app", "cli"]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
That's it. Extensions handle their lifecycle, registration, and middleware.
|
|
86
|
+
|
|
87
|
+
## Creating Extensions
|
|
88
|
+
|
|
89
|
+
Use `BaseExtension` for simple cases:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from cuneus import BaseExtension
|
|
93
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
|
94
|
+
import svcs
|
|
95
|
+
|
|
96
|
+
class DatabaseExtension(BaseExtension):
|
|
97
|
+
def __init__(self, settings):
|
|
98
|
+
self.settings = settings
|
|
99
|
+
self.engine: AsyncEngine | None = None
|
|
100
|
+
|
|
101
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
102
|
+
self.engine = create_async_engine(self.settings.database_url)
|
|
103
|
+
|
|
104
|
+
# Register with svcs for dependency injection
|
|
105
|
+
registry.register_value(AsyncEngine, self.engine)
|
|
106
|
+
|
|
107
|
+
# Add routes
|
|
108
|
+
app.include_router(health_router, prefix="/health")
|
|
109
|
+
|
|
110
|
+
# Add exception handlers
|
|
111
|
+
app.add_exception_handler(DBError, self.handle_db_error)
|
|
112
|
+
|
|
113
|
+
# Return state (accessible via request.state.db)
|
|
114
|
+
return {"db": self.engine}
|
|
115
|
+
|
|
116
|
+
async def shutdown(self, app: FastAPI) -> None:
|
|
117
|
+
if self.engine:
|
|
118
|
+
await self.engine.dispose()
|
|
119
|
+
|
|
120
|
+
def middleware(self) -> list[Middleware]:
|
|
121
|
+
return [Middleware(DatabaseLoggingMiddleware, level=INFO)]
|
|
122
|
+
|
|
123
|
+
def register_cli(self, app_cli: click.Group) -> None:
|
|
124
|
+
@app_cli.command()
|
|
125
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
126
|
+
def blow_up_db(workers: int): ...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For full control, override `register()` directly:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from contextlib import asynccontextmanager
|
|
133
|
+
|
|
134
|
+
class RedisExtension(BaseExtension):
|
|
135
|
+
def __init__(self, settings):
|
|
136
|
+
self.settings = settings
|
|
137
|
+
|
|
138
|
+
@asynccontextmanager
|
|
139
|
+
async def register(self, registry: svcs.Registry, app: FastAPI):
|
|
140
|
+
redis = await aioredis.from_url(self.settings.redis_url)
|
|
141
|
+
registry.register_value(Redis, redis)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
yield {"redis": redis}
|
|
145
|
+
finally:
|
|
146
|
+
await redis.close()
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Testing
|
|
150
|
+
|
|
151
|
+
The lifespan exposes a `.registry` attribute for test overrides:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
# test_app.py
|
|
155
|
+
from unittest.mock import Mock
|
|
156
|
+
from starlette.testclient import TestClient
|
|
157
|
+
from myapp import app, lifespan, Database
|
|
158
|
+
|
|
159
|
+
def test_db_error_handling():
|
|
160
|
+
with TestClient(app) as client:
|
|
161
|
+
# Override after app startup
|
|
162
|
+
mock_db = Mock(spec=Database)
|
|
163
|
+
mock_db.get_user.side_effect = Exception("boom")
|
|
164
|
+
lifespan.registry.register_value(Database, mock_db)
|
|
165
|
+
|
|
166
|
+
resp = client.get("/users/42")
|
|
167
|
+
assert resp.status_code == 500
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Settings
|
|
171
|
+
|
|
172
|
+
cuneus includes a base `Settings` class that loads from multiple sources:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from cuneus import Settings
|
|
176
|
+
|
|
177
|
+
class AppSettings(Settings):
|
|
178
|
+
database_url: str = "sqlite+aiosqlite:///./app.db"
|
|
179
|
+
redis_url: str = "redis://localhost"
|
|
180
|
+
|
|
181
|
+
model_config = SettingsConfigDict(env_prefix="APP_")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Load priority (highest wins):
|
|
185
|
+
|
|
186
|
+
1. Environment variables
|
|
187
|
+
2. `.env` file
|
|
188
|
+
3. `pyproject.toml` under `[tool.cuneus]`
|
|
189
|
+
|
|
190
|
+
## API Reference
|
|
191
|
+
|
|
192
|
+
### `build_lifespan(settings, *extensions)`
|
|
193
|
+
|
|
194
|
+
Creates a lifespan context manager for FastAPI.
|
|
195
|
+
|
|
196
|
+
- `settings`: Your settings instance (subclass of `Settings`)
|
|
197
|
+
- `*extensions`: Extension instances to register
|
|
198
|
+
|
|
199
|
+
Returns a lifespan with a `.registry` attribute for testing.
|
|
200
|
+
|
|
201
|
+
### `BaseExtension`
|
|
202
|
+
|
|
203
|
+
Base class with `startup()` and `shutdown()` hooks:
|
|
204
|
+
|
|
205
|
+
- `startup(registry, app) -> dict[str, Any]`: Setup resources, return state
|
|
206
|
+
- `shutdown(app) -> None`: Cleanup resources
|
|
207
|
+
- `middleware() -> list[Middleware]`: Optional middleware to configure
|
|
208
|
+
- `register_cli(group) -> None`: Optional hook to add click commands
|
|
209
|
+
|
|
210
|
+
### `Extension` Protocol
|
|
211
|
+
|
|
212
|
+
For full control, implement the protocol directly:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
def register(self, registry: svcs.Registry, app: FastAPI) -> AsyncContextManager[dict[str, Any]]
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Accessors
|
|
219
|
+
|
|
220
|
+
- `aget(request, *types)` - Async get services from svcs
|
|
221
|
+
- `get(request, *types)` - Sync get services from svcs
|
|
222
|
+
- `get_settings(request)` - Get settings from request state
|
|
223
|
+
- `get_request_id(request)` - Get request ID from request state
|
|
224
|
+
|
|
225
|
+
## Why cuneus?
|
|
226
|
+
|
|
227
|
+
- **Simple** — one function, `build_app()`, does what you need
|
|
228
|
+
- **Testable** — registry exposed via `lifespan.registry`
|
|
229
|
+
- **Composable** — extensions are just async context managers
|
|
230
|
+
- **Built on svcs** — proper dependency injection, not global state
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
cuneus/__init__.py,sha256=AJXN9dnXIWn0Eg6JxWOgOf0C386EqBNdHiQG2akTpKc,1295
|
|
2
|
+
cuneus/cli.py,sha256=c8QbGj9QLcr-XJNUgCiSeaO1n5oY3DWBOMj2ruAxUwM,1512
|
|
3
|
+
cuneus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
cuneus/utils.py,sha256=WyykPUXwxJWpFny5uL87_Fqd2R34za7gXUOf4IyeC0w,423
|
|
5
|
+
cuneus/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
cuneus/core/application.py,sha256=LHFLqdQvIbjsN1iCVkFJkPFFoIWNGWP2DAPjmI3IWaY,4489
|
|
7
|
+
cuneus/core/exceptions.py,sha256=gyHgVy4UC7gBjs5pXpCzIIyIVsV7K8xt-KZKi8eLJtM,5496
|
|
8
|
+
cuneus/core/extensions.py,sha256=fO9RaJ00Bw9s0VsnCQAAdo-zR9N8573uO3YP2fYUQc4,2934
|
|
9
|
+
cuneus/core/logging.py,sha256=Ql2VDQesZaDRdhua2HLYzvNhkIPVsTGUqxwYBkUI2dc,4581
|
|
10
|
+
cuneus/core/settings.py,sha256=cHHqdKtAjcTPBKXnfG9_GMJiTr7t-iX7rPvIQ480UkI,2248
|
|
11
|
+
cuneus/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
cuneus/ext/health.py,sha256=6JG25foR5ln2tnRuGYuTVju8bBv16iVR4xKpdf2OVa0,3596
|
|
13
|
+
cuneus/ext/server.py,sha256=wyQMiHFZEFYt1a4wC4IjorEp70kH7fF2aLx3_X5t5RI,1869
|
|
14
|
+
cuneus-0.2.9.dist-info/METADATA,sha256=90bfNg2zE9iFbH9Rd6luS1txjL6PRutr8e6BZosyyiA,6837
|
|
15
|
+
cuneus-0.2.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
16
|
+
cuneus-0.2.9.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
|
|
17
|
+
cuneus-0.2.9.dist-info/RECORD,,
|