cuneus 0.2.6__py3-none-any.whl → 0.2.8__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 CHANGED
@@ -16,19 +16,21 @@ Example:
16
16
  """
17
17
 
18
18
  from .core.application import build_app
19
- from .core.execptions import (
19
+ from .core.exceptions import (
20
20
  AppException,
21
21
  BadRequest,
22
- Unauthorized,
22
+ Conflict,
23
+ DatabaseError,
24
+ ErrorResponse,
25
+ ExceptionExtension,
26
+ ExternalServiceError,
23
27
  Forbidden,
24
28
  NotFound,
25
- Conflict,
26
29
  RateLimited,
27
- ServiceUnavailable,
28
- DatabaseError,
29
30
  RedisError,
30
- ExternalServiceError,
31
- ExceptionExtension,
31
+ ServiceUnavailable,
32
+ Unauthorized,
33
+ error_responses,
32
34
  )
33
35
  from .core.extensions import BaseExtension, Extension
34
36
  from .core.settings import Settings
@@ -46,14 +48,16 @@ __all__ = [
46
48
  # Exceptions
47
49
  "AppException",
48
50
  "BadRequest",
49
- "Unauthorized",
51
+ "Conflict",
52
+ "DatabaseError",
53
+ "ErrorResponse",
54
+ "ExceptionExtension",
55
+ "ExternalServiceError",
50
56
  "Forbidden",
51
57
  "NotFound",
52
- "Conflict",
53
58
  "RateLimited",
54
- "ServiceUnavailable",
55
- "DatabaseError",
56
59
  "RedisError",
57
- "ExternalServiceError",
58
- "ExceptionExtension",
60
+ "ServiceUnavailable",
61
+ "Unauthorized",
62
+ "error_responses",
59
63
  ]
cuneus/cli.py CHANGED
@@ -44,7 +44,7 @@ def get_user_cli() -> click.Group | None:
44
44
 
45
45
  @click.group()
46
46
  @click.pass_context
47
- def cli(ctx: click.Context) -> None:
47
+ def cli(ctx: click.Context) -> None: # pragma: no cover
48
48
  """Cuneus CLI - FastAPI application framework."""
49
49
  ctx.ensure_object(dict)
50
50
 
@@ -95,7 +95,7 @@ def routes() -> None:
95
95
  app = import_from_string(config.app_module)
96
96
 
97
97
  for route in app.routes:
98
- if hasattr(route, "methods"):
98
+ if hasattr(route, "methods"): # pragma: no branch
99
99
  methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
100
100
  click.echo(f"{methods:8} {route.path}")
101
101
 
@@ -17,40 +17,37 @@ from fastapi import FastAPI
17
17
  from starlette.middleware import Middleware
18
18
 
19
19
  from .settings import Settings
20
- from .execptions import ExceptionExtension
20
+ from .exceptions import ExceptionExtension
21
21
  from .logging import LoggingExtension
22
- from .extensions import Extension, HasCLI, HasMiddleware
22
+ from .extensions import Extension, HasCLI, HasExceptionHandler, HasMiddleware
23
23
  from ..ext.health import HealthExtension
24
24
 
25
25
  logger = structlog.stdlib.get_logger("cuneus")
26
26
 
27
27
  type ExtensionInput = Extension | Callable[..., Extension]
28
28
 
29
- DEFAULT_EXTENSIONS = (
29
+ DEFAULTS = (
30
30
  LoggingExtension,
31
31
  HealthExtension,
32
32
  ExceptionExtension,
33
33
  )
34
34
 
35
35
 
36
+ class ExtensionConflictError(Exception):
37
+ """Raised when extensions have conflicting state keys."""
38
+
39
+ pass
40
+
41
+
36
42
  def _instantiate_extension(
37
43
  ext: ExtensionInput, settings: Settings | None = None
38
44
  ) -> Extension:
39
45
  if isinstance(ext, type) or callable(ext):
40
- sig = inspect.signature(ext)
41
-
42
- # Check if it accepts a 'settings' parameter
43
- if "settings" in sig.parameters:
44
- return ext(settings=settings)
45
-
46
- # Check if it accepts **kwargs
47
- has_var_keyword = any(
48
- p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
49
- )
50
- if has_var_keyword:
46
+ try:
51
47
  return ext(settings=settings)
48
+ except TypeError:
49
+ return ext()
52
50
 
53
- return ext()
54
51
  return ext
55
52
 
56
53
 
@@ -92,13 +89,7 @@ def build_app(
92
89
 
93
90
  settings = settings or Settings()
94
91
 
95
- if include_defaults:
96
- # Allow users to override a default extension
97
- user_types = {type(ext) for ext in extensions}
98
- defaults = [ext for ext in DEFAULT_EXTENSIONS if type(ext) not in user_types]
99
- all_inputs = (*defaults, *extensions)
100
- else:
101
- all_inputs = extensions
92
+ all_inputs = (*DEFAULTS, *extensions) if include_defaults else extensions
102
93
 
103
94
  all_extensions = [_instantiate_extension(ext, settings) for ext in all_inputs]
104
95
 
@@ -117,10 +108,13 @@ def build_app(
117
108
  state: dict[str, Any] = {}
118
109
 
119
110
  for ext in all_extensions:
111
+ ext_name = ext.__class__.__name__
120
112
  ext_state = await stack.enter_async_context(ext.register(registry, app))
121
113
  if ext_state:
122
114
  if overlap := state.keys() & ext_state.keys():
123
- raise ValueError(f"Extension state key collision: {overlap}")
115
+ msg = f"Extension {ext_name} state key collision: {overlap}"
116
+ logger.error(msg, ext=ext_name, overlap=overlap)
117
+ raise ExtensionConflictError(msg).with_traceback(None) from None
124
118
  state.update(ext_state)
125
119
 
126
120
  yield state
@@ -129,12 +123,21 @@ def build_app(
129
123
  middleware: list[Middleware] = []
130
124
 
131
125
  for ext in all_extensions:
126
+ ext_name = ext.__class__.__name__
132
127
  if isinstance(ext, HasMiddleware):
133
- logger.debug(f"Loading middleware from {ext.__class__.__name__}")
128
+ logger.debug(f"Loading middleware from {ext_name}")
134
129
  middleware.extend(ext.middleware())
135
130
  if isinstance(ext, HasCLI):
136
- logger.debug(f"Adding cli commands from {ext.__class__.__name__}")
131
+ logger.debug(f"Adding cli commands from {ext_name}")
137
132
  ext.register_cli(app_cli)
138
133
 
139
134
  app = FastAPI(lifespan=lifespan, middleware=middleware, **fastapi_kwargs)
135
+
136
+ # Preform post app initialization extension customization
137
+ for ext in all_extensions:
138
+ ext_name = ext.__class__.__name__
139
+ if isinstance(ext, HasExceptionHandler):
140
+ logger.debug(f"Loading exception handlers from {ext_name}")
141
+ ext.add_exception_handler(app)
142
+
140
143
  return app, app_cli
@@ -7,7 +7,6 @@ from __future__ import annotations
7
7
  from typing import Any
8
8
 
9
9
  import structlog
10
- import svcs
11
10
  from fastapi import FastAPI, Request
12
11
  from fastapi.responses import JSONResponse
13
12
  from pydantic import BaseModel
@@ -152,24 +151,14 @@ class ExceptionExtension(BaseExtension):
152
151
 
153
152
  Catches AppException subclasses and converts to JSON responses.
154
153
  Catches unexpected exceptions and returns generic 500s.
155
-
156
- Usage:
157
- from qtip import build_app
158
- from qtip.core.exceptions import ExceptionExtension, ExceptionSettings
159
-
160
- app = build_app(
161
- settings,
162
- extensions=[ExceptionExtension(settings)],
163
- )
164
154
  """
165
155
 
166
156
  def __init__(self, settings: Settings | None = None) -> None:
167
157
  self.settings = settings or Settings()
168
158
 
169
- async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
159
+ def add_exception_handler(self, app: FastAPI) -> None:
170
160
  app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[arg-type]
171
161
  app.add_exception_handler(Exception, self._handle_unexpected_exception)
172
- return {}
173
162
 
174
163
  def _handle_app_exception(
175
164
  self, request: Request, exc: AppException
@@ -179,7 +168,7 @@ class ExceptionExtension(BaseExtension):
179
168
  else:
180
169
  log.warning("client_error", error_code=exc.error_code, message=exc.message)
181
170
 
182
- response = exc.to_response(request.state.get("request_id", None))
171
+ response = exc.to_response(getattr(request.state, "request_id", None))
183
172
 
184
173
  headers = {}
185
174
  if isinstance(exc, RateLimited) and exc.retry_after:
@@ -194,8 +183,7 @@ class ExceptionExtension(BaseExtension):
194
183
  def _handle_unexpected_exception(
195
184
  self, request: Request, exc: Exception
196
185
  ) -> JSONResponse:
197
- log.exception("unexpected_error")
198
-
186
+ log.exception("unexpected_error", exc_info=exc)
199
187
  response: dict[str, Any] = {
200
188
  "error": {
201
189
  "code": "internal_error",
@@ -203,7 +191,7 @@ class ExceptionExtension(BaseExtension):
203
191
  }
204
192
  }
205
193
 
206
- if hasattr(request.state, "request_id"):
194
+ if hasattr(request.state, "request_id"): # pragma: no branch
207
195
  response["error"]["request_id"] = request.state.request_id
208
196
 
209
197
  if self.settings.debug:
cuneus/core/extensions.py CHANGED
@@ -35,7 +35,7 @@ class Extension(Protocol):
35
35
 
36
36
  def register(
37
37
  self, registry: svcs.Registry, app: FastAPI
38
- ) -> AsyncContextManager[dict[str, Any]]:
38
+ ) -> AsyncContextManager[dict[str, Any]]: # pragma: no cover
39
39
  """
40
40
  Async context manager for lifecycle.
41
41
 
@@ -50,14 +50,21 @@ class Extension(Protocol):
50
50
  class HasMiddleware(Protocol):
51
51
  """Extension that provides middleware."""
52
52
 
53
- def middleware(self) -> list[Middleware]: ...
53
+ def middleware(self) -> list[Middleware]: ... # pragma: no cover
54
54
 
55
55
 
56
56
  @runtime_checkable
57
57
  class HasCLI(Protocol):
58
58
  """Extension that provides CLI commands."""
59
59
 
60
- def register_cli(self, cli_group: Group) -> None: ...
60
+ def register_cli(self, cli_group: Group) -> None: ... # pragma: no cover
61
+
62
+
63
+ @runtime_checkable
64
+ class HasExceptionHandler(Protocol):
65
+ """Extension that provides exception handlers."""
66
+
67
+ def add_exception_handler(self, app: FastAPI) -> None: ... # pragma: no cover
61
68
 
62
69
 
63
70
  class BaseExtension:
cuneus/core/logging.py CHANGED
@@ -4,18 +4,18 @@ Structured logging with structlog and request context.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from contextvars import ContextVar
8
7
  import logging
8
+ import shutil
9
9
  import time
10
10
  import uuid
11
- from typing import Any, Awaitable, Callable, MutableMapping
11
+ from typing import Any, Awaitable, Callable
12
12
 
13
13
  import structlog
14
14
  import svcs
15
15
  from fastapi import FastAPI, Request, Response
16
16
  from starlette.middleware.base import BaseHTTPMiddleware
17
17
  from starlette.middleware import Middleware
18
- from starlette.types import ASGIApp, Scope, Send, Receive
18
+ from starlette.types import ASGIApp
19
19
 
20
20
  from .extensions import BaseExtension
21
21
  from .settings import Settings
@@ -37,9 +37,14 @@ def configure_structlog(settings: Settings | None = None) -> None:
37
37
  structlog.processors.UnicodeDecoder(),
38
38
  ]
39
39
 
40
- renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(colors=True)
41
- if log_settings.log_json:
42
- renderer = structlog.processors.JSONRenderer()
40
+ renderer = structlog.processors.JSONRenderer()
41
+
42
+ if not log_settings.log_json: # pragma: no branch
43
+ term_width = shutil.get_terminal_size().columns
44
+ pad_event = term_width - 36
45
+ renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(
46
+ colors=True, pad_event=pad_event
47
+ )
43
48
 
44
49
  # Configure structlog
45
50
  structlog.configure(
@@ -79,25 +84,12 @@ class LoggingExtension(BaseExtension):
79
84
 
80
85
  Integrates with stdlib logging so uvicorn and other libraries
81
86
  also output through structlog.
82
-
83
- Usage:
84
- from qtip import build_app
85
- from qtip.middleware.logging import LoggingExtension, LoggingSettings
86
-
87
- app = build_app(
88
- settings,
89
- extensions=[LoggingExtension(settings)],
90
- )
91
87
  """
92
88
 
93
89
  def __init__(self, settings: Settings | None = None) -> None:
94
90
  self.settings = settings or Settings()
95
91
  configure_structlog(settings)
96
92
 
97
- async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
98
- # app.add_middleware(RequestLoggingMiddleware)
99
- return {}
100
-
101
93
  def middleware(self) -> list[Middleware]:
102
94
  return [
103
95
  Middleware(
@@ -160,71 +152,3 @@ class LoggingMiddleware(BaseHTTPMiddleware):
160
152
  raise
161
153
  finally:
162
154
  structlog.contextvars.clear_contextvars()
163
-
164
-
165
- # Used by httpx for request ID propagation
166
- request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
167
-
168
-
169
- class RequestIDMiddleware:
170
- def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
171
- self.app = app
172
- self.header_name = header_name
173
-
174
- async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
175
- if scope["type"] != "http":
176
- await self.app(scope, receive, send)
177
- return
178
-
179
- headers = dict(scope.get("headers", []))
180
- request_id = headers.get(
181
- self.header_name.lower().encode(), str(uuid.uuid4())[:8].encode()
182
- ).decode()
183
-
184
- if "state" not in scope:
185
- scope["state"] = {}
186
- scope["state"]["request_id"] = request_id
187
-
188
- # Set contextvar for use in HTTP clients
189
- token = request_id_ctx.set(request_id)
190
-
191
- async def send_with_request_id(message: MutableMapping[str, Any]) -> None:
192
- if message["type"] == "http.response.start":
193
- headers = list(message.get("headers", []))
194
- headers.append((self.header_name.encode(), request_id.encode()))
195
- message["headers"] = headers
196
- await send(message)
197
-
198
- try:
199
- await self.app(scope, receive, send_with_request_id)
200
- finally:
201
- request_id_ctx.reset(token)
202
-
203
-
204
- # === Public API ===
205
-
206
-
207
- def get_logger(**initial_context: Any) -> structlog.stdlib.BoundLogger:
208
- """
209
- Get a logger with optional initial context.
210
-
211
- Usage:
212
- log = get_logger()
213
- log.info("user logged in", user_id=123)
214
- """
215
- log = structlog.stdlib.get_logger()
216
- if initial_context:
217
- log = log.bind(**initial_context)
218
- return log
219
-
220
-
221
- def bind_contextvars(**context: Any) -> None:
222
- """
223
- Bind additional context that will appear in all subsequent logs.
224
- """
225
- structlog.contextvars.bind_contextvars(**context)
226
-
227
-
228
- def get_request_id(request: Request) -> str:
229
- """Get request ID from request state."""
230
- return getattr(request.state, "request_id", "-")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuneus
3
- Version: 0.2.6
3
+ Version: 0.2.8
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
@@ -31,6 +31,7 @@ Requires-Dist: httpx>=0.27; extra == 'dev'
31
31
  Requires-Dist: mypy>=1.8; extra == 'dev'
32
32
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
33
33
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
34
+ Requires-Dist: pytest-mock; extra == 'dev'
34
35
  Requires-Dist: pytest>=8.0; extra == 'dev'
35
36
  Requires-Dist: redis>=5.0; extra == 'dev'
36
37
  Requires-Dist: ruff>=0.3; extra == 'dev'
@@ -0,0 +1,15 @@
1
+ cuneus/__init__.py,sha256=AJXN9dnXIWn0Eg6JxWOgOf0C386EqBNdHiQG2akTpKc,1295
2
+ cuneus/cli.py,sha256=rcJDqU28FMPSw7tkmKUz95ERKzgYv43Y2kKVZw24rLo,3864
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=Xoqs3fREJ-KPzSilq4ua65JzKv6w5LgugGaMklDELdU,4376
6
+ cuneus/core/exceptions.py,sha256=gyHgVy4UC7gBjs5pXpCzIIyIVsV7K8xt-KZKi8eLJtM,5496
7
+ cuneus/core/extensions.py,sha256=wqN2rbaqhiTkfK_fA5AkFlefOAV2GJr6zTLf9Oi-n4c,2771
8
+ cuneus/core/logging.py,sha256=jWwtPJTNYtvOSb4etg7pglWHAKfV4iRuw4Npoqne_00,4578
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.8.dist-info/METADATA,sha256=Y3d-3jUnaPU_GkOeHpYaxM9AmiEfnjc4WVIo4OZrYjM,6837
13
+ cuneus-0.2.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
+ cuneus-0.2.8.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
15
+ cuneus-0.2.8.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- cuneus/__init__.py,sha256=JJ3nZ4757GU9KKuurxP1FfJSdSVrcO-xaorLFSvUJ5E,1211
2
- cuneus/cli.py,sha256=EYrQBBrBJVibGVGjrAgkJ5S4YAukub16HHkm-GaE4UY,3823
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=5nHe3y8FNV1-IKVcdx0Agnoy73xAcdUGjlEzLVNF7tA,4279
6
- cuneus/core/execptions.py,sha256=beQE3gD-14BUK4Se6yE2J2U92xgr0yarVmVeibkudxs,5753
7
- cuneus/core/extensions.py,sha256=qqBnAD_wN6wTTun7C2hfVqxHhA3WgScNSrgRTMsYa04,2515
8
- cuneus/core/logging.py,sha256=m9qRXAJ4uqcT-NbLn1W3SpSY8DystRRpH9N8jENcyX4,6945
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.6.dist-info/METADATA,sha256=Px89iKewk0sRhpZmde9snrTzuMsNkaID7NDU4XHatm8,6794
13
- cuneus-0.2.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
- cuneus-0.2.6.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
15
- cuneus-0.2.6.dist-info/RECORD,,
File without changes