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.
@@ -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, AsyncIterator
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 cuneus.core.application import BaseExtension, Settings
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
- async def dispatch(self, request: Request, call_next) -> Response:
106
- request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())[:8]
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["X-Request-ID"] = request_id
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
@@ -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, Request
12
+ from fastapi import APIRouter, FastAPI
13
13
  from pydantic import BaseModel
14
14
 
15
- from cuneus.core.application import BaseExtension, Settings
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.1
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.10
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 build_lifespan, Settings
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
- settings = Settings()
73
- lifespan = build_lifespan(
74
- settings,
75
- DatabaseExtension(settings),
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 = FastAPI(lifespan=lifespan, title="My App", version="1.0.0")
79
+ app.include_router(my_router)
79
80
 
80
- # Add middleware directly to FastAPI
81
- app.add_middleware(LoggingMiddleware)
81
+ __all__ = ["app", "cli"]
82
82
  ```
83
83
 
84
- That's it. Extensions handle their lifecycle, FastAPI handles the rest.
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, `build_lifespan()`, does what you need
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,,