cuneus 0.2.7__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.
Files changed (35) hide show
  1. {cuneus-0.2.7 → cuneus-0.2.9}/PKG-INFO +1 -1
  2. cuneus-0.2.9/examples/my_app/main.py +6 -0
  3. cuneus-0.2.9/examples/pyproject.toml +7 -0
  4. {cuneus-0.2.7 → cuneus-0.2.9}/pyproject.toml +1 -1
  5. cuneus-0.2.9/src/cuneus/cli.py +53 -0
  6. {cuneus-0.2.7 → cuneus-0.2.9}/src/cuneus/core/application.py +9 -9
  7. {cuneus-0.2.7 → cuneus-0.2.9}/src/cuneus/core/extensions.py +7 -0
  8. {cuneus-0.2.7 → cuneus-0.2.9}/src/cuneus/core/logging.py +9 -7
  9. {cuneus-0.2.7 → cuneus-0.2.9}/src/cuneus/core/settings.py +20 -3
  10. cuneus-0.2.9/src/cuneus/ext/health.py +128 -0
  11. cuneus-0.2.9/src/cuneus/ext/server.py +54 -0
  12. cuneus-0.2.9/src/cuneus/py.typed +0 -0
  13. cuneus-0.2.9/src/cuneus/utils.py +14 -0
  14. cuneus-0.2.9/tests/cli/test_cli.py +145 -0
  15. cuneus-0.2.9/tests/cli/testapp/__init__.py +0 -0
  16. cuneus-0.2.9/tests/cli/testapp/main.py +23 -0
  17. cuneus-0.2.9/tests/cli/testapp/pyproject.toml +9 -0
  18. cuneus-0.2.9/tests/ext/test_health.py +109 -0
  19. {cuneus-0.2.7 → cuneus-0.2.9}/tests/test_exceptions.py +1 -1
  20. {cuneus-0.2.7 → cuneus-0.2.9}/tests/test_integration.py +18 -5
  21. cuneus-0.2.9/tests/test_utils.py +23 -0
  22. {cuneus-0.2.7 → cuneus-0.2.9}/uv.lock +1 -1
  23. cuneus-0.2.7/src/cuneus/cli.py +0 -143
  24. cuneus-0.2.7/src/cuneus/ext/health.py +0 -129
  25. cuneus-0.2.7/tests/test_cli.py +0 -252
  26. {cuneus-0.2.7 → cuneus-0.2.9}/.gitignore +0 -0
  27. {cuneus-0.2.7 → cuneus-0.2.9}/.python-version +0 -0
  28. {cuneus-0.2.7 → cuneus-0.2.9}/Makefile +0 -0
  29. {cuneus-0.2.7 → cuneus-0.2.9}/README.md +0 -0
  30. {cuneus-0.2.7/src/cuneus/core → cuneus-0.2.9/examples/my_app}/__init__.py +0 -0
  31. {cuneus-0.2.7 → cuneus-0.2.9}/src/cuneus/__init__.py +0 -0
  32. {cuneus-0.2.7/src/cuneus/ext → cuneus-0.2.9/src/cuneus/core}/__init__.py +0 -0
  33. {cuneus-0.2.7 → cuneus-0.2.9}/src/cuneus/core/exceptions.py +0 -0
  34. /cuneus-0.2.7/src/cuneus/py.typed → /cuneus-0.2.9/src/cuneus/ext/__init__.py +0 -0
  35. {cuneus-0.2.7 → cuneus-0.2.9}/tests/test_extensions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuneus
3
- Version: 0.2.7
3
+ Version: 0.2.9
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
@@ -0,0 +1,6 @@
1
+ from cuneus import build_app
2
+
3
+
4
+ app, cli, lifespan = build_app()
5
+
6
+ __all__ = ["app", "cli", "lifespan"]
@@ -0,0 +1,7 @@
1
+ [project]
2
+ name = "test-project"
3
+ version = "0.0.1"
4
+
5
+ [tool.cuneus]
6
+ app_module = "my_app.main:app"
7
+ cli_module = "my_app.main:cli"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cuneus"
3
- version = "0.2.7"
3
+ version = "0.2.9"
4
4
  description = "ASGI application wrapper"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Robert Myers", email = "robert@julython.org" }]
@@ -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.
@@ -5,6 +5,7 @@ Structured logging with structlog and request context.
5
5
  from __future__ import annotations
6
6
 
7
7
  import logging
8
+ import shutil
8
9
  import time
9
10
  import uuid
10
11
  from typing import Any, Awaitable, Callable
@@ -36,9 +37,14 @@ def configure_structlog(settings: Settings | None = None) -> None:
36
37
  structlog.processors.UnicodeDecoder(),
37
38
  ]
38
39
 
39
- renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(colors=True)
40
- if log_settings.log_json: # pragma: no cover
41
- 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_to=pad_event
47
+ )
42
48
 
43
49
  # Configure structlog
44
50
  structlog.configure(
@@ -84,10 +90,6 @@ class LoggingExtension(BaseExtension):
84
90
  self.settings = settings or Settings()
85
91
  configure_structlog(settings)
86
92
 
87
- async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
88
- # app.add_middleware(RequestLoggingMiddleware)
89
- return {}
90
-
91
93
  def middleware(self) -> list[Middleware]:
92
94
  return [
93
95
  Middleware(
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
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"}
@@ -0,0 +1,9 @@
1
+ [project]
2
+ name = 'testapp'
3
+ version = '0.0.1'
4
+
5
+
6
+ [tool.cuneus]
7
+ app_name = "Testy McTester"
8
+ app_module = "main:app"
9
+ cli_module = "main:cli"