cuneus 0.2.1__py3-none-any.whl → 0.2.3__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 +9 -13
- cuneus/cli.py +136 -0
- cuneus/core/application.py +79 -182
- cuneus/core/execptions.py +6 -5
- cuneus/core/extensions.py +98 -0
- cuneus/{middleware → core}/logging.py +66 -10
- cuneus/core/settings.py +66 -0
- cuneus/ext/health.py +5 -4
- {cuneus-0.2.1.dist-info → cuneus-0.2.3.dist-info}/METADATA +24 -15
- cuneus-0.2.3.dist-info/RECORD +15 -0
- cuneus/cli/__init__.py +0 -394
- cuneus/cli/console.py +0 -208
- cuneus/middleware/__init__.py +0 -0
- cuneus-0.2.1.dist-info/RECORD +0 -15
- {cuneus-0.2.1.dist-info → cuneus-0.2.3.dist-info}/WHEEL +0 -0
- {cuneus-0.2.1.dist-info → cuneus-0.2.3.dist-info}/entry_points.txt +0 -0
cuneus/__init__.py
CHANGED
|
@@ -15,15 +15,8 @@ Example:
|
|
|
15
15
|
fastapi_app = app.build()
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
from
|
|
19
|
-
|
|
20
|
-
Extension,
|
|
21
|
-
Settings,
|
|
22
|
-
build_lifespan,
|
|
23
|
-
get_settings,
|
|
24
|
-
load_pyproject_config,
|
|
25
|
-
)
|
|
26
|
-
from cuneus.core.execptions import (
|
|
18
|
+
from .core.application import build_app
|
|
19
|
+
from .core.execptions import (
|
|
27
20
|
AppException,
|
|
28
21
|
BadRequest,
|
|
29
22
|
Unauthorized,
|
|
@@ -37,16 +30,19 @@ from cuneus.core.execptions import (
|
|
|
37
30
|
ExternalServiceError,
|
|
38
31
|
ExceptionExtension,
|
|
39
32
|
)
|
|
33
|
+
from .core.extensions import BaseExtension, Extension
|
|
34
|
+
from .core.settings import Settings
|
|
40
35
|
|
|
41
36
|
__version__ = "0.2.1"
|
|
42
37
|
__all__ = [
|
|
43
|
-
# Core
|
|
38
|
+
# Core exported functions
|
|
39
|
+
# Application
|
|
40
|
+
"build_app",
|
|
41
|
+
# Extension
|
|
44
42
|
"BaseExtension",
|
|
45
43
|
"Extension",
|
|
44
|
+
# Settings
|
|
46
45
|
"Settings",
|
|
47
|
-
"build_lifespan",
|
|
48
|
-
"get_settings",
|
|
49
|
-
"load_pyproject_config",
|
|
50
46
|
# Exceptions
|
|
51
47
|
"AppException",
|
|
52
48
|
"BadRequest",
|
cuneus/cli.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
module_path, _, attr = import_str.partition(":")
|
|
17
|
+
if not attr:
|
|
18
|
+
attr = "app" # default attribute name
|
|
19
|
+
|
|
20
|
+
module = importlib.import_module(module_path)
|
|
21
|
+
return getattr(module, attr)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_user_cli() -> click.Group | None:
|
|
25
|
+
"""Attempt to load user's CLI from config."""
|
|
26
|
+
config = Settings()
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
return cast(click.Group, import_from_string(config.cli_module))
|
|
30
|
+
except (ImportError, AttributeError) as e:
|
|
31
|
+
click.echo(
|
|
32
|
+
f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group()
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def cli(ctx: click.Context) -> None:
|
|
41
|
+
"""Cuneus CLI - FastAPI application framework."""
|
|
42
|
+
ctx.ensure_object(dict)
|
|
43
|
+
# Ensure cwd is in path for local imports
|
|
44
|
+
cwd = str(Path.cwd())
|
|
45
|
+
if cwd not in sys.path:
|
|
46
|
+
sys.path.insert(0, cwd)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cli.command()
|
|
50
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
51
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
52
|
+
def dev(host: str, port: int) -> None:
|
|
53
|
+
"""Run the application server."""
|
|
54
|
+
import uvicorn
|
|
55
|
+
|
|
56
|
+
config = Settings()
|
|
57
|
+
|
|
58
|
+
uvicorn.run(
|
|
59
|
+
config.app_module,
|
|
60
|
+
host=host,
|
|
61
|
+
port=port,
|
|
62
|
+
reload=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@cli.command()
|
|
67
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
68
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
69
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
70
|
+
def prod(host: str, port: int, workers: int) -> None:
|
|
71
|
+
"""Run the application server."""
|
|
72
|
+
import uvicorn
|
|
73
|
+
|
|
74
|
+
config = Settings()
|
|
75
|
+
|
|
76
|
+
uvicorn.run(
|
|
77
|
+
config.app_module,
|
|
78
|
+
host=host,
|
|
79
|
+
port=port,
|
|
80
|
+
workers=workers,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@cli.command()
|
|
85
|
+
def routes() -> None:
|
|
86
|
+
"""List all registered routes."""
|
|
87
|
+
config = Settings()
|
|
88
|
+
app = import_from_string(config.app_module)
|
|
89
|
+
|
|
90
|
+
for route in app.routes:
|
|
91
|
+
if hasattr(route, "methods"):
|
|
92
|
+
methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
|
|
93
|
+
click.echo(f"{methods:8} {route.path}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CuneusCLI(click.Group):
|
|
97
|
+
"""Merges base cuneus commands with user's app CLI."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
100
|
+
super().__init__(*args, **kwargs)
|
|
101
|
+
self._user_cli: click.Group | None = None
|
|
102
|
+
self._user_cli_loaded = False
|
|
103
|
+
|
|
104
|
+
# Register base commands directly
|
|
105
|
+
self.add_command(dev)
|
|
106
|
+
self.add_command(prod)
|
|
107
|
+
self.add_command(routes)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def user_cli(self) -> click.Group | None:
|
|
111
|
+
if not self._user_cli_loaded:
|
|
112
|
+
self._user_cli = get_user_cli()
|
|
113
|
+
self._user_cli_loaded = True
|
|
114
|
+
return self._user_cli
|
|
115
|
+
|
|
116
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
117
|
+
commands = set(super().list_commands(ctx))
|
|
118
|
+
if self.user_cli:
|
|
119
|
+
commands.update(self.user_cli.list_commands(ctx))
|
|
120
|
+
return sorted(commands)
|
|
121
|
+
|
|
122
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
123
|
+
# User CLI takes priority
|
|
124
|
+
if self.user_cli:
|
|
125
|
+
cmd = self.user_cli.get_command(ctx, cmd_name)
|
|
126
|
+
if cmd:
|
|
127
|
+
return cmd
|
|
128
|
+
return super().get_command(ctx, cmd_name)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# This is the actual entry point
|
|
132
|
+
main = CuneusCLI(help="Cuneus CLI - FastAPI application framework")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|
cuneus/core/application.py
CHANGED
|
@@ -6,152 +6,66 @@ Lightweight lifespan management for FastAPI applications.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
import logging
|
|
10
|
-
import tomllib
|
|
11
|
-
import uuid
|
|
12
9
|
from contextlib import AsyncExitStack, asynccontextmanager
|
|
13
|
-
from
|
|
14
|
-
from typing import Any, AsyncContextManager, AsyncIterator, Protocol, runtime_checkable
|
|
10
|
+
from typing import Any, AsyncIterator, Callable
|
|
15
11
|
|
|
12
|
+
import click
|
|
16
13
|
import svcs
|
|
17
|
-
from fastapi import FastAPI
|
|
18
|
-
from
|
|
19
|
-
|
|
20
|
-
from
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
from starlette.middleware import Middleware
|
|
16
|
+
|
|
17
|
+
from .settings import Settings
|
|
18
|
+
from .execptions import ExceptionExtension
|
|
19
|
+
from .logging import LoggingExtension
|
|
20
|
+
from .extensions import Extension, HasCLI, HasMiddleware
|
|
21
|
+
from ..ext.health import HealthExtension
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
type ExtensionInput = Extension | Callable[..., Extension]
|
|
25
|
+
|
|
26
|
+
DEFAULT_EXTENSIONS = (
|
|
27
|
+
LoggingExtension,
|
|
28
|
+
HealthExtension,
|
|
29
|
+
ExceptionExtension,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _instantiate_extension(
|
|
34
|
+
ext: ExtensionInput, settings: Settings | None = None
|
|
35
|
+
) -> Extension:
|
|
36
|
+
if isinstance(ext, type):
|
|
37
|
+
# It's a class, instantiate it
|
|
38
|
+
return ext(settings)
|
|
39
|
+
if callable(ext):
|
|
40
|
+
# It's a factory function
|
|
41
|
+
return ext(settings)
|
|
42
|
+
# Already an instance
|
|
43
|
+
return ext
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_app(
|
|
47
|
+
*extensions: ExtensionInput,
|
|
48
|
+
settings: Settings | None = None,
|
|
49
|
+
include_defaults: bool = True,
|
|
50
|
+
**fastapi_kwargs: Any,
|
|
51
|
+
) -> tuple[FastAPI, click.Group]:
|
|
51
52
|
"""
|
|
52
|
-
|
|
53
|
-
model_config = SettingsConfigDict(
|
|
54
|
-
env_file=".env",
|
|
55
|
-
env_file_encoding="utf-8",
|
|
56
|
-
extra="ignore",
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
app_name: str = "app"
|
|
60
|
-
app_module: str = "app.main:app"
|
|
61
|
-
debug: bool = False
|
|
62
|
-
version: str | None = None
|
|
63
|
-
|
|
64
|
-
# logging
|
|
65
|
-
log_level: str = "INFO"
|
|
66
|
-
log_json: bool = False
|
|
67
|
-
log_server_errors: bool = True
|
|
68
|
-
|
|
69
|
-
# health
|
|
70
|
-
health_enabled: bool = True
|
|
71
|
-
health_prefix: str = "/healthz"
|
|
72
|
-
|
|
73
|
-
@model_validator(mode="before")
|
|
74
|
-
@classmethod
|
|
75
|
-
def load_from_pyproject(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
76
|
-
pyproject_config = load_pyproject_config()
|
|
77
|
-
return {**pyproject_config, **data}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@runtime_checkable
|
|
81
|
-
class Extension(Protocol):
|
|
82
|
-
"""
|
|
83
|
-
Protocol for extensions that hook into app lifecycle.
|
|
84
|
-
|
|
85
|
-
Extensions can:
|
|
86
|
-
- Register services with svcs
|
|
87
|
-
- Add routes via app.include_router()
|
|
88
|
-
- Add exception handlers via app.add_exception_handler()
|
|
89
|
-
- Return state to merge into lifespan state
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
def register(
|
|
93
|
-
self, registry: svcs.Registry, app: FastAPI
|
|
94
|
-
) -> AsyncContextManager[dict[str, Any]]:
|
|
95
|
-
"""
|
|
96
|
-
Async context manager for lifecycle.
|
|
97
|
-
|
|
98
|
-
- Enter: startup (register services, add routes, etc.)
|
|
99
|
-
- Yield: dict of state to merge into lifespan state
|
|
100
|
-
- Exit: shutdown (cleanup resources)
|
|
101
|
-
"""
|
|
102
|
-
...
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
class BaseExtension:
|
|
106
|
-
"""
|
|
107
|
-
Base class for extensions with explicit startup/shutdown hooks.
|
|
108
|
-
|
|
109
|
-
For simple extensions, override startup() and shutdown().
|
|
110
|
-
For full control, override register() directly.
|
|
111
|
-
"""
|
|
112
|
-
|
|
113
|
-
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
114
|
-
"""
|
|
115
|
-
Override to setup resources during app startup.
|
|
116
|
-
|
|
117
|
-
You can call app.include_router(), app.add_exception_handler(), etc.
|
|
118
|
-
Returns a dict of state to merge into lifespan state.
|
|
119
|
-
"""
|
|
120
|
-
return {}
|
|
121
|
-
|
|
122
|
-
async def shutdown(self, app: FastAPI) -> None:
|
|
123
|
-
"""Override to cleanup resources during app shutdown."""
|
|
124
|
-
pass
|
|
125
|
-
|
|
126
|
-
@asynccontextmanager
|
|
127
|
-
async def register(
|
|
128
|
-
self, registry: svcs.Registry, app: FastAPI
|
|
129
|
-
) -> AsyncIterator[dict[str, Any]]:
|
|
130
|
-
"""Wraps startup/shutdown into async context manager."""
|
|
131
|
-
state = await self.startup(registry, app)
|
|
132
|
-
try:
|
|
133
|
-
yield state
|
|
134
|
-
finally:
|
|
135
|
-
await self.shutdown(app)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def build_lifespan(settings: Settings, *extensions: Extension):
|
|
139
|
-
"""
|
|
140
|
-
Create a lifespan context manager for FastAPI.
|
|
53
|
+
Build a FastAPI with extensions preconfigured.
|
|
141
54
|
|
|
142
55
|
The returned lifespan has a `.registry` attribute for testing overrides.
|
|
143
56
|
|
|
144
57
|
Usage:
|
|
145
|
-
from cuneus import
|
|
58
|
+
from cuneus import build_app, Settings, SettingsExtension
|
|
146
59
|
from myapp.extensions import DatabaseExtension
|
|
147
60
|
|
|
148
61
|
settings = Settings()
|
|
149
|
-
|
|
150
|
-
settings,
|
|
62
|
+
app, cli = build_app(
|
|
63
|
+
SettingsExtension(settings),
|
|
151
64
|
DatabaseExtension(settings),
|
|
65
|
+
title="Args are passed to FastAPI",
|
|
152
66
|
)
|
|
153
67
|
|
|
154
|
-
|
|
68
|
+
__all__ = ["app", "cli"]
|
|
155
69
|
|
|
156
70
|
Testing:
|
|
157
71
|
from myapp import app, lifespan
|
|
@@ -159,8 +73,29 @@ def build_lifespan(settings: Settings, *extensions: Extension):
|
|
|
159
73
|
def test_with_mock_db(client):
|
|
160
74
|
mock_db = Mock(spec=Database)
|
|
161
75
|
lifespan.registry.register_value(Database, mock_db)
|
|
162
|
-
# ...
|
|
163
76
|
"""
|
|
77
|
+
if "lifespan" in fastapi_kwargs:
|
|
78
|
+
raise AttributeError("cannot set lifespan with build_app")
|
|
79
|
+
if "middleware" in fastapi_kwargs:
|
|
80
|
+
raise AttributeError("cannot set middleware with build_app")
|
|
81
|
+
|
|
82
|
+
settings = settings or Settings()
|
|
83
|
+
|
|
84
|
+
if include_defaults:
|
|
85
|
+
# Allow users to override a default extension
|
|
86
|
+
user_types = {type(ext) for ext in extensions}
|
|
87
|
+
defaults = [ext for ext in DEFAULT_EXTENSIONS if type(ext) not in user_types]
|
|
88
|
+
all_inputs = (*defaults, *extensions)
|
|
89
|
+
else:
|
|
90
|
+
all_inputs = extensions
|
|
91
|
+
|
|
92
|
+
all_extensions = [_instantiate_extension(ext, settings) for ext in all_inputs]
|
|
93
|
+
|
|
94
|
+
@click.group()
|
|
95
|
+
@click.pass_context
|
|
96
|
+
def app_cli(ctx: click.Context) -> None:
|
|
97
|
+
"""Application CLI."""
|
|
98
|
+
ctx.ensure_object(dict)
|
|
164
99
|
|
|
165
100
|
@svcs.fastapi.lifespan
|
|
166
101
|
@asynccontextmanager
|
|
@@ -168,9 +103,9 @@ def build_lifespan(settings: Settings, *extensions: Extension):
|
|
|
168
103
|
app: FastAPI, registry: svcs.Registry
|
|
169
104
|
) -> AsyncIterator[dict[str, Any]]:
|
|
170
105
|
async with AsyncExitStack() as stack:
|
|
171
|
-
state: dict[str, Any] = {
|
|
106
|
+
state: dict[str, Any] = {}
|
|
172
107
|
|
|
173
|
-
for ext in
|
|
108
|
+
for ext in all_extensions:
|
|
174
109
|
ext_state = await stack.enter_async_context(ext.register(registry, app))
|
|
175
110
|
if ext_state:
|
|
176
111
|
if overlap := state.keys() & ext_state.keys():
|
|
@@ -179,52 +114,14 @@ def build_lifespan(settings: Settings, *extensions: Extension):
|
|
|
179
114
|
|
|
180
115
|
yield state
|
|
181
116
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
class RequestIDMiddleware:
|
|
186
|
-
"""
|
|
187
|
-
Middleware that adds a unique request_id to each request.
|
|
188
|
-
|
|
189
|
-
Access via request.state.request_id
|
|
190
|
-
"""
|
|
191
|
-
|
|
192
|
-
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID"):
|
|
193
|
-
self.app = app
|
|
194
|
-
self.header_name = header_name
|
|
195
|
-
|
|
196
|
-
async def __call__(self, scope, receive, send):
|
|
197
|
-
if scope["type"] != "http":
|
|
198
|
-
await self.app(scope, receive, send)
|
|
199
|
-
return
|
|
200
|
-
|
|
201
|
-
# Check for existing request ID in headers, or generate new one
|
|
202
|
-
headers = dict(scope.get("headers", []))
|
|
203
|
-
request_id = headers.get(
|
|
204
|
-
self.header_name.lower().encode(), str(uuid.uuid4())[:8].encode()
|
|
205
|
-
).decode()
|
|
206
|
-
|
|
207
|
-
# Store in scope state
|
|
208
|
-
if "state" not in scope:
|
|
209
|
-
scope["state"] = {}
|
|
210
|
-
scope["state"]["request_id"] = request_id
|
|
211
|
-
|
|
212
|
-
# Add request ID to response headers
|
|
213
|
-
async def send_with_request_id(message):
|
|
214
|
-
if message["type"] == "http.response.start":
|
|
215
|
-
headers = list(message.get("headers", []))
|
|
216
|
-
headers.append((self.header_name.encode(), request_id.encode()))
|
|
217
|
-
message["headers"] = headers
|
|
218
|
-
await send(message)
|
|
219
|
-
|
|
220
|
-
await self.app(scope, receive, send_with_request_id)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def get_settings(request: Request) -> Settings:
|
|
224
|
-
"""Get settings from request state."""
|
|
225
|
-
return request.state.settings
|
|
117
|
+
# Parse extensions for middleware and cli commands
|
|
118
|
+
middleware: list[Middleware] = []
|
|
226
119
|
|
|
120
|
+
for ext in all_extensions:
|
|
121
|
+
if isinstance(ext, HasMiddleware):
|
|
122
|
+
middleware.extend(ext.middleware())
|
|
123
|
+
if isinstance(ext, HasCLI):
|
|
124
|
+
ext.register_cli(app_cli)
|
|
227
125
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return request.state.request_id
|
|
126
|
+
app = FastAPI(lifespan=lifespan, middleware=middleware, **fastapi_kwargs)
|
|
127
|
+
return app, app_cli
|
cuneus/core/execptions.py
CHANGED
|
@@ -12,7 +12,8 @@ from fastapi import FastAPI, Request
|
|
|
12
12
|
from fastapi.responses import JSONResponse
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
|
-
from
|
|
15
|
+
from .extensions import BaseExtension
|
|
16
|
+
from .settings import Settings
|
|
16
17
|
|
|
17
18
|
log = structlog.get_logger()
|
|
18
19
|
|
|
@@ -103,7 +104,7 @@ class RateLimited(AppException):
|
|
|
103
104
|
error_code = "rate_limited"
|
|
104
105
|
message = "Too many requests"
|
|
105
106
|
|
|
106
|
-
def __init__(self, retry_after: int | None = None, **kwargs):
|
|
107
|
+
def __init__(self, retry_after: int | None = None, **kwargs: Any) -> None:
|
|
107
108
|
super().__init__(**kwargs)
|
|
108
109
|
self.retry_after = retry_after
|
|
109
110
|
|
|
@@ -162,11 +163,11 @@ class ExceptionExtension(BaseExtension):
|
|
|
162
163
|
)
|
|
163
164
|
"""
|
|
164
165
|
|
|
165
|
-
def __init__(self, settings: Settings) -> None:
|
|
166
|
-
self.settings = settings
|
|
166
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
167
|
+
self.settings = settings or Settings()
|
|
167
168
|
|
|
168
169
|
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
169
|
-
app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[]
|
|
170
|
+
app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[arg-type]
|
|
170
171
|
app.add_exception_handler(Exception, self._handle_unexpected_exception)
|
|
171
172
|
return {}
|
|
172
173
|
|
|
@@ -0,0 +1,98 @@
|
|
|
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 __init__(self, settings: Settings | None = None) -> None: ...
|
|
37
|
+
|
|
38
|
+
def register(
|
|
39
|
+
self, registry: svcs.Registry, app: FastAPI
|
|
40
|
+
) -> AsyncContextManager[dict[str, Any]]:
|
|
41
|
+
"""
|
|
42
|
+
Async context manager for lifecycle.
|
|
43
|
+
|
|
44
|
+
- Enter: startup (register services, add routes, etc.)
|
|
45
|
+
- Yield: dict of state to merge into lifespan state
|
|
46
|
+
- Exit: shutdown (cleanup resources)
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class HasMiddleware(Protocol):
|
|
53
|
+
"""Extension that provides middleware."""
|
|
54
|
+
|
|
55
|
+
def middleware(self) -> list[Middleware]: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@runtime_checkable
|
|
59
|
+
class HasCLI(Protocol):
|
|
60
|
+
"""Extension that provides CLI commands."""
|
|
61
|
+
|
|
62
|
+
def register_cli(self, cli_group: Group) -> None: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BaseExtension:
|
|
66
|
+
"""
|
|
67
|
+
Base class for extensions with explicit startup/shutdown hooks.
|
|
68
|
+
|
|
69
|
+
For simple extensions, override startup() and shutdown().
|
|
70
|
+
For full control, override register() directly.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, settings: Settings | None = None):
|
|
74
|
+
self.settings = settings or Settings()
|
|
75
|
+
|
|
76
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
77
|
+
"""
|
|
78
|
+
Override to setup resources during app startup.
|
|
79
|
+
|
|
80
|
+
You can call app.include_router(), app.add_exception_handler(), etc.
|
|
81
|
+
Returns a dict of state to merge into lifespan state.
|
|
82
|
+
"""
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
async def shutdown(self, app: FastAPI) -> None:
|
|
86
|
+
"""Override to cleanup resources during app shutdown."""
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
@asynccontextmanager
|
|
90
|
+
async def register(
|
|
91
|
+
self, registry: svcs.Registry, app: FastAPI
|
|
92
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
93
|
+
"""Wraps startup/shutdown into async context manager."""
|
|
94
|
+
state = await self.startup(registry, app)
|
|
95
|
+
try:
|
|
96
|
+
yield state
|
|
97
|
+
finally:
|
|
98
|
+
await self.shutdown(app)
|