cuneus 0.2.1__py3-none-any.whl → 0.2.2__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 +129 -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.2.dist-info}/METADATA +24 -15
- cuneus-0.2.2.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.2.dist-info}/WHEEL +0 -0
- {cuneus-0.2.1.dist-info → cuneus-0.2.2.dist-info}/entry_points.txt +0 -0
|
@@ -4,17 +4,21 @@ Structured logging with structlog and request context.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
from contextvars import ContextVar
|
|
7
8
|
import logging
|
|
8
9
|
import time
|
|
9
10
|
import uuid
|
|
10
|
-
from typing import Any,
|
|
11
|
+
from typing import Any, Awaitable, Callable, MutableMapping
|
|
11
12
|
|
|
12
13
|
import structlog
|
|
13
14
|
import svcs
|
|
14
15
|
from fastapi import FastAPI, Request, Response
|
|
15
16
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.middleware import Middleware
|
|
18
|
+
from starlette.types import ASGIApp, Scope, Send, Receive
|
|
16
19
|
|
|
17
|
-
from
|
|
20
|
+
from .extensions import BaseExtension
|
|
21
|
+
from .settings import Settings
|
|
18
22
|
|
|
19
23
|
|
|
20
24
|
class LoggingExtension(BaseExtension):
|
|
@@ -34,8 +38,8 @@ class LoggingExtension(BaseExtension):
|
|
|
34
38
|
)
|
|
35
39
|
"""
|
|
36
40
|
|
|
37
|
-
def __init__(self, settings: Settings) -> None:
|
|
38
|
-
self.settings = settings
|
|
41
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
42
|
+
self.settings = settings or Settings()
|
|
39
43
|
self._configure_structlog()
|
|
40
44
|
|
|
41
45
|
def _configure_structlog(self) -> None:
|
|
@@ -52,10 +56,9 @@ class LoggingExtension(BaseExtension):
|
|
|
52
56
|
structlog.processors.UnicodeDecoder(),
|
|
53
57
|
]
|
|
54
58
|
|
|
59
|
+
renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(colors=True)
|
|
55
60
|
if settings.log_json:
|
|
56
61
|
renderer = structlog.processors.JSONRenderer()
|
|
57
|
-
else:
|
|
58
|
-
renderer = structlog.dev.ConsoleRenderer(colors=True)
|
|
59
62
|
|
|
60
63
|
# Configure structlog
|
|
61
64
|
structlog.configure(
|
|
@@ -92,6 +95,14 @@ class LoggingExtension(BaseExtension):
|
|
|
92
95
|
# app.add_middleware(RequestLoggingMiddleware)
|
|
93
96
|
return {}
|
|
94
97
|
|
|
98
|
+
def middleware(self) -> list[Middleware]:
|
|
99
|
+
return [
|
|
100
|
+
Middleware(
|
|
101
|
+
LoggingMiddleware,
|
|
102
|
+
header_name=self.settings.request_id_header,
|
|
103
|
+
),
|
|
104
|
+
]
|
|
105
|
+
|
|
95
106
|
|
|
96
107
|
class LoggingMiddleware(BaseHTTPMiddleware):
|
|
97
108
|
"""
|
|
@@ -102,8 +113,14 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|
|
102
113
|
- Adds request_id to response headers
|
|
103
114
|
"""
|
|
104
115
|
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
|
|
117
|
+
self.header_name = header_name
|
|
118
|
+
super().__init__(app)
|
|
119
|
+
|
|
120
|
+
async def dispatch(
|
|
121
|
+
self, request: Request, call_next: Callable[..., Awaitable[Response]]
|
|
122
|
+
) -> Response:
|
|
123
|
+
request_id = request.headers.get(self.header_name) or str(uuid.uuid4())[:8]
|
|
107
124
|
|
|
108
125
|
structlog.contextvars.clear_contextvars()
|
|
109
126
|
structlog.contextvars.bind_contextvars(
|
|
@@ -127,7 +144,7 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|
|
127
144
|
duration_ms=round(duration_ms, 2),
|
|
128
145
|
)
|
|
129
146
|
|
|
130
|
-
response.headers[
|
|
147
|
+
response.headers[self.header_name] = request_id
|
|
131
148
|
return response
|
|
132
149
|
|
|
133
150
|
except Exception:
|
|
@@ -136,6 +153,45 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|
|
136
153
|
structlog.contextvars.clear_contextvars()
|
|
137
154
|
|
|
138
155
|
|
|
156
|
+
# Used by httpx for request ID propagation
|
|
157
|
+
request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RequestIDMiddleware:
|
|
161
|
+
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
|
|
162
|
+
self.app = app
|
|
163
|
+
self.header_name = header_name
|
|
164
|
+
|
|
165
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
166
|
+
if scope["type"] != "http":
|
|
167
|
+
await self.app(scope, receive, send)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
headers = dict(scope.get("headers", []))
|
|
171
|
+
request_id = headers.get(
|
|
172
|
+
self.header_name.lower().encode(), str(uuid.uuid4())[:8].encode()
|
|
173
|
+
).decode()
|
|
174
|
+
|
|
175
|
+
if "state" not in scope:
|
|
176
|
+
scope["state"] = {}
|
|
177
|
+
scope["state"]["request_id"] = request_id
|
|
178
|
+
|
|
179
|
+
# Set contextvar for use in HTTP clients
|
|
180
|
+
token = request_id_ctx.set(request_id)
|
|
181
|
+
|
|
182
|
+
async def send_with_request_id(message: MutableMapping[str, Any]) -> None:
|
|
183
|
+
if message["type"] == "http.response.start":
|
|
184
|
+
headers = list(message.get("headers", []))
|
|
185
|
+
headers.append((self.header_name.encode(), request_id.encode()))
|
|
186
|
+
message["headers"] = headers
|
|
187
|
+
await send(message)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
await self.app(scope, receive, send_with_request_id)
|
|
191
|
+
finally:
|
|
192
|
+
request_id_ctx.reset(token)
|
|
193
|
+
|
|
194
|
+
|
|
139
195
|
# === Public API ===
|
|
140
196
|
|
|
141
197
|
|
|
@@ -147,7 +203,7 @@ def get_logger(**initial_context: Any) -> structlog.stdlib.BoundLogger:
|
|
|
147
203
|
log = get_logger()
|
|
148
204
|
log.info("user logged in", user_id=123)
|
|
149
205
|
"""
|
|
150
|
-
log = structlog.get_logger()
|
|
206
|
+
log = structlog.stdlib.get_logger()
|
|
151
207
|
if initial_context:
|
|
152
208
|
log = log.bind(**initial_context)
|
|
153
209
|
return log
|
cuneus/core/settings.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pydantic_settings import (
|
|
5
|
+
BaseSettings,
|
|
6
|
+
PydanticBaseSettingsSource,
|
|
7
|
+
PyprojectTomlConfigSettingsSource,
|
|
8
|
+
SettingsConfigDict,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
DEFAULT_TOOL_NAME = "cuneus"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CuneusBaseSettings(BaseSettings):
|
|
17
|
+
"""
|
|
18
|
+
Base settings that loads from:
|
|
19
|
+
1. pyproject.toml [tool.cuneus] (lowest priority)
|
|
20
|
+
2. .env file
|
|
21
|
+
3. Environment variables (highest priority)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def settings_customise_sources(
|
|
26
|
+
cls,
|
|
27
|
+
settings_cls: type[BaseSettings],
|
|
28
|
+
init_settings: PydanticBaseSettingsSource,
|
|
29
|
+
env_settings: PydanticBaseSettingsSource,
|
|
30
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
31
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
32
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
33
|
+
return (
|
|
34
|
+
init_settings,
|
|
35
|
+
PyprojectTomlConfigSettingsSource(settings_cls),
|
|
36
|
+
env_settings,
|
|
37
|
+
dotenv_settings,
|
|
38
|
+
file_secret_settings,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Settings(CuneusBaseSettings):
|
|
43
|
+
|
|
44
|
+
model_config = SettingsConfigDict(
|
|
45
|
+
env_file=".env",
|
|
46
|
+
env_file_encoding="utf-8",
|
|
47
|
+
extra="allow",
|
|
48
|
+
pyproject_toml_depth=2,
|
|
49
|
+
pyproject_toml_table_header=("tool", DEFAULT_TOOL_NAME),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
app_name: str = "app"
|
|
53
|
+
app_module: str = "app.main:app"
|
|
54
|
+
cli_module: str = "app.main:cli"
|
|
55
|
+
debug: bool = False
|
|
56
|
+
version: str | None = None
|
|
57
|
+
|
|
58
|
+
# logging
|
|
59
|
+
log_level: str = "INFO"
|
|
60
|
+
log_json: bool = False
|
|
61
|
+
log_server_errors: bool = True
|
|
62
|
+
request_id_header: str = "X-Request-ID"
|
|
63
|
+
|
|
64
|
+
# health
|
|
65
|
+
health_enabled: bool = True
|
|
66
|
+
health_prefix: str = "/healthz"
|
cuneus/ext/health.py
CHANGED
|
@@ -9,10 +9,11 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
import structlog
|
|
11
11
|
import svcs
|
|
12
|
-
from fastapi import APIRouter, FastAPI
|
|
12
|
+
from fastapi import APIRouter, FastAPI
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
|
-
from
|
|
15
|
+
from ..core.extensions import BaseExtension
|
|
16
|
+
from ..core.settings import Settings
|
|
16
17
|
|
|
17
18
|
log = structlog.get_logger()
|
|
18
19
|
|
|
@@ -53,8 +54,8 @@ class HealthExtension(BaseExtension):
|
|
|
53
54
|
)
|
|
54
55
|
"""
|
|
55
56
|
|
|
56
|
-
def __init__(self, settings: Settings) -> None:
|
|
57
|
-
self.settings = settings
|
|
57
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
58
|
+
self.settings = settings or Settings()
|
|
58
59
|
|
|
59
60
|
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
60
61
|
if not self.settings.health_enabled:
|
|
@@ -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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
cuneus/__init__.py,sha256=JJ3nZ4757GU9KKuurxP1FfJSdSVrcO-xaorLFSvUJ5E,1211
|
|
2
|
+
cuneus/cli.py,sha256=pdtoGmjn8NFuOcxaqAlBJfrkLOZI9fS0NEWo5WVSaUo,3480
|
|
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=MlVjGWjuKGut5YaIP8BdjuCXFi-tpCwhL4g6N5NRL4U,3773
|
|
6
|
+
cuneus/core/execptions.py,sha256=beQE3gD-14BUK4Se6yE2J2U92xgr0yarVmVeibkudxs,5753
|
|
7
|
+
cuneus/core/extensions.py,sha256=wdsn5DSHSrzduQwwLgKr38hgvQJi6zsh3MNnE1mINF0,2586
|
|
8
|
+
cuneus/core/logging.py,sha256=OlcWxBCLDqBORzTXZXKlMc_rGD8OkfOBfHgSwEpMCM4,6778
|
|
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.2.dist-info/METADATA,sha256=ItNul_KF_Lgjh2zU9cCm4SAqSdAR-Nqe7XDyGsQhB08,6794
|
|
13
|
+
cuneus-0.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
cuneus-0.2.2.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
|
|
15
|
+
cuneus-0.2.2.dist-info/RECORD,,
|