cuneus 0.2.5__tar.gz → 0.2.7__tar.gz

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.
@@ -5,7 +5,12 @@ build/
5
5
  dist/
6
6
  wheels/
7
7
  *.egg-info
8
+ .*_cache
9
+
10
+ # coverage
8
11
  .coverage
12
+ htmlcov
13
+
9
14
 
10
15
  # Virtual environments
11
16
  .venv
cuneus-0.2.7/Makefile ADDED
@@ -0,0 +1,68 @@
1
+ BASE_DIR := $(shell git rev-parse --show-toplevel 2>/dev/null || pwd)
2
+ PROG_NAME := $(shell basename $(BASE_DIR))
3
+ PYPROJECT := $(shell find . -name 'pyproject.toml')
4
+ SHELL := /bin/bash
5
+ UV_LOCK := uv.lock
6
+ UV ?= uv
7
+ VENV := .venv
8
+ MARKER := $(VENV)/.installed
9
+
10
+ ################################################################
11
+ #%
12
+ #% Usage:
13
+ #% make <command>
14
+ #%
15
+ #% Getting Started:
16
+ #% make setup
17
+ #%
18
+ #% Run the tests locally:
19
+ #% make test
20
+ #%
21
+ #% Available Commands:
22
+ help: ## Help is on the way
23
+ @echo " Tools for building, running, and testing $(PROG_NAME)"
24
+ @grep '^#%' $(MAKEFILE_LIST) | sed -e 's/#%//'
25
+ @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | awk -F ':.*?## ' 'NF==2 {printf " %-20s%s\n", $$1, $$2}' | sort
26
+
27
+ # export env vars in order to be used in commands
28
+ export PYTHONPATH ?= ./src
29
+
30
+ # uv creates the venv automatically, but this tracks if sync has been run
31
+ $(MARKER): $(PYPROJECT) $(UV_LOCK)
32
+ $(UV) sync --all-extras
33
+ touch $(MARKER)
34
+
35
+ .PHONY: install
36
+ setup: $(MARKER) ## Install dependencies for the project
37
+
38
+ .PHONY: update
39
+ update: ## Update and lock dependencies
40
+ $(UV) lock
41
+ $(UV) sync
42
+ touch $(MARKER)
43
+
44
+ .PHONY: clean
45
+ clean: ## Remove virtual environment
46
+ rm -rf $(VENV)
47
+
48
+ .PHONY: deps
49
+ deps: $(MARKER) ## Run any dependencies for local dev and test
50
+ @echo "Starting deps..."
51
+
52
+ .PHONY: dev
53
+ dev: deps ## Run the application in dev mode
54
+ $(UV) run cuneus dev
55
+
56
+ .PHONY: prod
57
+ prod: deps ## Run the application in prod mode
58
+ $(UV) run cuneus prod
59
+
60
+ .PHONY: test
61
+ test: deps ## Run pytests
62
+ $(UV) run pytest
63
+
64
+ .PHONY: test-failed
65
+ test-failed: deps ## Re-Run pytest on tests that failed
66
+ $(UV) run pytest --lf
67
+
68
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuneus
3
- Version: 0.2.5
3
+ Version: 0.2.7
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'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cuneus"
3
- version = "0.2.5"
3
+ version = "0.2.7"
4
4
  description = "ASGI application wrapper"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Robert Myers", email = "robert@julython.org" }]
@@ -26,6 +26,7 @@ dev = [
26
26
  "pytest>=8.0",
27
27
  "pytest-asyncio>=0.23",
28
28
  "pytest-cov>=4.0",
29
+ "pytest-mock",
29
30
  "httpx>=0.27",
30
31
  "asgi-lifespan>=2.1.0",
31
32
  "ruff>=0.3",
@@ -63,7 +64,28 @@ warn_unused_ignores = true
63
64
  [tool.pytest.ini_options]
64
65
  asyncio_mode = "auto"
65
66
  testpaths = ["tests"]
67
+ addopts = [
68
+ # "--test-alembic",
69
+ "--log-cli-level=warning",
70
+ "--cov=cuneus",
71
+ "--cov-branch",
72
+ "--cov-report=term-missing:skip-covered",
73
+ "--cov-report=html",
74
+ "--strict-markers",
75
+ "--strict-config",
76
+ "--verbose",
77
+ ]
66
78
 
67
79
  [tool.coverage.run]
68
80
  source = ["src/cuneus"]
69
81
  branch = true
82
+
83
+ [tool.coverage.report]
84
+ exclude_lines = [
85
+ "pragma: no cover",
86
+ "def __repr__",
87
+ "raise AssertionError",
88
+ "raise NotImplementedError",
89
+ "if __name__ == .__main__.:",
90
+ "if TYPE_CHECKING:",
91
+ ]
@@ -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
  ]
@@ -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
 
@@ -63,6 +63,8 @@ def dev(host: str, port: int) -> None:
63
63
  host=host,
64
64
  port=port,
65
65
  reload=True,
66
+ log_config=None,
67
+ server_header=False,
66
68
  )
67
69
 
68
70
 
@@ -81,6 +83,8 @@ def prod(host: str, port: int, workers: int) -> None:
81
83
  host=host,
82
84
  port=port,
83
85
  workers=workers,
86
+ log_config=None,
87
+ server_header=False,
84
88
  )
85
89
 
86
90
 
@@ -91,7 +95,7 @@ def routes() -> None:
91
95
  app = import_from_string(config.app_module)
92
96
 
93
97
  for route in app.routes:
94
- if hasattr(route, "methods"):
98
+ if hasattr(route, "methods"): # pragma: no branch
95
99
  methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
96
100
  click.echo(f"{methods:8} {route.path}")
97
101
 
@@ -11,44 +11,43 @@ from contextlib import AsyncExitStack, asynccontextmanager
11
11
  from typing import Any, AsyncIterator, Callable
12
12
 
13
13
  import click
14
+ import structlog
14
15
  import svcs
15
16
  from fastapi import FastAPI
16
17
  from starlette.middleware import Middleware
17
18
 
18
19
  from .settings import Settings
19
- from .execptions import ExceptionExtension
20
+ from .exceptions import ExceptionExtension
20
21
  from .logging import LoggingExtension
21
- from .extensions import Extension, HasCLI, HasMiddleware
22
+ from .extensions import Extension, HasCLI, HasExceptionHandler, HasMiddleware
22
23
  from ..ext.health import HealthExtension
23
24
 
25
+ logger = structlog.stdlib.get_logger("cuneus")
24
26
 
25
27
  type ExtensionInput = Extension | Callable[..., Extension]
26
28
 
27
- DEFAULT_EXTENSIONS = (
29
+ DEFAULTS = (
28
30
  LoggingExtension,
29
31
  HealthExtension,
30
32
  ExceptionExtension,
31
33
  )
32
34
 
33
35
 
36
+ class ExtensionConflictError(Exception):
37
+ """Raised when extensions have conflicting state keys."""
38
+
39
+ pass
40
+
41
+
34
42
  def _instantiate_extension(
35
43
  ext: ExtensionInput, settings: Settings | None = None
36
44
  ) -> Extension:
37
45
  if isinstance(ext, type) or callable(ext):
38
- sig = inspect.signature(ext)
39
-
40
- # Check if it accepts a 'settings' parameter
41
- if "settings" in sig.parameters:
42
- return ext(settings=settings)
43
-
44
- # Check if it accepts **kwargs
45
- has_var_keyword = any(
46
- p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
47
- )
48
- if has_var_keyword:
46
+ try:
49
47
  return ext(settings=settings)
48
+ except TypeError:
49
+ return ext()
50
50
 
51
- return ext()
52
51
  return ext
53
52
 
54
53
 
@@ -90,13 +89,7 @@ def build_app(
90
89
 
91
90
  settings = settings or Settings()
92
91
 
93
- if include_defaults:
94
- # Allow users to override a default extension
95
- user_types = {type(ext) for ext in extensions}
96
- defaults = [ext for ext in DEFAULT_EXTENSIONS if type(ext) not in user_types]
97
- all_inputs = (*defaults, *extensions)
98
- else:
99
- all_inputs = extensions
92
+ all_inputs = (*DEFAULTS, *extensions) if include_defaults else extensions
100
93
 
101
94
  all_extensions = [_instantiate_extension(ext, settings) for ext in all_inputs]
102
95
 
@@ -115,10 +108,13 @@ def build_app(
115
108
  state: dict[str, Any] = {}
116
109
 
117
110
  for ext in all_extensions:
111
+ ext_name = ext.__class__.__name__
118
112
  ext_state = await stack.enter_async_context(ext.register(registry, app))
119
113
  if ext_state:
120
114
  if overlap := state.keys() & ext_state.keys():
121
- 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
122
118
  state.update(ext_state)
123
119
 
124
120
  yield state
@@ -127,10 +123,21 @@ def build_app(
127
123
  middleware: list[Middleware] = []
128
124
 
129
125
  for ext in all_extensions:
126
+ ext_name = ext.__class__.__name__
130
127
  if isinstance(ext, HasMiddleware):
128
+ logger.debug(f"Loading middleware from {ext_name}")
131
129
  middleware.extend(ext.middleware())
132
130
  if isinstance(ext, HasCLI):
131
+ logger.debug(f"Adding cli commands from {ext_name}")
133
132
  ext.register_cli(app_cli)
134
133
 
135
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
+
136
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:
@@ -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:
@@ -0,0 +1,152 @@
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, Awaitable, Callable
11
+
12
+ import structlog
13
+ import svcs
14
+ from fastapi import FastAPI, Request, Response
15
+ from starlette.middleware.base import BaseHTTPMiddleware
16
+ from starlette.middleware import Middleware
17
+ from starlette.types import ASGIApp
18
+
19
+ from .extensions import BaseExtension
20
+ from .settings import Settings
21
+
22
+ logger = structlog.stdlib.get_logger("cuneus")
23
+
24
+
25
+ def configure_structlog(settings: Settings | None = None) -> None:
26
+ log_settings = settings or Settings()
27
+
28
+ # Shared processors
29
+ shared_processors: list[structlog.types.Processor] = [
30
+ structlog.contextvars.merge_contextvars,
31
+ structlog.stdlib.add_log_level,
32
+ structlog.stdlib.add_logger_name,
33
+ structlog.stdlib.PositionalArgumentsFormatter(),
34
+ structlog.processors.TimeStamper(fmt="iso", utc=True),
35
+ structlog.processors.StackInfoRenderer(),
36
+ structlog.processors.UnicodeDecoder(),
37
+ ]
38
+
39
+ renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(colors=True)
40
+ if log_settings.log_json: # pragma: no cover
41
+ renderer = structlog.processors.JSONRenderer()
42
+
43
+ # Configure structlog
44
+ structlog.configure(
45
+ processors=shared_processors
46
+ + [
47
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
48
+ ],
49
+ logger_factory=structlog.stdlib.LoggerFactory(),
50
+ cache_logger_on_first_use=True,
51
+ )
52
+
53
+ # Create formatter for stdlib
54
+ formatter = structlog.stdlib.ProcessorFormatter(
55
+ foreign_pre_chain=shared_processors,
56
+ processors=[
57
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
58
+ renderer,
59
+ ],
60
+ )
61
+
62
+ # Configure root logger
63
+ handler = logging.StreamHandler()
64
+ handler.setFormatter(formatter)
65
+
66
+ root_logger = logging.getLogger()
67
+ root_logger.handlers.clear()
68
+ root_logger.addHandler(handler)
69
+ root_logger.setLevel(log_settings.log_level.upper())
70
+
71
+ # Quiet noisy loggers
72
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
73
+
74
+
75
+ class LoggingExtension(BaseExtension):
76
+ """
77
+ Structured logging extension using structlog.
78
+
79
+ Integrates with stdlib logging so uvicorn and other libraries
80
+ also output through structlog.
81
+ """
82
+
83
+ def __init__(self, settings: Settings | None = None) -> None:
84
+ self.settings = settings or Settings()
85
+ configure_structlog(settings)
86
+
87
+ async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
88
+ # app.add_middleware(RequestLoggingMiddleware)
89
+ return {}
90
+
91
+ def middleware(self) -> list[Middleware]:
92
+ return [
93
+ Middleware(
94
+ LoggingMiddleware,
95
+ header_name=self.settings.request_id_header,
96
+ ),
97
+ ]
98
+
99
+
100
+ class LoggingMiddleware(BaseHTTPMiddleware):
101
+ """
102
+ Middleware that:
103
+ - Generates request_id
104
+ - Binds it to structlog context
105
+ - Logs request start/end
106
+ - Adds request_id to response headers
107
+ """
108
+
109
+ def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
110
+ self.header_name = header_name
111
+ super().__init__(app)
112
+
113
+ async def dispatch(
114
+ self, request: Request, call_next: Callable[..., Awaitable[Response]]
115
+ ) -> Response:
116
+ path = request.url.path
117
+ # Exclude health routes as these are just noise
118
+ # TODO(rmyers): make this configurable
119
+ if path.startswith("/health"):
120
+ return await call_next(request)
121
+
122
+ request_id = request.headers.get(self.header_name) or str(uuid.uuid4())[:8]
123
+
124
+ structlog.contextvars.clear_contextvars()
125
+ structlog.contextvars.bind_contextvars(
126
+ request_id=request_id,
127
+ method=request.method,
128
+ path=path,
129
+ )
130
+
131
+ request.state.request_id = request_id
132
+
133
+ log = structlog.stdlib.get_logger("cuneus")
134
+ start_time = time.perf_counter()
135
+
136
+ try:
137
+ response = await call_next(request)
138
+
139
+ duration_ms = (time.perf_counter() - start_time) * 1000
140
+ log.info(
141
+ f"{request.method} {request.url.path} {response.status_code}",
142
+ status_code=response.status_code,
143
+ duration_ms=round(duration_ms, 2),
144
+ )
145
+
146
+ response.headers[self.header_name] = request_id
147
+ return response
148
+
149
+ except Exception:
150
+ raise
151
+ finally:
152
+ structlog.contextvars.clear_contextvars()