cuneus 0.2.6__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 +59 -0
- cuneus/cli.py +143 -0
- cuneus/core/__init__.py +0 -0
- cuneus/core/application.py +140 -0
- cuneus/core/execptions.py +215 -0
- cuneus/core/extensions.py +96 -0
- cuneus/core/logging.py +230 -0
- cuneus/core/settings.py +66 -0
- cuneus/ext/__init__.py +0 -0
- cuneus/ext/health.py +129 -0
- cuneus/py.typed +0 -0
- cuneus-0.2.6.dist-info/METADATA +233 -0
- cuneus-0.2.6.dist-info/RECORD +15 -0
- cuneus-0.2.6.dist-info/WHEEL +4 -0
- cuneus-0.2.6.dist-info/entry_points.txt +2 -0
cuneus/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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.execptions import (
|
|
20
|
+
AppException,
|
|
21
|
+
BadRequest,
|
|
22
|
+
Unauthorized,
|
|
23
|
+
Forbidden,
|
|
24
|
+
NotFound,
|
|
25
|
+
Conflict,
|
|
26
|
+
RateLimited,
|
|
27
|
+
ServiceUnavailable,
|
|
28
|
+
DatabaseError,
|
|
29
|
+
RedisError,
|
|
30
|
+
ExternalServiceError,
|
|
31
|
+
ExceptionExtension,
|
|
32
|
+
)
|
|
33
|
+
from .core.extensions import BaseExtension, Extension
|
|
34
|
+
from .core.settings import Settings
|
|
35
|
+
|
|
36
|
+
__version__ = "0.2.1"
|
|
37
|
+
__all__ = [
|
|
38
|
+
# Core exported functions
|
|
39
|
+
# Application
|
|
40
|
+
"build_app",
|
|
41
|
+
# Extension
|
|
42
|
+
"BaseExtension",
|
|
43
|
+
"Extension",
|
|
44
|
+
# Settings
|
|
45
|
+
"Settings",
|
|
46
|
+
# Exceptions
|
|
47
|
+
"AppException",
|
|
48
|
+
"BadRequest",
|
|
49
|
+
"Unauthorized",
|
|
50
|
+
"Forbidden",
|
|
51
|
+
"NotFound",
|
|
52
|
+
"Conflict",
|
|
53
|
+
"RateLimited",
|
|
54
|
+
"ServiceUnavailable",
|
|
55
|
+
"DatabaseError",
|
|
56
|
+
"RedisError",
|
|
57
|
+
"ExternalServiceError",
|
|
58
|
+
"ExceptionExtension",
|
|
59
|
+
]
|
cuneus/cli.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Base CLI that cuneus provides."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .core.settings import Settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def import_from_string(import_str: str) -> Any:
|
|
15
|
+
"""Import an object from a module:attribute string."""
|
|
16
|
+
# Ensure cwd is in path for local imports
|
|
17
|
+
cwd = str(Path.cwd())
|
|
18
|
+
if cwd not in sys.path:
|
|
19
|
+
sys.path.insert(0, cwd)
|
|
20
|
+
|
|
21
|
+
module_path, _, attr = import_str.partition(":")
|
|
22
|
+
if not attr:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"module_path missing function {import_str} expecting 'module.path:name'"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
module = importlib.import_module(module_path)
|
|
28
|
+
return getattr(module, attr)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_user_cli() -> click.Group | None:
|
|
32
|
+
"""Attempt to load user's CLI from config."""
|
|
33
|
+
config = Settings()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
return cast(click.Group, import_from_string(config.cli_module))
|
|
37
|
+
except (ImportError, AttributeError) as e:
|
|
38
|
+
click.echo(
|
|
39
|
+
f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.group()
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def cli(ctx: click.Context) -> None:
|
|
48
|
+
"""Cuneus CLI - FastAPI application framework."""
|
|
49
|
+
ctx.ensure_object(dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.command()
|
|
53
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
54
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
55
|
+
def dev(host: str, port: int) -> None:
|
|
56
|
+
"""Run the application server."""
|
|
57
|
+
import uvicorn
|
|
58
|
+
|
|
59
|
+
config = Settings()
|
|
60
|
+
|
|
61
|
+
uvicorn.run(
|
|
62
|
+
config.app_module,
|
|
63
|
+
host=host,
|
|
64
|
+
port=port,
|
|
65
|
+
reload=True,
|
|
66
|
+
log_config=None,
|
|
67
|
+
server_header=False,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@cli.command()
|
|
72
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
73
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
74
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
75
|
+
def prod(host: str, port: int, workers: int) -> None:
|
|
76
|
+
"""Run the application server."""
|
|
77
|
+
import uvicorn
|
|
78
|
+
|
|
79
|
+
config = Settings()
|
|
80
|
+
|
|
81
|
+
uvicorn.run(
|
|
82
|
+
config.app_module,
|
|
83
|
+
host=host,
|
|
84
|
+
port=port,
|
|
85
|
+
workers=workers,
|
|
86
|
+
log_config=None,
|
|
87
|
+
server_header=False,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cli.command()
|
|
92
|
+
def routes() -> None:
|
|
93
|
+
"""List all registered routes."""
|
|
94
|
+
config = Settings()
|
|
95
|
+
app = import_from_string(config.app_module)
|
|
96
|
+
|
|
97
|
+
for route in app.routes:
|
|
98
|
+
if hasattr(route, "methods"):
|
|
99
|
+
methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
|
|
100
|
+
click.echo(f"{methods:8} {route.path}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CuneusCLI(click.Group):
|
|
104
|
+
"""Merges base cuneus commands with user's app CLI."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
107
|
+
super().__init__(*args, **kwargs)
|
|
108
|
+
self._user_cli: click.Group | None = None
|
|
109
|
+
self._user_cli_loaded = False
|
|
110
|
+
|
|
111
|
+
# Register base commands directly
|
|
112
|
+
self.add_command(dev)
|
|
113
|
+
self.add_command(prod)
|
|
114
|
+
self.add_command(routes)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def user_cli(self) -> click.Group | None:
|
|
118
|
+
if not self._user_cli_loaded:
|
|
119
|
+
self._user_cli = get_user_cli()
|
|
120
|
+
self._user_cli_loaded = True
|
|
121
|
+
return self._user_cli
|
|
122
|
+
|
|
123
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
124
|
+
commands = set(super().list_commands(ctx))
|
|
125
|
+
if self.user_cli:
|
|
126
|
+
commands.update(self.user_cli.list_commands(ctx))
|
|
127
|
+
return sorted(commands)
|
|
128
|
+
|
|
129
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
130
|
+
# User CLI takes priority
|
|
131
|
+
if self.user_cli:
|
|
132
|
+
cmd = self.user_cli.get_command(ctx, cmd_name)
|
|
133
|
+
if cmd:
|
|
134
|
+
return cmd
|
|
135
|
+
return super().get_command(ctx, cmd_name)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# This is the actual entry point
|
|
139
|
+
main = CuneusCLI(help="Cuneus CLI - FastAPI application framework")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|
cuneus/core/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,140 @@
|
|
|
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 .execptions import ExceptionExtension
|
|
21
|
+
from .logging import LoggingExtension
|
|
22
|
+
from .extensions import Extension, HasCLI, HasMiddleware
|
|
23
|
+
from ..ext.health import HealthExtension
|
|
24
|
+
|
|
25
|
+
logger = structlog.stdlib.get_logger("cuneus")
|
|
26
|
+
|
|
27
|
+
type ExtensionInput = Extension | Callable[..., Extension]
|
|
28
|
+
|
|
29
|
+
DEFAULT_EXTENSIONS = (
|
|
30
|
+
LoggingExtension,
|
|
31
|
+
HealthExtension,
|
|
32
|
+
ExceptionExtension,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _instantiate_extension(
|
|
37
|
+
ext: ExtensionInput, settings: Settings | None = None
|
|
38
|
+
) -> Extension:
|
|
39
|
+
if isinstance(ext, type) or callable(ext):
|
|
40
|
+
sig = inspect.signature(ext)
|
|
41
|
+
|
|
42
|
+
# Check if it accepts a 'settings' parameter
|
|
43
|
+
if "settings" in sig.parameters:
|
|
44
|
+
return ext(settings=settings)
|
|
45
|
+
|
|
46
|
+
# Check if it accepts **kwargs
|
|
47
|
+
has_var_keyword = any(
|
|
48
|
+
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
|
49
|
+
)
|
|
50
|
+
if has_var_keyword:
|
|
51
|
+
return ext(settings=settings)
|
|
52
|
+
|
|
53
|
+
return ext()
|
|
54
|
+
return ext
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_app(
|
|
58
|
+
*extensions: ExtensionInput,
|
|
59
|
+
settings: Settings | None = None,
|
|
60
|
+
include_defaults: bool = True,
|
|
61
|
+
**fastapi_kwargs: Any,
|
|
62
|
+
) -> tuple[FastAPI, click.Group]:
|
|
63
|
+
"""
|
|
64
|
+
Build a FastAPI with extensions preconfigured.
|
|
65
|
+
|
|
66
|
+
The returned lifespan has a `.registry` attribute for testing overrides.
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
from cuneus import build_app, Settings, SettingsExtension
|
|
70
|
+
from myapp.extensions import DatabaseExtension
|
|
71
|
+
|
|
72
|
+
settings = Settings()
|
|
73
|
+
app, cli = build_app(
|
|
74
|
+
SettingsExtension(settings),
|
|
75
|
+
DatabaseExtension(settings),
|
|
76
|
+
title="Args are passed to FastAPI",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
__all__ = ["app", "cli"]
|
|
80
|
+
|
|
81
|
+
Testing:
|
|
82
|
+
from myapp import app, lifespan
|
|
83
|
+
|
|
84
|
+
def test_with_mock_db(client):
|
|
85
|
+
mock_db = Mock(spec=Database)
|
|
86
|
+
lifespan.registry.register_value(Database, mock_db)
|
|
87
|
+
"""
|
|
88
|
+
if "lifespan" in fastapi_kwargs:
|
|
89
|
+
raise AttributeError("cannot set lifespan with build_app")
|
|
90
|
+
if "middleware" in fastapi_kwargs:
|
|
91
|
+
raise AttributeError("cannot set middleware with build_app")
|
|
92
|
+
|
|
93
|
+
settings = settings or Settings()
|
|
94
|
+
|
|
95
|
+
if include_defaults:
|
|
96
|
+
# Allow users to override a default extension
|
|
97
|
+
user_types = {type(ext) for ext in extensions}
|
|
98
|
+
defaults = [ext for ext in DEFAULT_EXTENSIONS if type(ext) not in user_types]
|
|
99
|
+
all_inputs = (*defaults, *extensions)
|
|
100
|
+
else:
|
|
101
|
+
all_inputs = extensions
|
|
102
|
+
|
|
103
|
+
all_extensions = [_instantiate_extension(ext, settings) for ext in all_inputs]
|
|
104
|
+
|
|
105
|
+
@click.group()
|
|
106
|
+
@click.pass_context
|
|
107
|
+
def app_cli(ctx: click.Context) -> None:
|
|
108
|
+
"""Application CLI."""
|
|
109
|
+
ctx.ensure_object(dict)
|
|
110
|
+
|
|
111
|
+
@svcs.fastapi.lifespan
|
|
112
|
+
@asynccontextmanager
|
|
113
|
+
async def lifespan(
|
|
114
|
+
app: FastAPI, registry: svcs.Registry
|
|
115
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
116
|
+
async with AsyncExitStack() as stack:
|
|
117
|
+
state: dict[str, Any] = {}
|
|
118
|
+
|
|
119
|
+
for ext in all_extensions:
|
|
120
|
+
ext_state = await stack.enter_async_context(ext.register(registry, app))
|
|
121
|
+
if ext_state:
|
|
122
|
+
if overlap := state.keys() & ext_state.keys():
|
|
123
|
+
raise ValueError(f"Extension state key collision: {overlap}")
|
|
124
|
+
state.update(ext_state)
|
|
125
|
+
|
|
126
|
+
yield state
|
|
127
|
+
|
|
128
|
+
# Parse extensions for middleware and cli commands
|
|
129
|
+
middleware: list[Middleware] = []
|
|
130
|
+
|
|
131
|
+
for ext in all_extensions:
|
|
132
|
+
if isinstance(ext, HasMiddleware):
|
|
133
|
+
logger.debug(f"Loading middleware from {ext.__class__.__name__}")
|
|
134
|
+
middleware.extend(ext.middleware())
|
|
135
|
+
if isinstance(ext, HasCLI):
|
|
136
|
+
logger.debug(f"Adding cli commands from {ext.__class__.__name__}")
|
|
137
|
+
ext.register_cli(app_cli)
|
|
138
|
+
|
|
139
|
+
app = FastAPI(lifespan=lifespan, middleware=middleware, **fastapi_kwargs)
|
|
140
|
+
return app, app_cli
|
|
@@ -0,0 +1,215 @@
|
|
|
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 .extensions import BaseExtension
|
|
16
|
+
from .settings import Settings
|
|
17
|
+
|
|
18
|
+
log = structlog.get_logger()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ErrorDetails(BaseModel):
|
|
22
|
+
status: int
|
|
23
|
+
code: str
|
|
24
|
+
message: str
|
|
25
|
+
request_id: str | None = None
|
|
26
|
+
details: Any = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ErrorResponse(BaseModel):
|
|
30
|
+
error: ErrorDetails
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AppException(Exception):
|
|
34
|
+
"""
|
|
35
|
+
Base exception for application errors.
|
|
36
|
+
|
|
37
|
+
Subclass this for domain-specific errors.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
status_code: int = 500
|
|
41
|
+
error_code: str = "internal_error"
|
|
42
|
+
message: str = "An unexpected error occurred"
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
message: str | None = None,
|
|
47
|
+
*,
|
|
48
|
+
error_code: str | None = None,
|
|
49
|
+
status_code: int | None = None,
|
|
50
|
+
details: dict[str, Any] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.message = message or self.message
|
|
53
|
+
self.error_code = error_code or self.error_code
|
|
54
|
+
self.status_code = status_code or self.status_code
|
|
55
|
+
self.details = details or {}
|
|
56
|
+
super().__init__(self.message)
|
|
57
|
+
|
|
58
|
+
def to_response(self, request_id: str | None = None) -> ErrorResponse:
|
|
59
|
+
error_detail = ErrorDetails(
|
|
60
|
+
status=self.status_code,
|
|
61
|
+
code=self.error_code,
|
|
62
|
+
message=self.message,
|
|
63
|
+
request_id=request_id,
|
|
64
|
+
details=self.details,
|
|
65
|
+
)
|
|
66
|
+
return ErrorResponse(error=error_detail)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# === Common HTTP Exceptions ===
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BadRequest(AppException):
|
|
73
|
+
status_code = 400
|
|
74
|
+
error_code = "bad_request"
|
|
75
|
+
message = "Invalid request"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Unauthorized(AppException):
|
|
79
|
+
status_code = 401
|
|
80
|
+
error_code = "unauthorized"
|
|
81
|
+
message = "Authentication required"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Forbidden(AppException):
|
|
85
|
+
status_code = 403
|
|
86
|
+
error_code = "forbidden"
|
|
87
|
+
message = "Access denied"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class NotFound(AppException):
|
|
91
|
+
status_code = 404
|
|
92
|
+
error_code = "not_found"
|
|
93
|
+
message = "Resource not found"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Conflict(AppException):
|
|
97
|
+
status_code = 409
|
|
98
|
+
error_code = "conflict"
|
|
99
|
+
message = "Resource conflict"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class RateLimited(AppException):
|
|
103
|
+
status_code = 429
|
|
104
|
+
error_code = "rate_limited"
|
|
105
|
+
message = "Too many requests"
|
|
106
|
+
|
|
107
|
+
def __init__(self, retry_after: int | None = None, **kwargs: Any) -> None:
|
|
108
|
+
super().__init__(**kwargs)
|
|
109
|
+
self.retry_after = retry_after
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ServiceUnavailable(AppException):
|
|
113
|
+
status_code = 503
|
|
114
|
+
error_code = "service_unavailable"
|
|
115
|
+
message = "Service temporarily unavailable"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# === Infrastructure Exceptions ===
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class DatabaseError(AppException):
|
|
122
|
+
status_code = 503
|
|
123
|
+
error_code = "database_error"
|
|
124
|
+
message = "Database operation failed"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class RedisError(AppException):
|
|
128
|
+
status_code = 503
|
|
129
|
+
error_code = "cache_error"
|
|
130
|
+
message = "Cache operation failed"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ExternalServiceError(AppException):
|
|
134
|
+
status_code = 502
|
|
135
|
+
error_code = "external_service_error"
|
|
136
|
+
message = "External service request failed"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def error_responses(*excptions: AppException) -> dict[int, dict[str, Any]]:
|
|
140
|
+
responses: dict[int, dict[str, Any]] = {}
|
|
141
|
+
for exception in excptions:
|
|
142
|
+
responses[exception.status_code] = {
|
|
143
|
+
"model": ErrorResponse,
|
|
144
|
+
"description": exception.message,
|
|
145
|
+
}
|
|
146
|
+
return responses
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ExceptionExtension(BaseExtension):
|
|
150
|
+
"""
|
|
151
|
+
Exception handling extension.
|
|
152
|
+
|
|
153
|
+
Catches AppException subclasses and converts to JSON responses.
|
|
154
|
+
Catches unexpected exceptions and returns generic 500s.
|
|
155
|
+
|
|
156
|
+
Usage:
|
|
157
|
+
from qtip import build_app
|
|
158
|
+
from qtip.core.exceptions import ExceptionExtension, ExceptionSettings
|
|
159
|
+
|
|
160
|
+
app = build_app(
|
|
161
|
+
settings,
|
|
162
|
+
extensions=[ExceptionExtension(settings)],
|
|
163
|
+
)
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
167
|
+
self.settings = settings or Settings()
|
|
168
|
+
|
|
169
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
170
|
+
app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[arg-type]
|
|
171
|
+
app.add_exception_handler(Exception, self._handle_unexpected_exception)
|
|
172
|
+
return {}
|
|
173
|
+
|
|
174
|
+
def _handle_app_exception(
|
|
175
|
+
self, request: Request, exc: AppException
|
|
176
|
+
) -> JSONResponse:
|
|
177
|
+
if exc.status_code >= 500 and self.settings.log_server_errors:
|
|
178
|
+
log.exception("server_error", error_code=exc.error_code)
|
|
179
|
+
else:
|
|
180
|
+
log.warning("client_error", error_code=exc.error_code, message=exc.message)
|
|
181
|
+
|
|
182
|
+
response = exc.to_response(request.state.get("request_id", None))
|
|
183
|
+
|
|
184
|
+
headers = {}
|
|
185
|
+
if isinstance(exc, RateLimited) and exc.retry_after:
|
|
186
|
+
headers["Retry-After"] = str(exc.retry_after)
|
|
187
|
+
|
|
188
|
+
return JSONResponse(
|
|
189
|
+
status_code=exc.status_code,
|
|
190
|
+
content=response.model_dump(exclude_none=True, mode="json"),
|
|
191
|
+
headers=headers,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _handle_unexpected_exception(
|
|
195
|
+
self, request: Request, exc: Exception
|
|
196
|
+
) -> JSONResponse:
|
|
197
|
+
log.exception("unexpected_error")
|
|
198
|
+
|
|
199
|
+
response: dict[str, Any] = {
|
|
200
|
+
"error": {
|
|
201
|
+
"code": "internal_error",
|
|
202
|
+
"message": "An unexpected error occurred",
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if hasattr(request.state, "request_id"):
|
|
207
|
+
response["error"]["request_id"] = request.state.request_id
|
|
208
|
+
|
|
209
|
+
if self.settings.debug:
|
|
210
|
+
response["error"]["details"] = {
|
|
211
|
+
"exception": type(exc).__name__,
|
|
212
|
+
"message": str(exc),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return JSONResponse(status_code=500, content=response)
|
|
@@ -0,0 +1,96 @@
|
|
|
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]]:
|
|
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]: ...
|
|
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: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BaseExtension:
|
|
64
|
+
"""
|
|
65
|
+
Base class for extensions with explicit startup/shutdown hooks.
|
|
66
|
+
|
|
67
|
+
For simple extensions, override startup() and shutdown().
|
|
68
|
+
For full control, override register() directly.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, settings: Settings | None = None):
|
|
72
|
+
self.settings = settings or Settings()
|
|
73
|
+
|
|
74
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
75
|
+
"""
|
|
76
|
+
Override to setup resources during app startup.
|
|
77
|
+
|
|
78
|
+
You can call app.include_router(), app.add_exception_handler(), etc.
|
|
79
|
+
Returns a dict of state to merge into lifespan state.
|
|
80
|
+
"""
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
async def shutdown(self, app: FastAPI) -> None:
|
|
84
|
+
"""Override to cleanup resources during app shutdown."""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
@asynccontextmanager
|
|
88
|
+
async def register(
|
|
89
|
+
self, registry: svcs.Registry, app: FastAPI
|
|
90
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
91
|
+
"""Wraps startup/shutdown into async context manager."""
|
|
92
|
+
state = await self.startup(registry, app)
|
|
93
|
+
try:
|
|
94
|
+
yield state
|
|
95
|
+
finally:
|
|
96
|
+
await self.shutdown(app)
|
cuneus/core/logging.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging with structlog and request context.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Awaitable, Callable, MutableMapping
|
|
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, Scope, Send, Receive
|
|
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.types.Processor = structlog.dev.ConsoleRenderer(colors=True)
|
|
41
|
+
if log_settings.log_json:
|
|
42
|
+
renderer = structlog.processors.JSONRenderer()
|
|
43
|
+
|
|
44
|
+
# Configure structlog
|
|
45
|
+
structlog.configure(
|
|
46
|
+
processors=shared_processors
|
|
47
|
+
+ [
|
|
48
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
49
|
+
],
|
|
50
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
51
|
+
cache_logger_on_first_use=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Create formatter for stdlib
|
|
55
|
+
formatter = structlog.stdlib.ProcessorFormatter(
|
|
56
|
+
foreign_pre_chain=shared_processors,
|
|
57
|
+
processors=[
|
|
58
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
59
|
+
renderer,
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Configure root logger
|
|
64
|
+
handler = logging.StreamHandler()
|
|
65
|
+
handler.setFormatter(formatter)
|
|
66
|
+
|
|
67
|
+
root_logger = logging.getLogger()
|
|
68
|
+
root_logger.handlers.clear()
|
|
69
|
+
root_logger.addHandler(handler)
|
|
70
|
+
root_logger.setLevel(log_settings.log_level.upper())
|
|
71
|
+
|
|
72
|
+
# Quiet noisy loggers
|
|
73
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LoggingExtension(BaseExtension):
|
|
77
|
+
"""
|
|
78
|
+
Structured logging extension using structlog.
|
|
79
|
+
|
|
80
|
+
Integrates with stdlib logging so uvicorn and other libraries
|
|
81
|
+
also output through structlog.
|
|
82
|
+
|
|
83
|
+
Usage:
|
|
84
|
+
from qtip import build_app
|
|
85
|
+
from qtip.middleware.logging import LoggingExtension, LoggingSettings
|
|
86
|
+
|
|
87
|
+
app = build_app(
|
|
88
|
+
settings,
|
|
89
|
+
extensions=[LoggingExtension(settings)],
|
|
90
|
+
)
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
94
|
+
self.settings = settings or Settings()
|
|
95
|
+
configure_structlog(settings)
|
|
96
|
+
|
|
97
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
98
|
+
# app.add_middleware(RequestLoggingMiddleware)
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
def middleware(self) -> list[Middleware]:
|
|
102
|
+
return [
|
|
103
|
+
Middleware(
|
|
104
|
+
LoggingMiddleware,
|
|
105
|
+
header_name=self.settings.request_id_header,
|
|
106
|
+
),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class LoggingMiddleware(BaseHTTPMiddleware):
|
|
111
|
+
"""
|
|
112
|
+
Middleware that:
|
|
113
|
+
- Generates request_id
|
|
114
|
+
- Binds it to structlog context
|
|
115
|
+
- Logs request start/end
|
|
116
|
+
- Adds request_id to response headers
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
|
|
120
|
+
self.header_name = header_name
|
|
121
|
+
super().__init__(app)
|
|
122
|
+
|
|
123
|
+
async def dispatch(
|
|
124
|
+
self, request: Request, call_next: Callable[..., Awaitable[Response]]
|
|
125
|
+
) -> Response:
|
|
126
|
+
path = request.url.path
|
|
127
|
+
# Exclude health routes as these are just noise
|
|
128
|
+
# TODO(rmyers): make this configurable
|
|
129
|
+
if path.startswith("/health"):
|
|
130
|
+
return await call_next(request)
|
|
131
|
+
|
|
132
|
+
request_id = request.headers.get(self.header_name) or str(uuid.uuid4())[:8]
|
|
133
|
+
|
|
134
|
+
structlog.contextvars.clear_contextvars()
|
|
135
|
+
structlog.contextvars.bind_contextvars(
|
|
136
|
+
request_id=request_id,
|
|
137
|
+
method=request.method,
|
|
138
|
+
path=path,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
request.state.request_id = request_id
|
|
142
|
+
|
|
143
|
+
log = structlog.stdlib.get_logger("cuneus")
|
|
144
|
+
start_time = time.perf_counter()
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
response = await call_next(request)
|
|
148
|
+
|
|
149
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
150
|
+
log.info(
|
|
151
|
+
f"{request.method} {request.url.path} {response.status_code}",
|
|
152
|
+
status_code=response.status_code,
|
|
153
|
+
duration_ms=round(duration_ms, 2),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
response.headers[self.header_name] = request_id
|
|
157
|
+
return response
|
|
158
|
+
|
|
159
|
+
except Exception:
|
|
160
|
+
raise
|
|
161
|
+
finally:
|
|
162
|
+
structlog.contextvars.clear_contextvars()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Used by httpx for request ID propagation
|
|
166
|
+
request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class RequestIDMiddleware:
|
|
170
|
+
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
|
|
171
|
+
self.app = app
|
|
172
|
+
self.header_name = header_name
|
|
173
|
+
|
|
174
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
175
|
+
if scope["type"] != "http":
|
|
176
|
+
await self.app(scope, receive, send)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
headers = dict(scope.get("headers", []))
|
|
180
|
+
request_id = headers.get(
|
|
181
|
+
self.header_name.lower().encode(), str(uuid.uuid4())[:8].encode()
|
|
182
|
+
).decode()
|
|
183
|
+
|
|
184
|
+
if "state" not in scope:
|
|
185
|
+
scope["state"] = {}
|
|
186
|
+
scope["state"]["request_id"] = request_id
|
|
187
|
+
|
|
188
|
+
# Set contextvar for use in HTTP clients
|
|
189
|
+
token = request_id_ctx.set(request_id)
|
|
190
|
+
|
|
191
|
+
async def send_with_request_id(message: MutableMapping[str, Any]) -> None:
|
|
192
|
+
if message["type"] == "http.response.start":
|
|
193
|
+
headers = list(message.get("headers", []))
|
|
194
|
+
headers.append((self.header_name.encode(), request_id.encode()))
|
|
195
|
+
message["headers"] = headers
|
|
196
|
+
await send(message)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
await self.app(scope, receive, send_with_request_id)
|
|
200
|
+
finally:
|
|
201
|
+
request_id_ctx.reset(token)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# === Public API ===
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_logger(**initial_context: Any) -> structlog.stdlib.BoundLogger:
|
|
208
|
+
"""
|
|
209
|
+
Get a logger with optional initial context.
|
|
210
|
+
|
|
211
|
+
Usage:
|
|
212
|
+
log = get_logger()
|
|
213
|
+
log.info("user logged in", user_id=123)
|
|
214
|
+
"""
|
|
215
|
+
log = structlog.stdlib.get_logger()
|
|
216
|
+
if initial_context:
|
|
217
|
+
log = log.bind(**initial_context)
|
|
218
|
+
return log
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def bind_contextvars(**context: Any) -> None:
|
|
222
|
+
"""
|
|
223
|
+
Bind additional context that will appear in all subsequent logs.
|
|
224
|
+
"""
|
|
225
|
+
structlog.contextvars.bind_contextvars(**context)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_request_id(request: Request) -> str:
|
|
229
|
+
"""Get request ID from request state."""
|
|
230
|
+
return getattr(request.state, "request_id", "-")
|
cuneus/core/settings.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pydantic_settings import (
|
|
5
|
+
BaseSettings,
|
|
6
|
+
PydanticBaseSettingsSource,
|
|
7
|
+
PyprojectTomlConfigSettingsSource,
|
|
8
|
+
SettingsConfigDict,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
DEFAULT_TOOL_NAME = "cuneus"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CuneusBaseSettings(BaseSettings):
|
|
17
|
+
"""
|
|
18
|
+
Base settings that loads from:
|
|
19
|
+
1. pyproject.toml [tool.cuneus] (lowest priority)
|
|
20
|
+
2. .env file
|
|
21
|
+
3. Environment variables (highest priority)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def settings_customise_sources(
|
|
26
|
+
cls,
|
|
27
|
+
settings_cls: type[BaseSettings],
|
|
28
|
+
init_settings: PydanticBaseSettingsSource,
|
|
29
|
+
env_settings: PydanticBaseSettingsSource,
|
|
30
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
31
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
32
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
33
|
+
return (
|
|
34
|
+
init_settings,
|
|
35
|
+
PyprojectTomlConfigSettingsSource(settings_cls),
|
|
36
|
+
env_settings,
|
|
37
|
+
dotenv_settings,
|
|
38
|
+
file_secret_settings,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Settings(CuneusBaseSettings):
|
|
43
|
+
|
|
44
|
+
model_config = SettingsConfigDict(
|
|
45
|
+
env_file=".env",
|
|
46
|
+
env_file_encoding="utf-8",
|
|
47
|
+
extra="allow",
|
|
48
|
+
pyproject_toml_depth=2,
|
|
49
|
+
pyproject_toml_table_header=("tool", DEFAULT_TOOL_NAME),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
app_name: str = "app"
|
|
53
|
+
app_module: str = "app.main:app"
|
|
54
|
+
cli_module: str = "app.main:cli"
|
|
55
|
+
debug: bool = False
|
|
56
|
+
version: str | None = None
|
|
57
|
+
|
|
58
|
+
# logging
|
|
59
|
+
log_level: str = "INFO"
|
|
60
|
+
log_json: bool = False
|
|
61
|
+
log_server_errors: bool = True
|
|
62
|
+
request_id_header: str = "X-Request-ID"
|
|
63
|
+
|
|
64
|
+
# health
|
|
65
|
+
health_enabled: bool = True
|
|
66
|
+
health_prefix: str = "/healthz"
|
cuneus/ext/__init__.py
ADDED
|
File without changes
|
cuneus/ext/health.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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
|
|
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
|
+
|
|
20
|
+
|
|
21
|
+
class HealthStatus(str, Enum):
|
|
22
|
+
HEALTHY = "healthy"
|
|
23
|
+
UNHEALTHY = "unhealthy"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ServiceHealth(BaseModel):
|
|
27
|
+
name: str
|
|
28
|
+
status: HealthStatus
|
|
29
|
+
message: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HealthResponse(BaseModel):
|
|
33
|
+
status: HealthStatus
|
|
34
|
+
version: str | None = None
|
|
35
|
+
services: list[ServiceHealth] = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HealthExtension(BaseExtension):
|
|
39
|
+
"""
|
|
40
|
+
Health check extension using svcs pings.
|
|
41
|
+
|
|
42
|
+
Adds:
|
|
43
|
+
GET /health - Full health check using svcs pings
|
|
44
|
+
GET /health/live - Liveness probe (always 200)
|
|
45
|
+
GET /health/ready - Readiness probe (503 if unhealthy)
|
|
46
|
+
|
|
47
|
+
Usage:
|
|
48
|
+
from qtip import build_app
|
|
49
|
+
from qtip.ext.health import HealthExtension, HealthSettings
|
|
50
|
+
|
|
51
|
+
app = build_app(
|
|
52
|
+
settings,
|
|
53
|
+
extensions=[HealthExtension(settings)],
|
|
54
|
+
)
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
58
|
+
self.settings = settings or Settings()
|
|
59
|
+
|
|
60
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
61
|
+
if not self.settings.health_enabled:
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
router = APIRouter(prefix=self.settings.health_prefix, tags=["health"])
|
|
65
|
+
version = self.settings.version
|
|
66
|
+
|
|
67
|
+
@router.get("", response_model=HealthResponse)
|
|
68
|
+
async def health(services: svcs.fastapi.DepContainer) -> HealthResponse:
|
|
69
|
+
"""Full health check - pings all registered services."""
|
|
70
|
+
pings = services.get_pings()
|
|
71
|
+
|
|
72
|
+
_services: list[ServiceHealth] = []
|
|
73
|
+
overall_healthy = True
|
|
74
|
+
|
|
75
|
+
for ping in pings:
|
|
76
|
+
try:
|
|
77
|
+
await ping.aping()
|
|
78
|
+
_services.append(
|
|
79
|
+
ServiceHealth(
|
|
80
|
+
name=ping.name,
|
|
81
|
+
status=HealthStatus.HEALTHY,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
log.warning("health_check_failed", service=ping.name, error=str(e))
|
|
86
|
+
_services.append(
|
|
87
|
+
ServiceHealth(
|
|
88
|
+
name=ping.name,
|
|
89
|
+
status=HealthStatus.UNHEALTHY,
|
|
90
|
+
message=str(e),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
overall_healthy = False
|
|
94
|
+
|
|
95
|
+
return HealthResponse(
|
|
96
|
+
status=(
|
|
97
|
+
HealthStatus.HEALTHY if overall_healthy else HealthStatus.UNHEALTHY
|
|
98
|
+
),
|
|
99
|
+
version=version,
|
|
100
|
+
services=_services,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@router.get("/live")
|
|
104
|
+
async def liveness() -> dict[str, str]:
|
|
105
|
+
"""Liveness probe - is the process running?"""
|
|
106
|
+
return {"status": "ok"}
|
|
107
|
+
|
|
108
|
+
@router.get("/ready")
|
|
109
|
+
async def readiness(services: svcs.fastapi.DepContainer) -> dict[str, str]:
|
|
110
|
+
"""Readiness probe - can we serve traffic?"""
|
|
111
|
+
from fastapi import HTTPException
|
|
112
|
+
|
|
113
|
+
pings = services.get_pings()
|
|
114
|
+
|
|
115
|
+
for ping in pings:
|
|
116
|
+
try:
|
|
117
|
+
await ping.aping()
|
|
118
|
+
except Exception as e:
|
|
119
|
+
log.warning(
|
|
120
|
+
"readiness_check_failed", service=ping.name, error=str(e)
|
|
121
|
+
)
|
|
122
|
+
raise HTTPException(
|
|
123
|
+
status_code=503, detail=f"{ping.name} unhealthy"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return {"status": "ok"}
|
|
127
|
+
|
|
128
|
+
app.include_router(router)
|
|
129
|
+
return {}
|
cuneus/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cuneus
|
|
3
|
+
Version: 0.2.6
|
|
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>=8.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: redis>=5.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff>=0.3; extra == 'dev'
|
|
37
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'dev'
|
|
38
|
+
Provides-Extra: redis
|
|
39
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# cuneus
|
|
43
|
+
|
|
44
|
+
> _The wedge stone that locks the arch together_
|
|
45
|
+
|
|
46
|
+
**cuneus** is a lightweight lifespan manager for FastAPI applications. It provides a simple pattern for composing extensions that handle startup/shutdown and service registration.
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv add cuneus
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
or
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install cuneus
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# app/main.py
|
|
66
|
+
from fastapi import FastAPI
|
|
67
|
+
from cuneus import build_app, Settings
|
|
68
|
+
|
|
69
|
+
from myapp.extensions import DatabaseExtension
|
|
70
|
+
|
|
71
|
+
class MyAppSettings(Settings):
|
|
72
|
+
my_mood: str = "extatic"
|
|
73
|
+
|
|
74
|
+
app, cli = build_app(
|
|
75
|
+
DatabaseExtension,
|
|
76
|
+
settings=MyAppSettings(),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
app.include_router(my_router)
|
|
80
|
+
|
|
81
|
+
__all__ = ["app", "cli"]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
That's it. Extensions handle their lifecycle, registration, and middleware.
|
|
85
|
+
|
|
86
|
+
## Creating Extensions
|
|
87
|
+
|
|
88
|
+
Use `BaseExtension` for simple cases:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from cuneus import BaseExtension
|
|
92
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
|
93
|
+
import svcs
|
|
94
|
+
|
|
95
|
+
class DatabaseExtension(BaseExtension):
|
|
96
|
+
def __init__(self, settings):
|
|
97
|
+
self.settings = settings
|
|
98
|
+
self.engine: AsyncEngine | None = None
|
|
99
|
+
|
|
100
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
101
|
+
self.engine = create_async_engine(self.settings.database_url)
|
|
102
|
+
|
|
103
|
+
# Register with svcs for dependency injection
|
|
104
|
+
registry.register_value(AsyncEngine, self.engine)
|
|
105
|
+
|
|
106
|
+
# Add routes
|
|
107
|
+
app.include_router(health_router, prefix="/health")
|
|
108
|
+
|
|
109
|
+
# Add exception handlers
|
|
110
|
+
app.add_exception_handler(DBError, self.handle_db_error)
|
|
111
|
+
|
|
112
|
+
# Return state (accessible via request.state.db)
|
|
113
|
+
return {"db": self.engine}
|
|
114
|
+
|
|
115
|
+
async def shutdown(self, app: FastAPI) -> None:
|
|
116
|
+
if self.engine:
|
|
117
|
+
await self.engine.dispose()
|
|
118
|
+
|
|
119
|
+
def middleware(self) -> list[Middleware]:
|
|
120
|
+
return [Middleware(DatabaseLoggingMiddleware, level=INFO)]
|
|
121
|
+
|
|
122
|
+
def register_cli(self, app_cli: click.Group) -> None:
|
|
123
|
+
@app_cli.command()
|
|
124
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
125
|
+
def blow_up_db(workers: int): ...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
For full control, override `register()` directly:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from contextlib import asynccontextmanager
|
|
132
|
+
|
|
133
|
+
class RedisExtension(BaseExtension):
|
|
134
|
+
def __init__(self, settings):
|
|
135
|
+
self.settings = settings
|
|
136
|
+
|
|
137
|
+
@asynccontextmanager
|
|
138
|
+
async def register(self, registry: svcs.Registry, app: FastAPI):
|
|
139
|
+
redis = await aioredis.from_url(self.settings.redis_url)
|
|
140
|
+
registry.register_value(Redis, redis)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
yield {"redis": redis}
|
|
144
|
+
finally:
|
|
145
|
+
await redis.close()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Testing
|
|
149
|
+
|
|
150
|
+
The lifespan exposes a `.registry` attribute for test overrides:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
# test_app.py
|
|
154
|
+
from unittest.mock import Mock
|
|
155
|
+
from starlette.testclient import TestClient
|
|
156
|
+
from myapp import app, lifespan, Database
|
|
157
|
+
|
|
158
|
+
def test_db_error_handling():
|
|
159
|
+
with TestClient(app) as client:
|
|
160
|
+
# Override after app startup
|
|
161
|
+
mock_db = Mock(spec=Database)
|
|
162
|
+
mock_db.get_user.side_effect = Exception("boom")
|
|
163
|
+
lifespan.registry.register_value(Database, mock_db)
|
|
164
|
+
|
|
165
|
+
resp = client.get("/users/42")
|
|
166
|
+
assert resp.status_code == 500
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Settings
|
|
170
|
+
|
|
171
|
+
cuneus includes a base `Settings` class that loads from multiple sources:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from cuneus import Settings
|
|
175
|
+
|
|
176
|
+
class AppSettings(Settings):
|
|
177
|
+
database_url: str = "sqlite+aiosqlite:///./app.db"
|
|
178
|
+
redis_url: str = "redis://localhost"
|
|
179
|
+
|
|
180
|
+
model_config = SettingsConfigDict(env_prefix="APP_")
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Load priority (highest wins):
|
|
184
|
+
|
|
185
|
+
1. Environment variables
|
|
186
|
+
2. `.env` file
|
|
187
|
+
3. `pyproject.toml` under `[tool.cuneus]`
|
|
188
|
+
|
|
189
|
+
## API Reference
|
|
190
|
+
|
|
191
|
+
### `build_lifespan(settings, *extensions)`
|
|
192
|
+
|
|
193
|
+
Creates a lifespan context manager for FastAPI.
|
|
194
|
+
|
|
195
|
+
- `settings`: Your settings instance (subclass of `Settings`)
|
|
196
|
+
- `*extensions`: Extension instances to register
|
|
197
|
+
|
|
198
|
+
Returns a lifespan with a `.registry` attribute for testing.
|
|
199
|
+
|
|
200
|
+
### `BaseExtension`
|
|
201
|
+
|
|
202
|
+
Base class with `startup()` and `shutdown()` hooks:
|
|
203
|
+
|
|
204
|
+
- `startup(registry, app) -> dict[str, Any]`: Setup resources, return state
|
|
205
|
+
- `shutdown(app) -> None`: Cleanup resources
|
|
206
|
+
- `middleware() -> list[Middleware]`: Optional middleware to configure
|
|
207
|
+
- `register_cli(group) -> None`: Optional hook to add click commands
|
|
208
|
+
|
|
209
|
+
### `Extension` Protocol
|
|
210
|
+
|
|
211
|
+
For full control, implement the protocol directly:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
def register(self, registry: svcs.Registry, app: FastAPI) -> AsyncContextManager[dict[str, Any]]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Accessors
|
|
218
|
+
|
|
219
|
+
- `aget(request, *types)` - Async get services from svcs
|
|
220
|
+
- `get(request, *types)` - Sync get services from svcs
|
|
221
|
+
- `get_settings(request)` - Get settings from request state
|
|
222
|
+
- `get_request_id(request)` - Get request ID from request state
|
|
223
|
+
|
|
224
|
+
## Why cuneus?
|
|
225
|
+
|
|
226
|
+
- **Simple** — one function, `build_app()`, does what you need
|
|
227
|
+
- **Testable** — registry exposed via `lifespan.registry`
|
|
228
|
+
- **Composable** — extensions are just async context managers
|
|
229
|
+
- **Built on svcs** — proper dependency injection, not global state
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
cuneus/__init__.py,sha256=JJ3nZ4757GU9KKuurxP1FfJSdSVrcO-xaorLFSvUJ5E,1211
|
|
2
|
+
cuneus/cli.py,sha256=EYrQBBrBJVibGVGjrAgkJ5S4YAukub16HHkm-GaE4UY,3823
|
|
3
|
+
cuneus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
cuneus/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
cuneus/core/application.py,sha256=5nHe3y8FNV1-IKVcdx0Agnoy73xAcdUGjlEzLVNF7tA,4279
|
|
6
|
+
cuneus/core/execptions.py,sha256=beQE3gD-14BUK4Se6yE2J2U92xgr0yarVmVeibkudxs,5753
|
|
7
|
+
cuneus/core/extensions.py,sha256=qqBnAD_wN6wTTun7C2hfVqxHhA3WgScNSrgRTMsYa04,2515
|
|
8
|
+
cuneus/core/logging.py,sha256=m9qRXAJ4uqcT-NbLn1W3SpSY8DystRRpH9N8jENcyX4,6945
|
|
9
|
+
cuneus/core/settings.py,sha256=PaYXQ_ubeSt3AFpxNNErii-h1_ehHYPrajFWRT42mTI,1703
|
|
10
|
+
cuneus/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
cuneus/ext/health.py,sha256=5dWVVEPFL1tWFBhQwZv8C-IvZRzg28V-4sk_g1jJ0vc,3854
|
|
12
|
+
cuneus-0.2.6.dist-info/METADATA,sha256=Px89iKewk0sRhpZmde9snrTzuMsNkaID7NDU4XHatm8,6794
|
|
13
|
+
cuneus-0.2.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
cuneus-0.2.6.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
|
|
15
|
+
cuneus-0.2.6.dist-info/RECORD,,
|