cuneus 0.2.1__tar.gz → 0.2.2__tar.gz
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-0.2.1 → cuneus-0.2.2}/PKG-INFO +24 -15
- {cuneus-0.2.1 → cuneus-0.2.2}/README.md +22 -13
- {cuneus-0.2.1 → cuneus-0.2.2}/pyproject.toml +4 -4
- {cuneus-0.2.1 → cuneus-0.2.2}/src/cuneus/__init__.py +9 -13
- cuneus-0.2.2/src/cuneus/cli.py +129 -0
- cuneus-0.2.2/src/cuneus/core/application.py +127 -0
- {cuneus-0.2.1 → cuneus-0.2.2}/src/cuneus/core/execptions.py +6 -5
- cuneus-0.2.2/src/cuneus/core/extensions.py +98 -0
- {cuneus-0.2.1/src/cuneus/middleware → cuneus-0.2.2/src/cuneus/core}/logging.py +66 -10
- cuneus-0.2.2/src/cuneus/core/settings.py +66 -0
- {cuneus-0.2.1 → cuneus-0.2.2}/src/cuneus/ext/health.py +5 -4
- cuneus-0.2.2/tests/test_integration.py +14 -0
- {cuneus-0.2.1 → cuneus-0.2.2}/uv.lock +2 -174
- cuneus-0.2.1/src/cuneus/cli/__init__.py +0 -394
- cuneus-0.2.1/src/cuneus/cli/console.py +0 -208
- cuneus-0.2.1/src/cuneus/core/application.py +0 -230
- cuneus-0.2.1/src/cuneus/middleware/__init__.py +0 -0
- cuneus-0.2.1/tests/test_integration.py +0 -25
- {cuneus-0.2.1 → cuneus-0.2.2}/.gitignore +0 -0
- {cuneus-0.2.1 → cuneus-0.2.2}/.python-version +0 -0
- {cuneus-0.2.1 → cuneus-0.2.2}/src/cuneus/core/__init__.py +0 -0
- {cuneus-0.2.1 → cuneus-0.2.2}/src/cuneus/ext/__init__.py +0 -0
- {cuneus-0.2.1 → cuneus-0.2.2}/src/cuneus/py.typed +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cuneus
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: ASGI application wrapper
|
|
5
5
|
Project-URL: Homepage, https://github.com/rmyers/cuneus
|
|
6
6
|
Project-URL: Documentation, https://github.com/rmyers/cuneus#readme
|
|
7
7
|
Project-URL: Repository, https://github.com/rmyers/cuneus
|
|
8
8
|
Author-email: Robert Myers <robert@julython.org>
|
|
9
|
-
Requires-Python: >=3.
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
10
|
Requires-Dist: click>=8.0
|
|
11
11
|
Requires-Dist: fastapi>=0.109.0
|
|
12
12
|
Requires-Dist: pydantic-settings>=2.0
|
|
@@ -62,26 +62,26 @@ pip install cuneus
|
|
|
62
62
|
## Quick Start
|
|
63
63
|
|
|
64
64
|
```python
|
|
65
|
-
# app.py
|
|
65
|
+
# app/main.py
|
|
66
66
|
from fastapi import FastAPI
|
|
67
|
-
from cuneus import
|
|
68
|
-
from cuneus.middleware.logging import LoggingMiddleware
|
|
67
|
+
from cuneus import build_app, Settings
|
|
69
68
|
|
|
70
69
|
from myapp.extensions import DatabaseExtension
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
class MyAppSettings(Settings):
|
|
72
|
+
my_mood: str = "extatic"
|
|
73
|
+
|
|
74
|
+
app, cli = build_app(
|
|
75
|
+
DatabaseExtension,
|
|
76
|
+
settings=MyAppSettings(),
|
|
76
77
|
)
|
|
77
78
|
|
|
78
|
-
app
|
|
79
|
+
app.include_router(my_router)
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
app.add_middleware(LoggingMiddleware)
|
|
81
|
+
__all__ = ["app", "cli"]
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
That's it. Extensions handle their lifecycle,
|
|
84
|
+
That's it. Extensions handle their lifecycle, registration, and middleware.
|
|
85
85
|
|
|
86
86
|
## Creating Extensions
|
|
87
87
|
|
|
@@ -115,6 +115,14 @@ class DatabaseExtension(BaseExtension):
|
|
|
115
115
|
async def shutdown(self, app: FastAPI) -> None:
|
|
116
116
|
if self.engine:
|
|
117
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): ...
|
|
118
126
|
```
|
|
119
127
|
|
|
120
128
|
For full control, override `register()` directly:
|
|
@@ -195,6 +203,8 @@ Base class with `startup()` and `shutdown()` hooks:
|
|
|
195
203
|
|
|
196
204
|
- `startup(registry, app) -> dict[str, Any]`: Setup resources, return state
|
|
197
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
|
|
198
208
|
|
|
199
209
|
### `Extension` Protocol
|
|
200
210
|
|
|
@@ -213,8 +223,7 @@ def register(self, registry: svcs.Registry, app: FastAPI) -> AsyncContextManager
|
|
|
213
223
|
|
|
214
224
|
## Why cuneus?
|
|
215
225
|
|
|
216
|
-
- **Simple** — one function, `
|
|
217
|
-
- **No magic** — middleware added directly to FastAPI, not hidden
|
|
226
|
+
- **Simple** — one function, `build_app()`, does what you need
|
|
218
227
|
- **Testable** — registry exposed via `lifespan.registry`
|
|
219
228
|
- **Composable** — extensions are just async context managers
|
|
220
229
|
- **Built on svcs** — proper dependency injection, not global state
|
|
@@ -21,26 +21,26 @@ pip install cuneus
|
|
|
21
21
|
## Quick Start
|
|
22
22
|
|
|
23
23
|
```python
|
|
24
|
-
# app.py
|
|
24
|
+
# app/main.py
|
|
25
25
|
from fastapi import FastAPI
|
|
26
|
-
from cuneus import
|
|
27
|
-
from cuneus.middleware.logging import LoggingMiddleware
|
|
26
|
+
from cuneus import build_app, Settings
|
|
28
27
|
|
|
29
28
|
from myapp.extensions import DatabaseExtension
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
class MyAppSettings(Settings):
|
|
31
|
+
my_mood: str = "extatic"
|
|
32
|
+
|
|
33
|
+
app, cli = build_app(
|
|
34
|
+
DatabaseExtension,
|
|
35
|
+
settings=MyAppSettings(),
|
|
35
36
|
)
|
|
36
37
|
|
|
37
|
-
app
|
|
38
|
+
app.include_router(my_router)
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
app.add_middleware(LoggingMiddleware)
|
|
40
|
+
__all__ = ["app", "cli"]
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
That's it. Extensions handle their lifecycle,
|
|
43
|
+
That's it. Extensions handle their lifecycle, registration, and middleware.
|
|
44
44
|
|
|
45
45
|
## Creating Extensions
|
|
46
46
|
|
|
@@ -74,6 +74,14 @@ class DatabaseExtension(BaseExtension):
|
|
|
74
74
|
async def shutdown(self, app: FastAPI) -> None:
|
|
75
75
|
if self.engine:
|
|
76
76
|
await self.engine.dispose()
|
|
77
|
+
|
|
78
|
+
def middleware(self) -> list[Middleware]:
|
|
79
|
+
return [Middleware(DatabaseLoggingMiddleware, level=INFO)]
|
|
80
|
+
|
|
81
|
+
def register_cli(self, app_cli: click.Group) -> None:
|
|
82
|
+
@app_cli.command()
|
|
83
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
84
|
+
def blow_up_db(workers: int): ...
|
|
77
85
|
```
|
|
78
86
|
|
|
79
87
|
For full control, override `register()` directly:
|
|
@@ -154,6 +162,8 @@ Base class with `startup()` and `shutdown()` hooks:
|
|
|
154
162
|
|
|
155
163
|
- `startup(registry, app) -> dict[str, Any]`: Setup resources, return state
|
|
156
164
|
- `shutdown(app) -> None`: Cleanup resources
|
|
165
|
+
- `middleware() -> list[Middleware]`: Optional middleware to configure
|
|
166
|
+
- `register_cli(group) -> None`: Optional hook to add click commands
|
|
157
167
|
|
|
158
168
|
### `Extension` Protocol
|
|
159
169
|
|
|
@@ -172,8 +182,7 @@ def register(self, registry: svcs.Registry, app: FastAPI) -> AsyncContextManager
|
|
|
172
182
|
|
|
173
183
|
## Why cuneus?
|
|
174
184
|
|
|
175
|
-
- **Simple** — one function, `
|
|
176
|
-
- **No magic** — middleware added directly to FastAPI, not hidden
|
|
185
|
+
- **Simple** — one function, `build_app()`, does what you need
|
|
177
186
|
- **Testable** — registry exposed via `lifespan.registry`
|
|
178
187
|
- **Composable** — extensions are just async context managers
|
|
179
188
|
- **Built on svcs** — proper dependency injection, not global state
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cuneus"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "ASGI application wrapper"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Robert Myers", email = "robert@julython.org" }]
|
|
7
|
-
requires-python = ">=3.
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
8
|
|
|
9
9
|
dependencies = [
|
|
10
10
|
"fastapi>=0.109.0",
|
|
@@ -48,14 +48,14 @@ build-backend = "hatchling.build"
|
|
|
48
48
|
packages = ["src/cuneus"]
|
|
49
49
|
|
|
50
50
|
[tool.ruff]
|
|
51
|
-
target-version = "
|
|
51
|
+
target-version = "py312"
|
|
52
52
|
line-length = 100
|
|
53
53
|
|
|
54
54
|
[tool.ruff.lint]
|
|
55
55
|
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
|
|
56
56
|
|
|
57
57
|
[tool.mypy]
|
|
58
|
-
python_version = "3.
|
|
58
|
+
python_version = "3.12"
|
|
59
59
|
strict = true
|
|
60
60
|
warn_return_any = true
|
|
61
61
|
warn_unused_ignores = true
|
|
@@ -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",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Base CLI that cuneus provides."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import importlib
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from .core.settings import Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def import_from_string(import_str: str) -> Any:
|
|
12
|
+
"""Import an object from a module:attribute string."""
|
|
13
|
+
module_path, _, attr = import_str.partition(":")
|
|
14
|
+
if not attr:
|
|
15
|
+
attr = "app" # default attribute name
|
|
16
|
+
|
|
17
|
+
module = importlib.import_module(module_path)
|
|
18
|
+
return getattr(module, attr)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_user_cli() -> click.Group | None:
|
|
22
|
+
"""Attempt to load user's CLI from config."""
|
|
23
|
+
config = Settings()
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
return cast(click.Group, import_from_string(config.cli_module))
|
|
27
|
+
except (ImportError, AttributeError) as e:
|
|
28
|
+
click.echo(
|
|
29
|
+
f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.group()
|
|
36
|
+
@click.pass_context
|
|
37
|
+
def cli(ctx: click.Context) -> None:
|
|
38
|
+
"""Cuneus CLI - FastAPI application framework."""
|
|
39
|
+
ctx.ensure_object(dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@cli.command()
|
|
43
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
44
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
45
|
+
def dev(host: str, port: int) -> None:
|
|
46
|
+
"""Run the application server."""
|
|
47
|
+
import uvicorn
|
|
48
|
+
|
|
49
|
+
config = Settings()
|
|
50
|
+
|
|
51
|
+
uvicorn.run(
|
|
52
|
+
config.app_module,
|
|
53
|
+
host=host,
|
|
54
|
+
port=port,
|
|
55
|
+
reload=True,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@cli.command()
|
|
60
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
61
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
62
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
63
|
+
def prod(host: str, port: int, workers: int) -> None:
|
|
64
|
+
"""Run the application server."""
|
|
65
|
+
import uvicorn
|
|
66
|
+
|
|
67
|
+
config = Settings()
|
|
68
|
+
|
|
69
|
+
uvicorn.run(
|
|
70
|
+
config.app_module,
|
|
71
|
+
host=host,
|
|
72
|
+
port=port,
|
|
73
|
+
workers=workers,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@cli.command()
|
|
78
|
+
def routes() -> None:
|
|
79
|
+
"""List all registered routes."""
|
|
80
|
+
config = Settings()
|
|
81
|
+
app = import_from_string(config.app_module)
|
|
82
|
+
|
|
83
|
+
for route in app.routes:
|
|
84
|
+
if hasattr(route, "methods"):
|
|
85
|
+
methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
|
|
86
|
+
click.echo(f"{methods:8} {route.path}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CuneusCLI(click.Group):
|
|
90
|
+
"""Merges base cuneus commands with user's app CLI."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
93
|
+
super().__init__(*args, **kwargs)
|
|
94
|
+
self._user_cli: click.Group | None = None
|
|
95
|
+
self._user_cli_loaded = False
|
|
96
|
+
|
|
97
|
+
# Register base commands directly
|
|
98
|
+
self.add_command(dev)
|
|
99
|
+
self.add_command(prod)
|
|
100
|
+
self.add_command(routes)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def user_cli(self) -> click.Group | None:
|
|
104
|
+
if not self._user_cli_loaded:
|
|
105
|
+
self._user_cli = get_user_cli()
|
|
106
|
+
self._user_cli_loaded = True
|
|
107
|
+
return self._user_cli
|
|
108
|
+
|
|
109
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
110
|
+
commands = set(super().list_commands(ctx))
|
|
111
|
+
if self.user_cli:
|
|
112
|
+
commands.update(self.user_cli.list_commands(ctx))
|
|
113
|
+
return sorted(commands)
|
|
114
|
+
|
|
115
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
116
|
+
# User CLI takes priority
|
|
117
|
+
if self.user_cli:
|
|
118
|
+
cmd = self.user_cli.get_command(ctx, cmd_name)
|
|
119
|
+
if cmd:
|
|
120
|
+
return cmd
|
|
121
|
+
return super().get_command(ctx, cmd_name)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# This is the actual entry point
|
|
125
|
+
main = CuneusCLI(help="Cuneus CLI - FastAPI application framework")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
10
|
+
from typing import Any, AsyncIterator, Callable
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import svcs
|
|
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]:
|
|
52
|
+
"""
|
|
53
|
+
Build a FastAPI with extensions preconfigured.
|
|
54
|
+
|
|
55
|
+
The returned lifespan has a `.registry` attribute for testing overrides.
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
from cuneus import build_app, Settings, SettingsExtension
|
|
59
|
+
from myapp.extensions import DatabaseExtension
|
|
60
|
+
|
|
61
|
+
settings = Settings()
|
|
62
|
+
app, cli = build_app(
|
|
63
|
+
SettingsExtension(settings),
|
|
64
|
+
DatabaseExtension(settings),
|
|
65
|
+
title="Args are passed to FastAPI",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__all__ = ["app", "cli"]
|
|
69
|
+
|
|
70
|
+
Testing:
|
|
71
|
+
from myapp import app, lifespan
|
|
72
|
+
|
|
73
|
+
def test_with_mock_db(client):
|
|
74
|
+
mock_db = Mock(spec=Database)
|
|
75
|
+
lifespan.registry.register_value(Database, mock_db)
|
|
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)
|
|
99
|
+
|
|
100
|
+
@svcs.fastapi.lifespan
|
|
101
|
+
@asynccontextmanager
|
|
102
|
+
async def lifespan(
|
|
103
|
+
app: FastAPI, registry: svcs.Registry
|
|
104
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
105
|
+
async with AsyncExitStack() as stack:
|
|
106
|
+
state: dict[str, Any] = {}
|
|
107
|
+
|
|
108
|
+
for ext in all_extensions:
|
|
109
|
+
ext_state = await stack.enter_async_context(ext.register(registry, app))
|
|
110
|
+
if ext_state:
|
|
111
|
+
if overlap := state.keys() & ext_state.keys():
|
|
112
|
+
raise ValueError(f"Extension state key collision: {overlap}")
|
|
113
|
+
state.update(ext_state)
|
|
114
|
+
|
|
115
|
+
yield state
|
|
116
|
+
|
|
117
|
+
# Parse extensions for middleware and cli commands
|
|
118
|
+
middleware: list[Middleware] = []
|
|
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)
|
|
125
|
+
|
|
126
|
+
app = FastAPI(lifespan=lifespan, middleware=middleware, **fastapi_kwargs)
|
|
127
|
+
return app, app_cli
|
|
@@ -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)
|