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 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()
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()
@@ -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,,
@@ -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