cuneus 0.2.6__tar.gz → 0.2.8__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
@@ -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.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'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cuneus"
3
- version = "0.2.6"
3
+ version = "0.2.8"
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
 
@@ -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:
@@ -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,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", "-")
@@ -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,7 @@
1
+ from cuneus.core.extensions import BaseExtension
2
+
3
+
4
+ async def test_base_extension(mocker):
5
+ b = BaseExtension()
6
+ state = await b.startup(mocker.Mock(), mocker.Mock())
7
+ assert state == {}
@@ -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.5"
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