cuneus 0.2.1__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 +63 -0
- cuneus/cli/__init__.py +394 -0
- cuneus/cli/console.py +208 -0
- cuneus/core/__init__.py +0 -0
- cuneus/core/application.py +230 -0
- cuneus/core/execptions.py +214 -0
- cuneus/ext/__init__.py +0 -0
- cuneus/ext/health.py +128 -0
- cuneus/middleware/__init__.py +0 -0
- cuneus/middleware/logging.py +165 -0
- cuneus/py.typed +0 -0
- cuneus-0.2.1.dist-info/METADATA +224 -0
- cuneus-0.2.1.dist-info/RECORD +15 -0
- cuneus-0.2.1.dist-info/WHEEL +4 -0
- cuneus-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging with structlog and request context.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, AsyncIterator
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
import svcs
|
|
14
|
+
from fastapi import FastAPI, Request, Response
|
|
15
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
16
|
+
|
|
17
|
+
from cuneus.core.application import BaseExtension, Settings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LoggingExtension(BaseExtension):
|
|
21
|
+
"""
|
|
22
|
+
Structured logging extension using structlog.
|
|
23
|
+
|
|
24
|
+
Integrates with stdlib logging so uvicorn and other libraries
|
|
25
|
+
also output through structlog.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
from qtip import build_app
|
|
29
|
+
from qtip.middleware.logging import LoggingExtension, LoggingSettings
|
|
30
|
+
|
|
31
|
+
app = build_app(
|
|
32
|
+
settings,
|
|
33
|
+
extensions=[LoggingExtension(settings)],
|
|
34
|
+
)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, settings: Settings) -> None:
|
|
38
|
+
self.settings = settings
|
|
39
|
+
self._configure_structlog()
|
|
40
|
+
|
|
41
|
+
def _configure_structlog(self) -> None:
|
|
42
|
+
settings = self.settings
|
|
43
|
+
|
|
44
|
+
# Shared processors
|
|
45
|
+
shared_processors: list[structlog.types.Processor] = [
|
|
46
|
+
structlog.contextvars.merge_contextvars,
|
|
47
|
+
structlog.stdlib.add_log_level,
|
|
48
|
+
structlog.stdlib.add_logger_name,
|
|
49
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
50
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
51
|
+
structlog.processors.StackInfoRenderer(),
|
|
52
|
+
structlog.processors.UnicodeDecoder(),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
if settings.log_json:
|
|
56
|
+
renderer = structlog.processors.JSONRenderer()
|
|
57
|
+
else:
|
|
58
|
+
renderer = structlog.dev.ConsoleRenderer(colors=True)
|
|
59
|
+
|
|
60
|
+
# Configure structlog
|
|
61
|
+
structlog.configure(
|
|
62
|
+
processors=shared_processors
|
|
63
|
+
+ [
|
|
64
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
65
|
+
],
|
|
66
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
67
|
+
cache_logger_on_first_use=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Create formatter for stdlib
|
|
71
|
+
formatter = structlog.stdlib.ProcessorFormatter(
|
|
72
|
+
foreign_pre_chain=shared_processors,
|
|
73
|
+
processors=[
|
|
74
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
75
|
+
renderer,
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Configure root logger
|
|
80
|
+
handler = logging.StreamHandler()
|
|
81
|
+
handler.setFormatter(formatter)
|
|
82
|
+
|
|
83
|
+
root_logger = logging.getLogger()
|
|
84
|
+
root_logger.handlers.clear()
|
|
85
|
+
root_logger.addHandler(handler)
|
|
86
|
+
root_logger.setLevel(settings.log_level.upper())
|
|
87
|
+
|
|
88
|
+
# Quiet noisy loggers
|
|
89
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
90
|
+
|
|
91
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
92
|
+
# app.add_middleware(RequestLoggingMiddleware)
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class LoggingMiddleware(BaseHTTPMiddleware):
|
|
97
|
+
"""
|
|
98
|
+
Middleware that:
|
|
99
|
+
- Generates request_id
|
|
100
|
+
- Binds it to structlog context
|
|
101
|
+
- Logs request start/end
|
|
102
|
+
- Adds request_id to response headers
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
106
|
+
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())[:8]
|
|
107
|
+
|
|
108
|
+
structlog.contextvars.clear_contextvars()
|
|
109
|
+
structlog.contextvars.bind_contextvars(
|
|
110
|
+
request_id=request_id,
|
|
111
|
+
method=request.method,
|
|
112
|
+
path=request.url.path,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
request.state.request_id = request_id
|
|
116
|
+
|
|
117
|
+
log = structlog.get_logger()
|
|
118
|
+
start_time = time.perf_counter()
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
response = await call_next(request)
|
|
122
|
+
|
|
123
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
124
|
+
log.info(
|
|
125
|
+
f"{request.method} {request.url.path} {response.status_code}",
|
|
126
|
+
status_code=response.status_code,
|
|
127
|
+
duration_ms=round(duration_ms, 2),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
response.headers["X-Request-ID"] = request_id
|
|
131
|
+
return response
|
|
132
|
+
|
|
133
|
+
except Exception:
|
|
134
|
+
raise
|
|
135
|
+
finally:
|
|
136
|
+
structlog.contextvars.clear_contextvars()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# === Public API ===
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_logger(**initial_context: Any) -> structlog.stdlib.BoundLogger:
|
|
143
|
+
"""
|
|
144
|
+
Get a logger with optional initial context.
|
|
145
|
+
|
|
146
|
+
Usage:
|
|
147
|
+
log = get_logger()
|
|
148
|
+
log.info("user logged in", user_id=123)
|
|
149
|
+
"""
|
|
150
|
+
log = structlog.get_logger()
|
|
151
|
+
if initial_context:
|
|
152
|
+
log = log.bind(**initial_context)
|
|
153
|
+
return log
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def bind_contextvars(**context: Any) -> None:
|
|
157
|
+
"""
|
|
158
|
+
Bind additional context that will appear in all subsequent logs.
|
|
159
|
+
"""
|
|
160
|
+
structlog.contextvars.bind_contextvars(**context)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_request_id(request: Request) -> str:
|
|
164
|
+
"""Get request ID from request state."""
|
|
165
|
+
return getattr(request.state, "request_id", "-")
|
cuneus/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cuneus
|
|
3
|
+
Version: 0.2.1
|
|
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.10
|
|
10
|
+
Requires-Dist: click>=8.0
|
|
11
|
+
Requires-Dist: fastapi>=0.109.0
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
13
|
+
Requires-Dist: pydantic>=2.0
|
|
14
|
+
Requires-Dist: structlog>=24.1.0
|
|
15
|
+
Requires-Dist: svcs>=24.1.0
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.27.0
|
|
17
|
+
Provides-Extra: all
|
|
18
|
+
Requires-Dist: alembic>=1.13.0; extra == 'all'
|
|
19
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'all'
|
|
20
|
+
Requires-Dist: redis>=5.0; extra == 'all'
|
|
21
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'all'
|
|
22
|
+
Provides-Extra: database
|
|
23
|
+
Requires-Dist: alembic>=1.13.0; extra == 'database'
|
|
24
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'database'
|
|
25
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'database'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: alembic>=1.13.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: asgi-lifespan>=2.1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: asyncpg>=0.29.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: redis>=5.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff>=0.3; extra == 'dev'
|
|
37
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'dev'
|
|
38
|
+
Provides-Extra: redis
|
|
39
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# cuneus
|
|
43
|
+
|
|
44
|
+
> _The wedge stone that locks the arch together_
|
|
45
|
+
|
|
46
|
+
**cuneus** is a lightweight lifespan manager for FastAPI applications. It provides a simple pattern for composing extensions that handle startup/shutdown and service registration.
|
|
47
|
+
|
|
48
|
+
The name comes from Roman architecture: a _cuneus_ is the wedge-shaped stone in a Roman arch. Each stone is simple on its own, but together they lock under pressure to create structures that have stood for millennia—no rebar required.
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv add cuneus
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
or
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install cuneus
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# app.py
|
|
66
|
+
from fastapi import FastAPI
|
|
67
|
+
from cuneus import build_lifespan, Settings
|
|
68
|
+
from cuneus.middleware.logging import LoggingMiddleware
|
|
69
|
+
|
|
70
|
+
from myapp.extensions import DatabaseExtension
|
|
71
|
+
|
|
72
|
+
settings = Settings()
|
|
73
|
+
lifespan = build_lifespan(
|
|
74
|
+
settings,
|
|
75
|
+
DatabaseExtension(settings),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
app = FastAPI(lifespan=lifespan, title="My App", version="1.0.0")
|
|
79
|
+
|
|
80
|
+
# Add middleware directly to FastAPI
|
|
81
|
+
app.add_middleware(LoggingMiddleware)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
That's it. Extensions handle their lifecycle, FastAPI handles the rest.
|
|
85
|
+
|
|
86
|
+
## Creating Extensions
|
|
87
|
+
|
|
88
|
+
Use `BaseExtension` for simple cases:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from cuneus import BaseExtension
|
|
92
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
|
93
|
+
import svcs
|
|
94
|
+
|
|
95
|
+
class DatabaseExtension(BaseExtension):
|
|
96
|
+
def __init__(self, settings):
|
|
97
|
+
self.settings = settings
|
|
98
|
+
self.engine: AsyncEngine | None = None
|
|
99
|
+
|
|
100
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
101
|
+
self.engine = create_async_engine(self.settings.database_url)
|
|
102
|
+
|
|
103
|
+
# Register with svcs for dependency injection
|
|
104
|
+
registry.register_value(AsyncEngine, self.engine)
|
|
105
|
+
|
|
106
|
+
# Add routes
|
|
107
|
+
app.include_router(health_router, prefix="/health")
|
|
108
|
+
|
|
109
|
+
# Add exception handlers
|
|
110
|
+
app.add_exception_handler(DBError, self.handle_db_error)
|
|
111
|
+
|
|
112
|
+
# Return state (accessible via request.state.db)
|
|
113
|
+
return {"db": self.engine}
|
|
114
|
+
|
|
115
|
+
async def shutdown(self, app: FastAPI) -> None:
|
|
116
|
+
if self.engine:
|
|
117
|
+
await self.engine.dispose()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
For full control, override `register()` directly:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from contextlib import asynccontextmanager
|
|
124
|
+
|
|
125
|
+
class RedisExtension(BaseExtension):
|
|
126
|
+
def __init__(self, settings):
|
|
127
|
+
self.settings = settings
|
|
128
|
+
|
|
129
|
+
@asynccontextmanager
|
|
130
|
+
async def register(self, registry: svcs.Registry, app: FastAPI):
|
|
131
|
+
redis = await aioredis.from_url(self.settings.redis_url)
|
|
132
|
+
registry.register_value(Redis, redis)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
yield {"redis": redis}
|
|
136
|
+
finally:
|
|
137
|
+
await redis.close()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Testing
|
|
141
|
+
|
|
142
|
+
The lifespan exposes a `.registry` attribute for test overrides:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
# test_app.py
|
|
146
|
+
from unittest.mock import Mock
|
|
147
|
+
from starlette.testclient import TestClient
|
|
148
|
+
from myapp import app, lifespan, Database
|
|
149
|
+
|
|
150
|
+
def test_db_error_handling():
|
|
151
|
+
with TestClient(app) as client:
|
|
152
|
+
# Override after app startup
|
|
153
|
+
mock_db = Mock(spec=Database)
|
|
154
|
+
mock_db.get_user.side_effect = Exception("boom")
|
|
155
|
+
lifespan.registry.register_value(Database, mock_db)
|
|
156
|
+
|
|
157
|
+
resp = client.get("/users/42")
|
|
158
|
+
assert resp.status_code == 500
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Settings
|
|
162
|
+
|
|
163
|
+
cuneus includes a base `Settings` class that loads from multiple sources:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from cuneus import Settings
|
|
167
|
+
|
|
168
|
+
class AppSettings(Settings):
|
|
169
|
+
database_url: str = "sqlite+aiosqlite:///./app.db"
|
|
170
|
+
redis_url: str = "redis://localhost"
|
|
171
|
+
|
|
172
|
+
model_config = SettingsConfigDict(env_prefix="APP_")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Load priority (highest wins):
|
|
176
|
+
|
|
177
|
+
1. Environment variables
|
|
178
|
+
2. `.env` file
|
|
179
|
+
3. `pyproject.toml` under `[tool.cuneus]`
|
|
180
|
+
|
|
181
|
+
## API Reference
|
|
182
|
+
|
|
183
|
+
### `build_lifespan(settings, *extensions)`
|
|
184
|
+
|
|
185
|
+
Creates a lifespan context manager for FastAPI.
|
|
186
|
+
|
|
187
|
+
- `settings`: Your settings instance (subclass of `Settings`)
|
|
188
|
+
- `*extensions`: Extension instances to register
|
|
189
|
+
|
|
190
|
+
Returns a lifespan with a `.registry` attribute for testing.
|
|
191
|
+
|
|
192
|
+
### `BaseExtension`
|
|
193
|
+
|
|
194
|
+
Base class with `startup()` and `shutdown()` hooks:
|
|
195
|
+
|
|
196
|
+
- `startup(registry, app) -> dict[str, Any]`: Setup resources, return state
|
|
197
|
+
- `shutdown(app) -> None`: Cleanup resources
|
|
198
|
+
|
|
199
|
+
### `Extension` Protocol
|
|
200
|
+
|
|
201
|
+
For full control, implement the protocol directly:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
def register(self, registry: svcs.Registry, app: FastAPI) -> AsyncContextManager[dict[str, Any]]
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Accessors
|
|
208
|
+
|
|
209
|
+
- `aget(request, *types)` - Async get services from svcs
|
|
210
|
+
- `get(request, *types)` - Sync get services from svcs
|
|
211
|
+
- `get_settings(request)` - Get settings from request state
|
|
212
|
+
- `get_request_id(request)` - Get request ID from request state
|
|
213
|
+
|
|
214
|
+
## Why cuneus?
|
|
215
|
+
|
|
216
|
+
- **Simple** — one function, `build_lifespan()`, does what you need
|
|
217
|
+
- **No magic** — middleware added directly to FastAPI, not hidden
|
|
218
|
+
- **Testable** — registry exposed via `lifespan.registry`
|
|
219
|
+
- **Composable** — extensions are just async context managers
|
|
220
|
+
- **Built on svcs** — proper dependency injection, not global state
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
cuneus/__init__.py,sha256=KB5M0Ll8iffKml3-DmI2_LdafkU8Jhw3BBJkH8MrbpI,1226
|
|
2
|
+
cuneus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
cuneus/cli/__init__.py,sha256=XlLdZYfjfQxkIi4QTe8NcinmkidX__kd4ryO-Fjwxig,10854
|
|
4
|
+
cuneus/cli/console.py,sha256=e8ElKzkhL4FQQeVE-pjZx-kLgbzAKQrZJJpss3F4vr4,5620
|
|
5
|
+
cuneus/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
cuneus/core/application.py,sha256=QSOSmo8-YwjbC6QVe_VkDRa0AdRStTZX9bSqcbf3MFw,6670
|
|
7
|
+
cuneus/core/execptions.py,sha256=fM7-KbxYxzixehYd5pSDgWDYBW6IsawdJ6NwCxeIJPM,5695
|
|
8
|
+
cuneus/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
cuneus/ext/health.py,sha256=YYKKfJ8X71hnqN8vCKydqUK7BmlvZrqeBs7L61r7zp8,3814
|
|
10
|
+
cuneus/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
cuneus/middleware/logging.py,sha256=6KYakjrjzjfgdQ80xSsRtT3pX7NFAGkLUwceiEhDLfI,4780
|
|
12
|
+
cuneus-0.2.1.dist-info/METADATA,sha256=c_ZSnSwnY8zjS-p9GQUTFQCYVPzsrboGizwRvISTAwE,6501
|
|
13
|
+
cuneus-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
cuneus-0.2.1.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
|
|
15
|
+
cuneus-0.2.1.dist-info/RECORD,,
|