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.
- {cuneus-0.2.5 → cuneus-0.2.7}/.gitignore +5 -0
- cuneus-0.2.7/Makefile +68 -0
- {cuneus-0.2.5 → cuneus-0.2.7}/PKG-INFO +2 -1
- {cuneus-0.2.5 → cuneus-0.2.7}/pyproject.toml +23 -1
- {cuneus-0.2.5 → cuneus-0.2.7}/src/cuneus/__init__.py +17 -13
- {cuneus-0.2.5 → cuneus-0.2.7}/src/cuneus/cli.py +6 -2
- {cuneus-0.2.5 → cuneus-0.2.7}/src/cuneus/core/application.py +30 -23
- cuneus-0.2.5/src/cuneus/core/execptions.py → cuneus-0.2.7/src/cuneus/core/exceptions.py +4 -16
- {cuneus-0.2.5 → cuneus-0.2.7}/src/cuneus/core/extensions.py +10 -3
- cuneus-0.2.7/src/cuneus/core/logging.py +152 -0
- cuneus-0.2.7/tests/test_cli.py +252 -0
- cuneus-0.2.7/tests/test_exceptions.py +178 -0
- cuneus-0.2.7/tests/test_extensions.py +7 -0
- cuneus-0.2.7/tests/test_integration.py +111 -0
- {cuneus-0.2.5 → cuneus-0.2.7}/uv.lock +15 -1
- cuneus-0.2.5/src/cuneus/core/logging.py +0 -221
- cuneus-0.2.5/tests/test_integration.py +0 -14
- {cuneus-0.2.5 → cuneus-0.2.7}/.python-version +0 -0
- {cuneus-0.2.5 → cuneus-0.2.7}/README.md +0 -0
- {cuneus-0.2.5 → cuneus-0.2.7}/src/cuneus/core/__init__.py +0 -0
- {cuneus-0.2.5 → cuneus-0.2.7}/src/cuneus/core/settings.py +0 -0
- {cuneus-0.2.5 → cuneus-0.2.7}/src/cuneus/ext/__init__.py +0 -0
- {cuneus-0.2.5 → cuneus-0.2.7}/src/cuneus/ext/health.py +0 -0
- {cuneus-0.2.5 → cuneus-0.2.7}/src/cuneus/py.typed +0 -0
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.
|
|
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.
|
|
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.
|
|
19
|
+
from .core.exceptions import (
|
|
20
20
|
AppException,
|
|
21
21
|
BadRequest,
|
|
22
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
58
|
-
"
|
|
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 .
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|