cuneus 0.2.6__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.6 → cuneus-0.2.7}/.gitignore +5 -0
- {cuneus-0.2.6 → cuneus-0.2.7}/Makefile +1 -1
- {cuneus-0.2.6 → cuneus-0.2.7}/PKG-INFO +2 -1
- {cuneus-0.2.6 → cuneus-0.2.7}/pyproject.toml +23 -1
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/__init__.py +17 -13
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/cli.py +2 -2
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/core/application.py +28 -25
- cuneus-0.2.6/src/cuneus/core/execptions.py → cuneus-0.2.7/src/cuneus/core/exceptions.py +4 -16
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/core/extensions.py +10 -3
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/core/logging.py +3 -81
- 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.6 → cuneus-0.2.7}/uv.lock +15 -1
- cuneus-0.2.6/tests/test_integration.py +0 -14
- {cuneus-0.2.6 → cuneus-0.2.7}/.python-version +0 -0
- {cuneus-0.2.6 → cuneus-0.2.7}/README.md +0 -0
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/core/__init__.py +0 -0
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/core/settings.py +0 -0
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/ext/__init__.py +0 -0
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/ext/health.py +0 -0
- {cuneus-0.2.6 → cuneus-0.2.7}/src/cuneus/py.typed +0 -0
|
@@ -25,7 +25,7 @@ help: ## Help is on the way
|
|
|
25
25
|
@grep '^[a-zA-Z]' $(MAKEFILE_LIST) | awk -F ':.*?## ' 'NF==2 {printf " %-20s%s\n", $$1, $$2}' | sort
|
|
26
26
|
|
|
27
27
|
# export env vars in order to be used in commands
|
|
28
|
-
export PYTHONPATH ?= ./
|
|
28
|
+
export PYTHONPATH ?= ./src
|
|
29
29
|
|
|
30
30
|
# uv creates the venv automatically, but this tracks if sync has been run
|
|
31
31
|
$(MARKER): $(PYPROJECT) $(UV_LOCK)
|
|
@@ -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
|
|
|
@@ -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 .
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
@@ -4,18 +4,17 @@ 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
|
|
9
8
|
import time
|
|
10
9
|
import uuid
|
|
11
|
-
from typing import Any, Awaitable, Callable
|
|
10
|
+
from typing import Any, Awaitable, Callable
|
|
12
11
|
|
|
13
12
|
import structlog
|
|
14
13
|
import svcs
|
|
15
14
|
from fastapi import FastAPI, Request, Response
|
|
16
15
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
16
|
from starlette.middleware import Middleware
|
|
18
|
-
from starlette.types import ASGIApp
|
|
17
|
+
from starlette.types import ASGIApp
|
|
19
18
|
|
|
20
19
|
from .extensions import BaseExtension
|
|
21
20
|
from .settings import Settings
|
|
@@ -38,7 +37,7 @@ def configure_structlog(settings: Settings | None = None) -> None:
|
|
|
38
37
|
]
|
|
39
38
|
|
|
40
39
|
renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(colors=True)
|
|
41
|
-
if log_settings.log_json:
|
|
40
|
+
if log_settings.log_json: # pragma: no cover
|
|
42
41
|
renderer = structlog.processors.JSONRenderer()
|
|
43
42
|
|
|
44
43
|
# Configure structlog
|
|
@@ -79,15 +78,6 @@ class LoggingExtension(BaseExtension):
|
|
|
79
78
|
|
|
80
79
|
Integrates with stdlib logging so uvicorn and other libraries
|
|
81
80
|
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
81
|
"""
|
|
92
82
|
|
|
93
83
|
def __init__(self, settings: Settings | None = None) -> None:
|
|
@@ -160,71 +150,3 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|
|
160
150
|
raise
|
|
161
151
|
finally:
|
|
162
152
|
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", "-")
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from click.testing import CliRunner
|
|
3
|
+
from unittest.mock import patch, MagicMock
|
|
4
|
+
|
|
5
|
+
from cuneus.cli import (
|
|
6
|
+
import_from_string,
|
|
7
|
+
get_user_cli,
|
|
8
|
+
cli,
|
|
9
|
+
dev,
|
|
10
|
+
prod,
|
|
11
|
+
routes,
|
|
12
|
+
CuneusCLI,
|
|
13
|
+
main,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestImportFromString:
|
|
18
|
+
def test_imports_module_attribute(self):
|
|
19
|
+
result = import_from_string("os.path:join")
|
|
20
|
+
from os.path import join
|
|
21
|
+
|
|
22
|
+
assert result is join
|
|
23
|
+
|
|
24
|
+
def test_raises_on_missing_colon(self):
|
|
25
|
+
with pytest.raises(ValueError, match="missing function"):
|
|
26
|
+
import_from_string("os.path.join")
|
|
27
|
+
|
|
28
|
+
def test_raises_on_invalid_module(self):
|
|
29
|
+
with pytest.raises(ModuleNotFoundError):
|
|
30
|
+
import_from_string("nonexistent.module:func")
|
|
31
|
+
|
|
32
|
+
def test_raises_on_invalid_attribute(self):
|
|
33
|
+
with pytest.raises(AttributeError):
|
|
34
|
+
import_from_string("os.path:nonexistent_func")
|
|
35
|
+
|
|
36
|
+
def test_adds_cwd_to_path(self, tmp_path, monkeypatch):
|
|
37
|
+
monkeypatch.chdir(tmp_path)
|
|
38
|
+
|
|
39
|
+
# Create a temp module
|
|
40
|
+
(tmp_path / "temp_module.py").write_text("my_var = 42")
|
|
41
|
+
|
|
42
|
+
result = import_from_string("temp_module:my_var")
|
|
43
|
+
assert result == 42
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestGetUserCli:
|
|
47
|
+
def test_returns_none_on_import_error(self):
|
|
48
|
+
with patch("cuneus.cli.Settings") as mock_settings:
|
|
49
|
+
mock_settings.return_value.cli_module = "nonexistent:cli"
|
|
50
|
+
result = get_user_cli()
|
|
51
|
+
assert result is None
|
|
52
|
+
|
|
53
|
+
def test_returns_cli_on_success(self):
|
|
54
|
+
import click
|
|
55
|
+
|
|
56
|
+
@click.group()
|
|
57
|
+
def user_cli():
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
with (
|
|
61
|
+
patch("cuneus.cli.Settings") as mock_settings,
|
|
62
|
+
patch("cuneus.cli.import_from_string", return_value=user_cli),
|
|
63
|
+
):
|
|
64
|
+
mock_settings.return_value.cli_module = "myapp:cli"
|
|
65
|
+
result = get_user_cli()
|
|
66
|
+
assert result is user_cli
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestCliCommands:
|
|
70
|
+
@pytest.fixture
|
|
71
|
+
def runner(self):
|
|
72
|
+
return CliRunner()
|
|
73
|
+
|
|
74
|
+
def test_cli_help(self, runner):
|
|
75
|
+
result = runner.invoke(cli, ["--help"])
|
|
76
|
+
assert result.exit_code == 0
|
|
77
|
+
assert "Cuneus CLI" in result.output
|
|
78
|
+
|
|
79
|
+
def test_dev_command(self, runner):
|
|
80
|
+
with (
|
|
81
|
+
patch("cuneus.cli.Settings") as mock_settings,
|
|
82
|
+
patch("uvicorn.run") as mock_run,
|
|
83
|
+
):
|
|
84
|
+
mock_settings.return_value.app_module = "myapp:app"
|
|
85
|
+
|
|
86
|
+
result = runner.invoke(dev, ["--host", "127.0.0.1", "--port", "3000"])
|
|
87
|
+
|
|
88
|
+
assert result.exit_code == 0
|
|
89
|
+
mock_run.assert_called_once_with(
|
|
90
|
+
"myapp:app",
|
|
91
|
+
host="127.0.0.1",
|
|
92
|
+
port=3000,
|
|
93
|
+
reload=True,
|
|
94
|
+
log_config=None,
|
|
95
|
+
server_header=False,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def test_dev_command_defaults(self, runner):
|
|
99
|
+
with (
|
|
100
|
+
patch("cuneus.cli.Settings") as mock_settings,
|
|
101
|
+
patch("uvicorn.run") as mock_run,
|
|
102
|
+
):
|
|
103
|
+
mock_settings.return_value.app_module = "myapp:app"
|
|
104
|
+
|
|
105
|
+
result = runner.invoke(dev)
|
|
106
|
+
|
|
107
|
+
assert result.exit_code == 0
|
|
108
|
+
mock_run.assert_called_once_with(
|
|
109
|
+
"myapp:app",
|
|
110
|
+
host="0.0.0.0",
|
|
111
|
+
port=8000,
|
|
112
|
+
reload=True,
|
|
113
|
+
log_config=None,
|
|
114
|
+
server_header=False,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def test_prod_command(self, runner):
|
|
118
|
+
with (
|
|
119
|
+
patch("cuneus.cli.Settings") as mock_settings,
|
|
120
|
+
patch("uvicorn.run") as mock_run,
|
|
121
|
+
):
|
|
122
|
+
mock_settings.return_value.app_module = "myapp:app"
|
|
123
|
+
|
|
124
|
+
result = runner.invoke(prod, ["--workers", "4"])
|
|
125
|
+
|
|
126
|
+
assert result.exit_code == 0
|
|
127
|
+
mock_run.assert_called_once_with(
|
|
128
|
+
"myapp:app",
|
|
129
|
+
host="0.0.0.0",
|
|
130
|
+
port=8000,
|
|
131
|
+
workers=4,
|
|
132
|
+
log_config=None,
|
|
133
|
+
server_header=False,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def test_routes_command(self, runner):
|
|
137
|
+
mock_app = MagicMock()
|
|
138
|
+
mock_route = MagicMock()
|
|
139
|
+
mock_route.methods = {"GET", "HEAD", "OPTIONS"}
|
|
140
|
+
mock_route.path = "/users"
|
|
141
|
+
mock_app.routes = [mock_route]
|
|
142
|
+
|
|
143
|
+
with (
|
|
144
|
+
patch("cuneus.cli.Settings") as mock_settings,
|
|
145
|
+
patch("cuneus.cli.import_from_string", return_value=mock_app),
|
|
146
|
+
):
|
|
147
|
+
mock_settings.return_value.app_module = "myapp:app"
|
|
148
|
+
|
|
149
|
+
result = runner.invoke(routes)
|
|
150
|
+
|
|
151
|
+
assert result.exit_code == 0
|
|
152
|
+
assert "GET" in result.output
|
|
153
|
+
assert "/users" in result.output
|
|
154
|
+
assert "HEAD" not in result.output
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TestCuneusCLI:
|
|
158
|
+
@pytest.fixture
|
|
159
|
+
def runner(self):
|
|
160
|
+
return CliRunner()
|
|
161
|
+
|
|
162
|
+
def test_has_base_commands(self):
|
|
163
|
+
cli = CuneusCLI()
|
|
164
|
+
commands = cli.list_commands(None) # type: ignore
|
|
165
|
+
assert "dev" in commands
|
|
166
|
+
assert "prod" in commands
|
|
167
|
+
assert "routes" in commands
|
|
168
|
+
|
|
169
|
+
def test_merges_user_commands(self):
|
|
170
|
+
import click
|
|
171
|
+
|
|
172
|
+
@click.group()
|
|
173
|
+
def user_cli():
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
@user_cli.command()
|
|
177
|
+
def custom():
|
|
178
|
+
click.echo("custom command")
|
|
179
|
+
|
|
180
|
+
cli = CuneusCLI()
|
|
181
|
+
cli._user_cli = user_cli
|
|
182
|
+
cli._user_cli_loaded = True
|
|
183
|
+
|
|
184
|
+
commands = cli.list_commands(None) # type: ignore
|
|
185
|
+
assert "dev" in commands
|
|
186
|
+
assert "custom" in commands
|
|
187
|
+
|
|
188
|
+
def test_user_cli_takes_priority(self):
|
|
189
|
+
import click
|
|
190
|
+
|
|
191
|
+
@click.group()
|
|
192
|
+
def user_cli():
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
@user_cli.command()
|
|
196
|
+
def dev():
|
|
197
|
+
click.echo("user dev")
|
|
198
|
+
|
|
199
|
+
cli = CuneusCLI()
|
|
200
|
+
cli._user_cli = user_cli
|
|
201
|
+
cli._user_cli_loaded = True
|
|
202
|
+
|
|
203
|
+
ctx = click.Context(cli)
|
|
204
|
+
cmd = cli.get_command(ctx, "dev")
|
|
205
|
+
assert cmd is not None
|
|
206
|
+
|
|
207
|
+
# Should get user's dev, not base dev
|
|
208
|
+
runner = CliRunner()
|
|
209
|
+
result = runner.invoke(cmd)
|
|
210
|
+
assert "user dev" in result.output
|
|
211
|
+
|
|
212
|
+
def test_falls_back_to_base_command(self):
|
|
213
|
+
import click
|
|
214
|
+
|
|
215
|
+
@click.group()
|
|
216
|
+
def user_cli():
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
cli = CuneusCLI()
|
|
220
|
+
cli._user_cli = user_cli
|
|
221
|
+
cli._user_cli_loaded = True
|
|
222
|
+
|
|
223
|
+
ctx = click.Context(cli)
|
|
224
|
+
cmd = cli.get_command(ctx, "routes")
|
|
225
|
+
|
|
226
|
+
assert cmd is not None
|
|
227
|
+
assert cmd.name == "routes"
|
|
228
|
+
|
|
229
|
+
def test_lazy_loads_user_cli(self):
|
|
230
|
+
cli = CuneusCLI()
|
|
231
|
+
assert cli._user_cli_loaded is False
|
|
232
|
+
|
|
233
|
+
with patch("cuneus.cli.get_user_cli", return_value=None) as mock_get:
|
|
234
|
+
_ = cli.user_cli
|
|
235
|
+
mock_get.assert_called_once()
|
|
236
|
+
assert cli._user_cli_loaded is True
|
|
237
|
+
|
|
238
|
+
# Second access doesn't reload
|
|
239
|
+
_ = cli.user_cli
|
|
240
|
+
mock_get.assert_called_once()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TestMain:
|
|
244
|
+
def test_main_is_cuneus_cli(self):
|
|
245
|
+
assert isinstance(main, CuneusCLI)
|
|
246
|
+
|
|
247
|
+
def test_main_help(self):
|
|
248
|
+
runner = CliRunner()
|
|
249
|
+
with patch.object(CuneusCLI, "user_cli", None):
|
|
250
|
+
result = runner.invoke(main, ["--help"])
|
|
251
|
+
assert result.exit_code == 0
|
|
252
|
+
assert "Cuneus CLI" in result.output
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
from starlette.testclient import TestClient
|
|
4
|
+
|
|
5
|
+
from cuneus import (
|
|
6
|
+
AppException,
|
|
7
|
+
BadRequest,
|
|
8
|
+
Conflict,
|
|
9
|
+
DatabaseError,
|
|
10
|
+
ErrorResponse,
|
|
11
|
+
ExceptionExtension,
|
|
12
|
+
ExternalServiceError,
|
|
13
|
+
Forbidden,
|
|
14
|
+
NotFound,
|
|
15
|
+
RateLimited,
|
|
16
|
+
RedisError,
|
|
17
|
+
ServiceUnavailable,
|
|
18
|
+
Settings,
|
|
19
|
+
Unauthorized,
|
|
20
|
+
build_app,
|
|
21
|
+
error_responses,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestAppException:
|
|
26
|
+
def test_defaults(self):
|
|
27
|
+
exc = AppException()
|
|
28
|
+
assert exc.status_code == 500
|
|
29
|
+
assert exc.error_code == "internal_error"
|
|
30
|
+
assert exc.message == "An unexpected error occurred"
|
|
31
|
+
assert exc.details == {}
|
|
32
|
+
|
|
33
|
+
def test_custom_values(self):
|
|
34
|
+
exc = AppException(
|
|
35
|
+
"Something broke",
|
|
36
|
+
error_code="custom_error",
|
|
37
|
+
status_code=418,
|
|
38
|
+
details={"foo": "bar"},
|
|
39
|
+
)
|
|
40
|
+
assert exc.message == "Something broke"
|
|
41
|
+
assert exc.error_code == "custom_error"
|
|
42
|
+
assert exc.status_code == 418
|
|
43
|
+
assert exc.details == {"foo": "bar"}
|
|
44
|
+
|
|
45
|
+
def test_to_response(self):
|
|
46
|
+
exc = AppException("Test error", error_code="test", details={"key": "value"})
|
|
47
|
+
response = exc.to_response(request_id="req-123")
|
|
48
|
+
|
|
49
|
+
assert isinstance(response, ErrorResponse)
|
|
50
|
+
assert response.error.status == 500
|
|
51
|
+
assert response.error.code == "test"
|
|
52
|
+
assert response.error.message == "Test error"
|
|
53
|
+
assert response.error.request_id == "req-123"
|
|
54
|
+
assert response.error.details == {"key": "value"}
|
|
55
|
+
|
|
56
|
+
def test_to_response_no_request_id(self):
|
|
57
|
+
exc = AppException()
|
|
58
|
+
response = exc.to_response()
|
|
59
|
+
assert response.error.request_id is None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestHttpExceptions:
|
|
63
|
+
@pytest.mark.parametrize(
|
|
64
|
+
"exc_class,status,code",
|
|
65
|
+
[
|
|
66
|
+
(BadRequest, 400, "bad_request"),
|
|
67
|
+
(Unauthorized, 401, "unauthorized"),
|
|
68
|
+
(Forbidden, 403, "forbidden"),
|
|
69
|
+
(NotFound, 404, "not_found"),
|
|
70
|
+
(Conflict, 409, "conflict"),
|
|
71
|
+
(RateLimited, 429, "rate_limited"),
|
|
72
|
+
(ServiceUnavailable, 503, "service_unavailable"),
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
def test_http_exception_defaults(self, exc_class, status, code):
|
|
76
|
+
exc = exc_class()
|
|
77
|
+
assert exc.status_code == status
|
|
78
|
+
assert exc.error_code == code
|
|
79
|
+
assert isinstance(exc, AppException)
|
|
80
|
+
|
|
81
|
+
def test_rate_limited_retry_after(self):
|
|
82
|
+
exc = RateLimited(retry_after=60)
|
|
83
|
+
assert exc.retry_after == 60
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestInfrastructureExceptions:
|
|
87
|
+
@pytest.mark.parametrize(
|
|
88
|
+
"exc_class,status,code",
|
|
89
|
+
[
|
|
90
|
+
(DatabaseError, 503, "database_error"),
|
|
91
|
+
(RedisError, 503, "cache_error"),
|
|
92
|
+
(ExternalServiceError, 502, "external_service_error"),
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
def test_infra_exception_defaults(self, exc_class, status, code):
|
|
96
|
+
exc = exc_class()
|
|
97
|
+
assert exc.status_code == status
|
|
98
|
+
assert exc.error_code == code
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestErrorResponses:
|
|
102
|
+
def test_single_exception(self):
|
|
103
|
+
responses = error_responses(NotFound())
|
|
104
|
+
assert 404 in responses
|
|
105
|
+
assert responses[404]["model"] == ErrorResponse
|
|
106
|
+
|
|
107
|
+
def test_multiple_exceptions(self):
|
|
108
|
+
responses = error_responses(NotFound(), BadRequest(), Forbidden())
|
|
109
|
+
assert set(responses.keys()) == {400, 403, 404}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestExceptionExtension:
|
|
113
|
+
@pytest.fixture
|
|
114
|
+
def app(self, request):
|
|
115
|
+
params = getattr(request, "param", {})
|
|
116
|
+
settings = Settings(**params)
|
|
117
|
+
app, _ = build_app(settings=settings)
|
|
118
|
+
|
|
119
|
+
@app.get("/app-error")
|
|
120
|
+
async def raise_app_error():
|
|
121
|
+
raise NotFound("Item not found", details={"id": 123})
|
|
122
|
+
|
|
123
|
+
@app.get("/unexpected")
|
|
124
|
+
async def raise_unexpected():
|
|
125
|
+
raise RuntimeError("Boom")
|
|
126
|
+
|
|
127
|
+
@app.get("/server-error")
|
|
128
|
+
async def raise_server_error():
|
|
129
|
+
raise AppException("Boom")
|
|
130
|
+
|
|
131
|
+
@app.get("/rate-limited")
|
|
132
|
+
async def raise_rate_limited():
|
|
133
|
+
raise RateLimited(retry_after=30)
|
|
134
|
+
|
|
135
|
+
return app
|
|
136
|
+
|
|
137
|
+
@pytest.fixture
|
|
138
|
+
def client(self, app):
|
|
139
|
+
with TestClient(app, raise_server_exceptions=False) as client:
|
|
140
|
+
yield client
|
|
141
|
+
|
|
142
|
+
def test_handles_app_exception(self, client):
|
|
143
|
+
resp = client.get("/app-error")
|
|
144
|
+
assert resp.status_code == 404, resp.text
|
|
145
|
+
body = resp.json()
|
|
146
|
+
assert body["error"]["code"] == "not_found"
|
|
147
|
+
assert body["error"]["message"] == "Item not found"
|
|
148
|
+
assert body["error"]["details"] == {"id": 123}
|
|
149
|
+
|
|
150
|
+
@pytest.mark.parametrize("app", [{"debug": False}], indirect=True)
|
|
151
|
+
def test_handles_unexpected_exception(self, client):
|
|
152
|
+
resp = client.get("/unexpected")
|
|
153
|
+
assert resp.status_code == 500, resp.text
|
|
154
|
+
body = resp.json()
|
|
155
|
+
assert body["error"]["code"] == "internal_error"
|
|
156
|
+
assert body["error"]["message"] == "An unexpected error occurred"
|
|
157
|
+
assert "details" not in body["error"]
|
|
158
|
+
|
|
159
|
+
@pytest.mark.parametrize("app", [{"debug": True}], indirect=True)
|
|
160
|
+
def test_debug_mode_includes_exception_details(self, client):
|
|
161
|
+
resp = client.get("/unexpected")
|
|
162
|
+
assert resp.status_code == 500, resp.text
|
|
163
|
+
body = resp.json()
|
|
164
|
+
assert body["error"]["details"]["exception"] == "RuntimeError"
|
|
165
|
+
assert body["error"]["details"]["message"] == "Boom"
|
|
166
|
+
|
|
167
|
+
@pytest.mark.parametrize("app", [{"log_server_errors": True}], indirect=True)
|
|
168
|
+
def test_log_server_errors(self, client):
|
|
169
|
+
resp = client.get("/server-error")
|
|
170
|
+
assert resp.status_code == 500, resp.text
|
|
171
|
+
body = resp.json()
|
|
172
|
+
assert body["error"]["code"] == "internal_error"
|
|
173
|
+
assert body["error"]["message"] == "Boom"
|
|
174
|
+
|
|
175
|
+
def test_rate_limited_includes_retry_after_header(self, client):
|
|
176
|
+
resp = client.get("/rate-limited")
|
|
177
|
+
assert resp.status_code == 429, resp.text
|
|
178
|
+
assert resp.headers["Retry-After"] == "30"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from starlette.testclient import TestClient
|
|
7
|
+
from svcs import Registry
|
|
8
|
+
|
|
9
|
+
from cuneus import build_app, BaseExtension
|
|
10
|
+
from cuneus.ext import health
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MyParamLessExtension(BaseExtension):
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MyExtraSettings(BaseExtension):
|
|
20
|
+
|
|
21
|
+
def __init__(self, debug: bool):
|
|
22
|
+
self.debug = debug
|
|
23
|
+
|
|
24
|
+
async def startup(self, registry: Registry, app: FastAPI) -> dict[str, Any]:
|
|
25
|
+
await super().startup(registry, app)
|
|
26
|
+
return {"my_ext": {"debug": self.debug}}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MyConflictState(BaseExtension):
|
|
30
|
+
|
|
31
|
+
def __init__(self, debug: bool):
|
|
32
|
+
self.debug = debug
|
|
33
|
+
|
|
34
|
+
async def startup(self, registry: Registry, app: FastAPI) -> dict[str, Any]:
|
|
35
|
+
await super().startup(registry, app)
|
|
36
|
+
return {"my_ext": {"debug": self.debug}}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def test_cuneus_defaults():
|
|
40
|
+
app, _ = build_app()
|
|
41
|
+
|
|
42
|
+
@app.get("/some_path")
|
|
43
|
+
async def something():
|
|
44
|
+
return {"it": "works"}
|
|
45
|
+
|
|
46
|
+
with TestClient(app) as client:
|
|
47
|
+
resp = client.get("/healthz")
|
|
48
|
+
assert resp.status_code == 200
|
|
49
|
+
assert resp.headers.get("X-Request-ID") is None
|
|
50
|
+
assert resp.json()["status"] == health.HealthStatus.HEALTHY
|
|
51
|
+
|
|
52
|
+
# Test other health routes
|
|
53
|
+
resp = client.get("/healthz/live")
|
|
54
|
+
assert resp.status_code == 200
|
|
55
|
+
resp = client.get("/healthz/ready")
|
|
56
|
+
assert resp.status_code == 200
|
|
57
|
+
|
|
58
|
+
resp = client.get("/some_path")
|
|
59
|
+
assert resp.status_code == 200
|
|
60
|
+
assert resp.headers["X-Request-ID"] is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def test_cuneus_custom_extension():
|
|
64
|
+
app, _ = build_app(MyParamLessExtension, MyExtraSettings(debug=True))
|
|
65
|
+
|
|
66
|
+
@app.get("/some_path")
|
|
67
|
+
async def something(request: Request):
|
|
68
|
+
return request.state.my_ext
|
|
69
|
+
|
|
70
|
+
with TestClient(app) as client:
|
|
71
|
+
resp = client.get("/healthz")
|
|
72
|
+
assert resp.status_code == 200
|
|
73
|
+
assert resp.headers.get("X-Request-ID") is None
|
|
74
|
+
|
|
75
|
+
assert resp.json()["status"] == health.HealthStatus.HEALTHY
|
|
76
|
+
|
|
77
|
+
resp = client.get("/some_path")
|
|
78
|
+
assert resp.status_code == 200
|
|
79
|
+
assert resp.headers["X-Request-ID"] is not None
|
|
80
|
+
assert resp.json() == {"debug": True}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def test_cuneus_conflict():
|
|
84
|
+
app, _ = build_app(MyExtraSettings(debug=True), MyConflictState(False))
|
|
85
|
+
|
|
86
|
+
@app.get("/some_path")
|
|
87
|
+
async def something():
|
|
88
|
+
return {"it": "works"}
|
|
89
|
+
|
|
90
|
+
with pytest.raises(Exception):
|
|
91
|
+
with TestClient(app) as client:
|
|
92
|
+
resp = client.get("/healthz")
|
|
93
|
+
assert resp.status_code == 200
|
|
94
|
+
assert resp.headers.get("X-Request-ID") is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_build_app_setup():
|
|
98
|
+
with pytest.raises(AttributeError):
|
|
99
|
+
build_app(lifespan={"lifespan": "this is not allowed"})
|
|
100
|
+
|
|
101
|
+
with pytest.raises(AttributeError):
|
|
102
|
+
build_app(middleware=[{"not": "allowed"}])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_cli():
|
|
106
|
+
_, cli = build_app(MyParamLessExtension, MyExtraSettings(debug=True))
|
|
107
|
+
runner = CliRunner()
|
|
108
|
+
result = runner.invoke(cli, ["--help"])
|
|
109
|
+
|
|
110
|
+
assert result.exit_code == 0
|
|
111
|
+
assert "Usage:" in result.output
|
|
@@ -249,7 +249,7 @@ toml = [
|
|
|
249
249
|
|
|
250
250
|
[[package]]
|
|
251
251
|
name = "cuneus"
|
|
252
|
-
version = "0.2.
|
|
252
|
+
version = "0.2.7"
|
|
253
253
|
source = { editable = "." }
|
|
254
254
|
dependencies = [
|
|
255
255
|
{ name = "click" },
|
|
@@ -282,6 +282,7 @@ dev = [
|
|
|
282
282
|
{ name = "pytest" },
|
|
283
283
|
{ name = "pytest-asyncio" },
|
|
284
284
|
{ name = "pytest-cov" },
|
|
285
|
+
{ name = "pytest-mock" },
|
|
285
286
|
{ name = "redis" },
|
|
286
287
|
{ name = "ruff" },
|
|
287
288
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
|
@@ -306,6 +307,7 @@ requires-dist = [
|
|
|
306
307
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
|
307
308
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
|
308
309
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
|
|
310
|
+
{ name = "pytest-mock", marker = "extra == 'dev'" },
|
|
309
311
|
{ name = "redis", marker = "extra == 'redis'", specifier = ">=5.0" },
|
|
310
312
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" },
|
|
311
313
|
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0" },
|
|
@@ -870,6 +872,18 @@ wheels = [
|
|
|
870
872
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
|
871
873
|
]
|
|
872
874
|
|
|
875
|
+
[[package]]
|
|
876
|
+
name = "pytest-mock"
|
|
877
|
+
version = "3.15.1"
|
|
878
|
+
source = { registry = "https://pypi.org/simple" }
|
|
879
|
+
dependencies = [
|
|
880
|
+
{ name = "pytest" },
|
|
881
|
+
]
|
|
882
|
+
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
|
883
|
+
wheels = [
|
|
884
|
+
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
|
885
|
+
]
|
|
886
|
+
|
|
873
887
|
[[package]]
|
|
874
888
|
name = "python-dotenv"
|
|
875
889
|
version = "1.2.1"
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
from starlette.testclient import TestClient
|
|
2
|
-
|
|
3
|
-
from cuneus import build_app
|
|
4
|
-
from cuneus.ext import health
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
async def test_cuneus():
|
|
8
|
-
app, _ = build_app()
|
|
9
|
-
|
|
10
|
-
with TestClient(app) as client:
|
|
11
|
-
resp = client.get("/healthz")
|
|
12
|
-
assert resp.status_code == 201
|
|
13
|
-
|
|
14
|
-
assert resp.json()["status"] == health.HealthStatus.HEALTHY
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|