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.
@@ -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,,
@@ -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