cuneus 0.2.8__tar.gz → 0.2.9__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.8 → cuneus-0.2.9}/PKG-INFO +1 -1
- cuneus-0.2.9/examples/my_app/main.py +6 -0
- cuneus-0.2.9/examples/pyproject.toml +7 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/pyproject.toml +1 -1
- cuneus-0.2.9/src/cuneus/cli.py +53 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/src/cuneus/core/application.py +9 -9
- {cuneus-0.2.8 → cuneus-0.2.9}/src/cuneus/core/extensions.py +7 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/src/cuneus/core/logging.py +1 -1
- {cuneus-0.2.8 → cuneus-0.2.9}/src/cuneus/core/settings.py +20 -3
- cuneus-0.2.9/src/cuneus/ext/health.py +128 -0
- cuneus-0.2.9/src/cuneus/ext/server.py +54 -0
- cuneus-0.2.9/src/cuneus/py.typed +0 -0
- cuneus-0.2.9/src/cuneus/utils.py +14 -0
- cuneus-0.2.9/tests/cli/test_cli.py +145 -0
- cuneus-0.2.9/tests/cli/testapp/__init__.py +0 -0
- cuneus-0.2.9/tests/cli/testapp/main.py +23 -0
- cuneus-0.2.9/tests/cli/testapp/pyproject.toml +9 -0
- cuneus-0.2.9/tests/ext/test_health.py +109 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/tests/test_exceptions.py +1 -1
- {cuneus-0.2.8 → cuneus-0.2.9}/tests/test_integration.py +18 -5
- cuneus-0.2.9/tests/test_utils.py +23 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/uv.lock +1 -1
- cuneus-0.2.8/src/cuneus/cli.py +0 -143
- cuneus-0.2.8/src/cuneus/ext/health.py +0 -129
- cuneus-0.2.8/tests/test_cli.py +0 -252
- {cuneus-0.2.8 → cuneus-0.2.9}/.gitignore +0 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/.python-version +0 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/Makefile +0 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/README.md +0 -0
- {cuneus-0.2.8/src/cuneus/core → cuneus-0.2.9/examples/my_app}/__init__.py +0 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/src/cuneus/__init__.py +0 -0
- {cuneus-0.2.8/src/cuneus/ext → cuneus-0.2.9/src/cuneus/core}/__init__.py +0 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/src/cuneus/core/exceptions.py +0 -0
- /cuneus-0.2.8/src/cuneus/py.typed → /cuneus-0.2.9/src/cuneus/ext/__init__.py +0 -0
- {cuneus-0.2.8 → cuneus-0.2.9}/tests/test_extensions.py +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Cuneus CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .core.settings import Settings, ensure_project_in_path
|
|
8
|
+
from .utils import import_from_string
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_user_cli(config: Settings = Settings()) -> click.Group | None:
|
|
12
|
+
"""Load CLI from config."""
|
|
13
|
+
try:
|
|
14
|
+
return cast(click.Group, import_from_string(config.cli_module))
|
|
15
|
+
except (ImportError, AttributeError) as e:
|
|
16
|
+
click.echo(
|
|
17
|
+
f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True
|
|
18
|
+
)
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CuneusCLI(click.Group):
|
|
23
|
+
"""Delegates to the app's CLI from config."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
26
|
+
super().__init__(*args, **kwargs)
|
|
27
|
+
self._user_cli: click.Group | None = None
|
|
28
|
+
self._user_cli_loaded = False
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def user_cli(self) -> click.Group | None:
|
|
32
|
+
if not self._user_cli_loaded:
|
|
33
|
+
self._user_cli = get_user_cli()
|
|
34
|
+
self._user_cli_loaded = True
|
|
35
|
+
return self._user_cli
|
|
36
|
+
|
|
37
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
38
|
+
if self.user_cli:
|
|
39
|
+
return self.user_cli.list_commands(ctx)
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
43
|
+
if self.user_cli:
|
|
44
|
+
return self.user_cli.get_command(ctx, cmd_name)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ensure_project_in_path()
|
|
49
|
+
main = CuneusCLI(help="Cuneus CLI - FastAPI application framework")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -19,8 +19,9 @@ from starlette.middleware import Middleware
|
|
|
19
19
|
from .settings import Settings
|
|
20
20
|
from .exceptions import ExceptionExtension
|
|
21
21
|
from .logging import LoggingExtension
|
|
22
|
-
from .extensions import Extension, HasCLI, HasExceptionHandler, HasMiddleware
|
|
22
|
+
from .extensions import Extension, HasCLI, HasExceptionHandler, HasMiddleware, HasRoutes
|
|
23
23
|
from ..ext.health import HealthExtension
|
|
24
|
+
from ..ext.server import ServerExtension
|
|
24
25
|
|
|
25
26
|
logger = structlog.stdlib.get_logger("cuneus")
|
|
26
27
|
|
|
@@ -30,6 +31,7 @@ DEFAULTS = (
|
|
|
30
31
|
LoggingExtension,
|
|
31
32
|
HealthExtension,
|
|
32
33
|
ExceptionExtension,
|
|
34
|
+
ServerExtension,
|
|
33
35
|
)
|
|
34
36
|
|
|
35
37
|
|
|
@@ -56,7 +58,7 @@ def build_app(
|
|
|
56
58
|
settings: Settings | None = None,
|
|
57
59
|
include_defaults: bool = True,
|
|
58
60
|
**fastapi_kwargs: Any,
|
|
59
|
-
) -> tuple[FastAPI, click.Group]:
|
|
61
|
+
) -> tuple[FastAPI, click.Group, svcs.fastapi.lifespan]:
|
|
60
62
|
"""
|
|
61
63
|
Build a FastAPI with extensions preconfigured.
|
|
62
64
|
|
|
@@ -93,12 +95,6 @@ def build_app(
|
|
|
93
95
|
|
|
94
96
|
all_extensions = [_instantiate_extension(ext, settings) for ext in all_inputs]
|
|
95
97
|
|
|
96
|
-
@click.group()
|
|
97
|
-
@click.pass_context
|
|
98
|
-
def app_cli(ctx: click.Context) -> None:
|
|
99
|
-
"""Application CLI."""
|
|
100
|
-
ctx.ensure_object(dict)
|
|
101
|
-
|
|
102
98
|
@svcs.fastapi.lifespan
|
|
103
99
|
@asynccontextmanager
|
|
104
100
|
async def lifespan(
|
|
@@ -121,6 +117,7 @@ def build_app(
|
|
|
121
117
|
|
|
122
118
|
# Parse extensions for middleware and cli commands
|
|
123
119
|
middleware: list[Middleware] = []
|
|
120
|
+
app_cli = click.Group()
|
|
124
121
|
|
|
125
122
|
for ext in all_extensions:
|
|
126
123
|
ext_name = ext.__class__.__name__
|
|
@@ -139,5 +136,8 @@ def build_app(
|
|
|
139
136
|
if isinstance(ext, HasExceptionHandler):
|
|
140
137
|
logger.debug(f"Loading exception handlers from {ext_name}")
|
|
141
138
|
ext.add_exception_handler(app)
|
|
139
|
+
if isinstance(ext, HasRoutes):
|
|
140
|
+
logger.debug(f"Loading routes from {ext_name}")
|
|
141
|
+
ext.add_routes(app)
|
|
142
142
|
|
|
143
|
-
return app, app_cli
|
|
143
|
+
return app, app_cli, lifespan
|
|
@@ -67,6 +67,13 @@ class HasExceptionHandler(Protocol):
|
|
|
67
67
|
def add_exception_handler(self, app: FastAPI) -> None: ... # pragma: no cover
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
@runtime_checkable
|
|
71
|
+
class HasRoutes(Protocol):
|
|
72
|
+
"""Extension that provides routes."""
|
|
73
|
+
|
|
74
|
+
def add_routes(self, app: FastAPI) -> None: ... # pragma: no cover
|
|
75
|
+
|
|
76
|
+
|
|
70
77
|
class BaseExtension:
|
|
71
78
|
"""
|
|
72
79
|
Base class for extensions with explicit startup/shutdown hooks.
|
|
@@ -43,7 +43,7 @@ def configure_structlog(settings: Settings | None = None) -> None:
|
|
|
43
43
|
term_width = shutil.get_terminal_size().columns
|
|
44
44
|
pad_event = term_width - 36
|
|
45
45
|
renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(
|
|
46
|
-
colors=True,
|
|
46
|
+
colors=True, pad_event_to=pad_event
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
# Configure structlog
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
4
5
|
from pydantic_settings import (
|
|
5
6
|
BaseSettings,
|
|
6
7
|
PydanticBaseSettingsSource,
|
|
@@ -8,8 +9,6 @@ from pydantic_settings import (
|
|
|
8
9
|
SettingsConfigDict,
|
|
9
10
|
)
|
|
10
11
|
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
12
|
DEFAULT_TOOL_NAME = "cuneus"
|
|
14
13
|
|
|
15
14
|
|
|
@@ -64,3 +63,21 @@ class Settings(CuneusBaseSettings):
|
|
|
64
63
|
# health
|
|
65
64
|
health_enabled: bool = True
|
|
66
65
|
health_prefix: str = "/healthz"
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def get_project_root(cls) -> pathlib.Path:
|
|
69
|
+
"""
|
|
70
|
+
Get the project root by inspecting where pydantic-settings
|
|
71
|
+
found the pyproject.toml file.
|
|
72
|
+
"""
|
|
73
|
+
source = PyprojectTomlConfigSettingsSource(
|
|
74
|
+
cls,
|
|
75
|
+
)
|
|
76
|
+
return source.toml_file_path.parent
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ensure_project_in_path() -> None:
|
|
80
|
+
"""Add project root to sys.path if not already present."""
|
|
81
|
+
project_root = str(Settings.get_project_root())
|
|
82
|
+
if project_root not in sys.path: # pragma: no branch
|
|
83
|
+
sys.path.insert(0, project_root)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health check endpoints using svcs ping capabilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
import svcs
|
|
12
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from ..core.extensions import BaseExtension
|
|
16
|
+
from ..core.settings import Settings
|
|
17
|
+
|
|
18
|
+
log = structlog.get_logger()
|
|
19
|
+
health_router = APIRouter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HealthStatus(str, Enum):
|
|
23
|
+
HEALTHY = "healthy"
|
|
24
|
+
UNHEALTHY = "unhealthy"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ServiceHealth(BaseModel):
|
|
28
|
+
name: str
|
|
29
|
+
status: HealthStatus
|
|
30
|
+
message: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HealthResponse(BaseModel):
|
|
34
|
+
status: HealthStatus
|
|
35
|
+
version: str | None = None
|
|
36
|
+
services: list[ServiceHealth] = []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@health_router.get("", response_model=HealthResponse)
|
|
40
|
+
async def health(
|
|
41
|
+
services: svcs.fastapi.DepContainer, request: Request
|
|
42
|
+
) -> HealthResponse:
|
|
43
|
+
"""Full health check - pings all registered services."""
|
|
44
|
+
pings = services.get_pings()
|
|
45
|
+
|
|
46
|
+
_services: list[ServiceHealth] = []
|
|
47
|
+
overall_healthy = True
|
|
48
|
+
|
|
49
|
+
for ping in pings:
|
|
50
|
+
try:
|
|
51
|
+
await ping.aping()
|
|
52
|
+
_services.append(
|
|
53
|
+
ServiceHealth(
|
|
54
|
+
name=ping.name,
|
|
55
|
+
status=HealthStatus.HEALTHY,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
log.warning("health_check_failed", service=ping.name, error=str(e))
|
|
60
|
+
_services.append(
|
|
61
|
+
ServiceHealth(
|
|
62
|
+
name=ping.name,
|
|
63
|
+
status=HealthStatus.UNHEALTHY,
|
|
64
|
+
message=str(e),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
overall_healthy = False
|
|
68
|
+
|
|
69
|
+
return HealthResponse(
|
|
70
|
+
status=(HealthStatus.HEALTHY if overall_healthy else HealthStatus.UNHEALTHY),
|
|
71
|
+
version=getattr(request.state, "version", "NA"),
|
|
72
|
+
services=_services,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@health_router.get("/live")
|
|
77
|
+
async def liveness() -> dict[str, str]:
|
|
78
|
+
"""Liveness probe - is the process running?"""
|
|
79
|
+
return {"status": "ok"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@health_router.get("/ready")
|
|
83
|
+
async def readiness(services: svcs.fastapi.DepContainer) -> dict[str, str]:
|
|
84
|
+
"""Readiness probe - can we serve traffic?"""
|
|
85
|
+
from fastapi import HTTPException
|
|
86
|
+
|
|
87
|
+
pings = services.get_pings()
|
|
88
|
+
|
|
89
|
+
for ping in pings:
|
|
90
|
+
try:
|
|
91
|
+
await ping.aping()
|
|
92
|
+
except Exception as e:
|
|
93
|
+
log.warning("readiness_check_failed", service=ping.name, error=str(e))
|
|
94
|
+
raise HTTPException(status_code=503, detail=f"{ping.name} unhealthy")
|
|
95
|
+
|
|
96
|
+
return {"status": "ok"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class HealthExtension(BaseExtension):
|
|
100
|
+
"""
|
|
101
|
+
Health check extension using svcs pings.
|
|
102
|
+
|
|
103
|
+
Adds:
|
|
104
|
+
GET /healthz - Full health check using svcs pings
|
|
105
|
+
GET /healthz/live - Liveness probe (always 200)
|
|
106
|
+
GET /healthz/ready - Readiness probe (503 if unhealthy)
|
|
107
|
+
|
|
108
|
+
Settings:
|
|
109
|
+
- `health_enabled`: bool to disable health routes, default True
|
|
110
|
+
- `health_prefix`: prefix to include routes default: '/healthz'
|
|
111
|
+
- `version`: used to display in the response
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
115
|
+
self.settings = settings or Settings()
|
|
116
|
+
|
|
117
|
+
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
118
|
+
state = await super().startup(registry, app)
|
|
119
|
+
state["version"] = self.settings.version
|
|
120
|
+
return state
|
|
121
|
+
|
|
122
|
+
def add_routes(self, app: FastAPI) -> None:
|
|
123
|
+
if self.settings.health_enabled:
|
|
124
|
+
app.include_router(
|
|
125
|
+
health_router,
|
|
126
|
+
prefix=self.settings.health_prefix,
|
|
127
|
+
tags=["health"],
|
|
128
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from ..core.extensions import BaseExtension
|
|
4
|
+
from ..utils import import_from_string
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ServerExtension(BaseExtension):
|
|
8
|
+
"""Provides dev/prod/routes CLI commands."""
|
|
9
|
+
|
|
10
|
+
def register_cli(self, cli_group: click.Group) -> None:
|
|
11
|
+
settings = self.settings
|
|
12
|
+
|
|
13
|
+
@cli_group.command()
|
|
14
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
15
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
16
|
+
def dev(host: str, port: int) -> None:
|
|
17
|
+
"""Run the application in development mode with reload."""
|
|
18
|
+
import uvicorn
|
|
19
|
+
|
|
20
|
+
uvicorn.run(
|
|
21
|
+
settings.app_module,
|
|
22
|
+
host=host,
|
|
23
|
+
port=port,
|
|
24
|
+
reload=True,
|
|
25
|
+
log_config=None,
|
|
26
|
+
server_header=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@cli_group.command()
|
|
30
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
31
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
32
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
33
|
+
def prod(host: str, port: int, workers: int) -> None:
|
|
34
|
+
"""Run the application in production mode."""
|
|
35
|
+
import uvicorn
|
|
36
|
+
|
|
37
|
+
uvicorn.run(
|
|
38
|
+
settings.app_module,
|
|
39
|
+
host=host,
|
|
40
|
+
port=port,
|
|
41
|
+
workers=workers,
|
|
42
|
+
log_config=None,
|
|
43
|
+
server_header=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@cli_group.command()
|
|
47
|
+
def routes() -> None:
|
|
48
|
+
"""List all registered routes."""
|
|
49
|
+
app = import_from_string(settings.app_module)
|
|
50
|
+
|
|
51
|
+
for route in app.routes:
|
|
52
|
+
if hasattr(route, "methods"): # pragma: no branch
|
|
53
|
+
methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
|
|
54
|
+
click.echo(f"{methods:8} {route.path}")
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def import_from_string(import_str: str) -> typing.Any:
|
|
6
|
+
"""Import an object from a module:attribute string."""
|
|
7
|
+
module_path, _, attr = import_str.partition(":")
|
|
8
|
+
if not attr:
|
|
9
|
+
raise ValueError(
|
|
10
|
+
f"module_path missing function {import_str} expecting 'module.path:name'"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
module = importlib.import_module(module_path)
|
|
14
|
+
return getattr(module, attr)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# tests/cli/test_cli_integration.py
|
|
2
|
+
"""Integration tests for the cuneus CLI."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from click.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TEST_APP_DIR = Path(__file__).parent / "testapp"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def cli_runner():
|
|
16
|
+
return CliRunner()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def test_app(cli_runner, tmp_path):
|
|
21
|
+
"""Set up isolated test app environment."""
|
|
22
|
+
import shutil
|
|
23
|
+
|
|
24
|
+
with cli_runner.isolated_filesystem(temp_dir=tmp_path):
|
|
25
|
+
# Copy pyproject.toml, main.py, etc. to project root (cwd)
|
|
26
|
+
shutil.copytree(TEST_APP_DIR, Path.cwd(), dirs_exist_ok=True)
|
|
27
|
+
|
|
28
|
+
for mod in list(sys.modules):
|
|
29
|
+
if mod.startswith(("main", "cuneus.cli")):
|
|
30
|
+
del sys.modules[mod]
|
|
31
|
+
|
|
32
|
+
yield Path.cwd()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestCLIDiscovery:
|
|
36
|
+
def test_discovers_app_commands(self, cli_runner, test_app):
|
|
37
|
+
from cuneus.cli import CuneusCLI
|
|
38
|
+
|
|
39
|
+
cli = CuneusCLI()
|
|
40
|
+
commands = cli.list_commands(None) # type: ignore
|
|
41
|
+
|
|
42
|
+
assert "dev" in commands
|
|
43
|
+
assert "prod" in commands
|
|
44
|
+
assert "routes" in commands
|
|
45
|
+
assert "custom" in commands
|
|
46
|
+
|
|
47
|
+
def test_custom_command_executes(self, cli_runner, test_app):
|
|
48
|
+
from cuneus.cli import CuneusCLI
|
|
49
|
+
|
|
50
|
+
cli = CuneusCLI()
|
|
51
|
+
result = cli_runner.invoke(cli, ["custom"])
|
|
52
|
+
|
|
53
|
+
assert result.exit_code == 0
|
|
54
|
+
assert "custom command executed" in result.output
|
|
55
|
+
|
|
56
|
+
def test_help_shows_all_commands(self, cli_runner, test_app):
|
|
57
|
+
from cuneus.cli import CuneusCLI
|
|
58
|
+
|
|
59
|
+
cli = CuneusCLI()
|
|
60
|
+
result = cli_runner.invoke(cli, ["--help"])
|
|
61
|
+
|
|
62
|
+
assert result.exit_code == 0
|
|
63
|
+
assert "dev" in result.output
|
|
64
|
+
assert "custom" in result.output
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestBuiltInCommands:
|
|
68
|
+
def test_routes_command_lists_routes(self, cli_runner, test_app):
|
|
69
|
+
from cuneus.cli import CuneusCLI
|
|
70
|
+
|
|
71
|
+
cli = CuneusCLI()
|
|
72
|
+
result = cli_runner.invoke(cli, ["routes"])
|
|
73
|
+
|
|
74
|
+
assert result.exit_code == 0
|
|
75
|
+
assert "/hello" in result.output
|
|
76
|
+
assert "/healthz" in result.output
|
|
77
|
+
|
|
78
|
+
def test_dev_command_starts_uvicorn(self, cli_runner, test_app, mocker):
|
|
79
|
+
mock_run = mocker.patch("uvicorn.run")
|
|
80
|
+
|
|
81
|
+
from cuneus.cli import CuneusCLI
|
|
82
|
+
|
|
83
|
+
cli = CuneusCLI()
|
|
84
|
+
result = cli_runner.invoke(
|
|
85
|
+
cli, ["dev", "--host", "127.0.0.1", "--port", "9000"]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
assert result.exit_code == 0
|
|
89
|
+
mock_run.assert_called_once()
|
|
90
|
+
call_kwargs = mock_run.call_args.kwargs
|
|
91
|
+
assert call_kwargs["host"] == "127.0.0.1"
|
|
92
|
+
assert call_kwargs["port"] == 9000
|
|
93
|
+
assert call_kwargs["reload"] is True
|
|
94
|
+
|
|
95
|
+
def test_prod_command_starts_uvicorn(self, cli_runner, test_app, mocker):
|
|
96
|
+
mock_run = mocker.patch("uvicorn.run")
|
|
97
|
+
|
|
98
|
+
from cuneus.cli import CuneusCLI
|
|
99
|
+
|
|
100
|
+
cli = CuneusCLI()
|
|
101
|
+
result = cli_runner.invoke(cli, ["prod", "--workers", "4"])
|
|
102
|
+
|
|
103
|
+
assert result.exit_code == 0
|
|
104
|
+
mock_run.assert_called_once()
|
|
105
|
+
call_kwargs = mock_run.call_args.kwargs
|
|
106
|
+
assert call_kwargs["workers"] == 4
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestCLIFromSubdirectory:
|
|
110
|
+
def test_discovers_pyproject_from_subdirectory(
|
|
111
|
+
self, cli_runner: CliRunner, test_app
|
|
112
|
+
):
|
|
113
|
+
import os
|
|
114
|
+
|
|
115
|
+
subdir = test_app / "src" / "nested"
|
|
116
|
+
subdir.mkdir(parents=True)
|
|
117
|
+
os.chdir(subdir)
|
|
118
|
+
|
|
119
|
+
for mod in list(sys.modules):
|
|
120
|
+
if mod.startswith(("main", "cuneus.cli")):
|
|
121
|
+
del sys.modules[mod]
|
|
122
|
+
|
|
123
|
+
from cuneus.cli import CuneusCLI
|
|
124
|
+
|
|
125
|
+
cli = CuneusCLI()
|
|
126
|
+
commands = cli.list_commands(None) # type: ignore
|
|
127
|
+
|
|
128
|
+
assert "custom" in commands
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TestCLIMissingConfig:
|
|
132
|
+
def test_handles_missing_pyproject(self, cli_runner, tmp_path):
|
|
133
|
+
with cli_runner.isolated_filesystem(temp_dir=tmp_path):
|
|
134
|
+
for mod in list(sys.modules):
|
|
135
|
+
if mod.startswith("cuneus.cli"):
|
|
136
|
+
del sys.modules[mod]
|
|
137
|
+
|
|
138
|
+
from cuneus.cli import CuneusCLI
|
|
139
|
+
|
|
140
|
+
cli = CuneusCLI()
|
|
141
|
+
result = cli_runner.invoke(cli, ["--help"])
|
|
142
|
+
|
|
143
|
+
assert result.exit_code == 0
|
|
144
|
+
result = cli_runner.invoke(cli, ["dev"])
|
|
145
|
+
assert result.exit_code == 2
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# tests/cli/testapp/main.py
|
|
2
|
+
"""Test application for CLI integration tests."""
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from cuneus import build_app, BaseExtension
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestExtension(BaseExtension):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
app, cli, lifespan = build_app(TestExtension)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@cli.command()
|
|
16
|
+
def custom():
|
|
17
|
+
"""A custom user command."""
|
|
18
|
+
click.echo("custom command executed")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.get("/hello")
|
|
22
|
+
def hello():
|
|
23
|
+
return {"message": "hello"}
|