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 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()
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", "-")
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cuneus = cuneus.cli:main