scaffold-ca-python 0.1.1__py3-none-any.whl
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.
- scaffold_ca_python/__init__.py +1 -0
- scaffold_ca_python/cli.py +39 -0
- scaffold_ca_python/commands/__init__.py +0 -0
- scaffold_ca_python/commands/delete_module.py +216 -0
- scaffold_ca_python/commands/generate_driven_adapter.py +182 -0
- scaffold_ca_python/commands/generate_entry_point.py +304 -0
- scaffold_ca_python/commands/generate_helper.py +135 -0
- scaffold_ca_python/commands/generate_model.py +134 -0
- scaffold_ca_python/commands/generate_pipeline.py +158 -0
- scaffold_ca_python/commands/generate_project.py +189 -0
- scaffold_ca_python/commands/generate_use_case.py +136 -0
- scaffold_ca_python/commands/update_project.py +84 -0
- scaffold_ca_python/commands/validate_structure.py +90 -0
- scaffold_ca_python/core/__init__.py +0 -0
- scaffold_ca_python/core/file_writer.py +128 -0
- scaffold_ca_python/core/module_builder.py +127 -0
- scaffold_ca_python/core/name_utils.py +59 -0
- scaffold_ca_python/core/project_detector.py +93 -0
- scaffold_ca_python/core/pyproject_writer.py +169 -0
- scaffold_ca_python/core/structure_validator.py +142 -0
- scaffold_ca_python/core/template_renderer.py +100 -0
- scaffold_ca_python/factory/__init__.py +16 -0
- scaffold_ca_python/factory/driven_adapters/__init__.py +0 -0
- scaffold_ca_python/factory/driven_adapters/da_generic.py +65 -0
- scaffold_ca_python/factory/driven_adapters/da_rest_consumer.py +64 -0
- scaffold_ca_python/factory/driven_adapters/da_secrets.py +64 -0
- scaffold_ca_python/factory/entry_points/__init__.py +0 -0
- scaffold_ca_python/factory/entry_points/ep_agent.py +91 -0
- scaffold_ca_python/factory/entry_points/ep_generic.py +75 -0
- scaffold_ca_python/factory/entry_points/ep_mcp.py +138 -0
- scaffold_ca_python/factory/entry_points/ep_restapi.py +133 -0
- scaffold_ca_python/factory/simple/__init__.py +0 -0
- scaffold_ca_python/factory/simple/delete_module_factory.py +85 -0
- scaffold_ca_python/factory/simple/helper_factory.py +67 -0
- scaffold_ca_python/factory/simple/model_factory.py +57 -0
- scaffold_ca_python/factory/simple/use_case_factory.py +59 -0
- scaffold_ca_python/models/__init__.py +0 -0
- scaffold_ca_python/models/context.py +60 -0
- scaffold_ca_python/models/file_operation.py +47 -0
- scaffold_ca_python/models/layer.py +41 -0
- scaffold_ca_python/models/violation.py +26 -0
- scaffold_ca_python/templates/__init__.py +0 -0
- scaffold_ca_python/templates/driven_adapter/generic/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/driven_adapter/generic/adapter.py.jinja2 +18 -0
- scaffold_ca_python/templates/driven_adapter/generic/test_adapter.py.jinja2 +22 -0
- scaffold_ca_python/templates/driven_adapter/rest_consumer/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/driven_adapter/rest_consumer/rest_consumer.py.jinja2 +27 -0
- scaffold_ca_python/templates/driven_adapter/rest_consumer/test_rest_consumer.py.jinja2 +24 -0
- scaffold_ca_python/templates/driven_adapter/secrets/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/driven_adapter/secrets/secrets_adapter.py.jinja2 +37 -0
- scaffold_ca_python/templates/driven_adapter/secrets/test_secrets_adapter.py.jinja2 +26 -0
- scaffold_ca_python/templates/entry_point/agent/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/agent/agent.py.jinja2 +49 -0
- scaffold_ca_python/templates/entry_point/agent/card.py.jinja2 +15 -0
- scaffold_ca_python/templates/entry_point/agent/entrypoint_main.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/agent/test_agent.py.jinja2 +20 -0
- scaffold_ca_python/templates/entry_point/generic/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/generic/entrypoint_main.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/generic/handler.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/generic/test_handler.py.jinja2 +35 -0
- scaffold_ca_python/templates/entry_point/mcp/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/mcp/app.py.jinja2 +51 -0
- scaffold_ca_python/templates/entry_point/mcp/prompts.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/mcp/resources.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/mcp/server.py.jinja2 +27 -0
- scaffold_ca_python/templates/entry_point/mcp/test_app.py.jinja2 +32 -0
- scaffold_ca_python/templates/entry_point/mcp/test_prompts.py.jinja2 +40 -0
- scaffold_ca_python/templates/entry_point/mcp/test_resources.py.jinja2 +47 -0
- scaffold_ca_python/templates/entry_point/mcp/test_tools.py.jinja2 +40 -0
- scaffold_ca_python/templates/entry_point/mcp/tools.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/restapi/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/restapi/app.py.jinja2 +78 -0
- scaffold_ca_python/templates/entry_point/restapi/exception_handler.py.jinja2 +35 -0
- scaffold_ca_python/templates/entry_point/restapi/health.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/restapi/rest_controller.py.jinja2 +26 -0
- scaffold_ca_python/templates/entry_point/restapi/server.py.jinja2 +5 -0
- scaffold_ca_python/templates/entry_point/restapi/test_app.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/restapi/test_exception_handler.py.jinja2 +44 -0
- scaffold_ca_python/templates/entry_point/restapi/test_rest_controller.py.jinja2 +35 -0
- scaffold_ca_python/templates/entry_point/restapi/test_server.py.jinja2 +15 -0
- scaffold_ca_python/templates/helper/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/helper/helper.py.jinja2 +7 -0
- scaffold_ca_python/templates/helper/test_helper.py.jinja2 +8 -0
- scaffold_ca_python/templates/model/model.py.jinja2 +9 -0
- scaffold_ca_python/templates/model/test_model.py.jinja2 +8 -0
- scaffold_ca_python/templates/pipeline/azure/azure_pipelines.yml.jinja2 +28 -0
- scaffold_ca_python/templates/pipeline/github/ci.yml.jinja2 +34 -0
- scaffold_ca_python/templates/project/README.jinja2 +30 -0
- scaffold_ca_python/templates/project/application/config/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/project/application/config/config.py.jinja2 +12 -0
- scaffold_ca_python/templates/project/application/config/container.py.jinja2 +17 -0
- scaffold_ca_python/templates/project/application/config/driven_adapters_container.py.jinja2 +14 -0
- scaffold_ca_python/templates/project/application/config/resource_container.py.jinja2 +17 -0
- scaffold_ca_python/templates/project/application/config/usecases_container.py.jinja2 +16 -0
- scaffold_ca_python/templates/project/dockerfile.jinja2 +22 -0
- scaffold_ca_python/templates/project/dockerignore.jinja2 +19 -0
- scaffold_ca_python/templates/project/gitignore.jinja2 +64 -0
- scaffold_ca_python/templates/project/layer_init.jinja2 +1 -0
- scaffold_ca_python/templates/project/main.py.jinja2 +10 -0
- scaffold_ca_python/templates/project/mypy_ini.jinja2 +5 -0
- scaffold_ca_python/templates/project/pyproject_toml.jinja2 +66 -0
- scaffold_ca_python/templates/project/python_version.jinja2 +1 -0
- scaffold_ca_python/templates/use_case/test_use_case.py.jinja2 +12 -0
- scaffold_ca_python/templates/use_case/use_case.py.jinja2 +9 -0
- scaffold_ca_python-0.1.1.dist-info/METADATA +285 -0
- scaffold_ca_python-0.1.1.dist-info/RECORD +109 -0
- scaffold_ca_python-0.1.1.dist-info/WHEEL +4 -0
- scaffold_ca_python-0.1.1.dist-info/entry_points.txt +3 -0
- scaffold_ca_python-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Tests for {{ project.python_package }}.infrastructure.entry_points.mcp_server.tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from unittest.mock import MagicMock
|
|
8
|
+
|
|
9
|
+
from {{ project.python_package }}.infrastructure.entry_points.mcp_server.tools import bind_tools
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _make_mock_mcp() -> tuple[MagicMock, dict[str, object]]:
|
|
13
|
+
"""Return (mock_mcp, registered) where registered maps tool name → handler."""
|
|
14
|
+
registered: dict[str, object] = {}
|
|
15
|
+
mock = MagicMock()
|
|
16
|
+
mock.tool = lambda name: (lambda fn: registered.setdefault(name, fn) or fn)
|
|
17
|
+
return mock, registered
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_bind_tools_is_async_callable() -> None:
|
|
21
|
+
"""bind_tools must be an async function (or wrap one via @inject)."""
|
|
22
|
+
fn = getattr(bind_tools, "__wrapped__", bind_tools)
|
|
23
|
+
assert inspect.iscoroutinefunction(fn), "bind_tools must be async"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_bind_tools_registers_example_tool() -> None:
|
|
27
|
+
"""Calling bind_tools registers 'example_tool' on the mcp instance."""
|
|
28
|
+
mock_mcp, registered = _make_mock_mcp()
|
|
29
|
+
fn = getattr(bind_tools, "__wrapped__", bind_tools)
|
|
30
|
+
asyncio.run(fn(mock_mcp))
|
|
31
|
+
assert "example_tool" in registered
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_example_tool_returns_expected_result() -> None:
|
|
35
|
+
"""The example_tool inner function returns a string containing the query."""
|
|
36
|
+
mock_mcp, registered = _make_mock_mcp()
|
|
37
|
+
fn = getattr(bind_tools, "__wrapped__", bind_tools)
|
|
38
|
+
asyncio.run(fn(mock_mcp))
|
|
39
|
+
result = asyncio.run(registered["example_tool"]("hello")) # type: ignore[operator]
|
|
40
|
+
assert "hello" in result
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""MCP tools binding — register FastMCP tools via dependency injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dependency_injector.wiring import Provide, inject
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from {{ project.python_package }}.application.config.container import Container
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@inject
|
|
12
|
+
async def bind_tools(
|
|
13
|
+
mcp: FastMCP,
|
|
14
|
+
# Replace with your actual use-case injection:
|
|
15
|
+
# some_usecase: SomeUseCase = Provide[Container.usecase_container.some_usecase],
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Bind all MCP tools to the server instance."""
|
|
18
|
+
|
|
19
|
+
@mcp.tool("example_tool")
|
|
20
|
+
async def example_tool(query: str) -> str:
|
|
21
|
+
"""Example tool stub — replace with your implementation."""
|
|
22
|
+
return f"result for: {query}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""REST API entry-point package."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""FastAPI application factory for {{ project.name }}."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.exceptions import RequestValidationError
|
|
10
|
+
from starlette.exceptions import HTTPException
|
|
11
|
+
|
|
12
|
+
from {{ project.python_package }}.application.config.container import Container
|
|
13
|
+
from {{ project.python_package }}.application.config.config import Settings
|
|
14
|
+
from {{ project.python_package }}.infrastructure.entry_points.api.v1 import rest_controller
|
|
15
|
+
from {{ project.python_package }}.infrastructure.entry_points.api.v1.exception_handler import (
|
|
16
|
+
generic_exception_handler,
|
|
17
|
+
http_exception_handler,
|
|
18
|
+
validation_exception_handler,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@asynccontextmanager
|
|
25
|
+
async def lifespan(app: FastAPI) -> None:
|
|
26
|
+
"""Wire the DI container on startup and unwire on shutdown."""
|
|
27
|
+
container: Container = app.state.container # type: ignore[attr-defined]
|
|
28
|
+
container.wire(
|
|
29
|
+
modules=[
|
|
30
|
+
rest_controller,
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
logger.info("Application startup complete.")
|
|
34
|
+
# Uncomment to initialize async resources on startup, if needed
|
|
35
|
+
# await container.resource_container.init_resources()
|
|
36
|
+
yield
|
|
37
|
+
container.unwire()
|
|
38
|
+
# Uncomment the following line if you have async resources to shutdown
|
|
39
|
+
# await container.resource_container.shutdown_resources()
|
|
40
|
+
logger.info("Application shutdown complete.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_app() -> FastAPI:
|
|
44
|
+
"""Create and configure the FastAPI application."""
|
|
45
|
+
settings = Settings()
|
|
46
|
+
container = Container()
|
|
47
|
+
|
|
48
|
+
app = FastAPI(
|
|
49
|
+
title="{{ project.name }}",
|
|
50
|
+
lifespan=lifespan,
|
|
51
|
+
root_path="/api"
|
|
52
|
+
)
|
|
53
|
+
app.state.container = container # type: ignore[attr-defined]
|
|
54
|
+
app.state.settings = settings # type: ignore[attr-defined]
|
|
55
|
+
|
|
56
|
+
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore[arg-type]
|
|
57
|
+
app.add_exception_handler(RequestValidationError, validation_exception_handler) # type: ignore[arg-type]
|
|
58
|
+
app.add_exception_handler(Exception, generic_exception_handler) # type: ignore[arg-type]
|
|
59
|
+
|
|
60
|
+
app.include_router(rest_controller.router)
|
|
61
|
+
|
|
62
|
+
return app
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def start_server() -> None:
|
|
66
|
+
"""Launch uvicorn — called from the [project.scripts] entrypoint."""
|
|
67
|
+
settings = Settings()
|
|
68
|
+
uvicorn.run(
|
|
69
|
+
"{{ project.python_package }}.application.app:create_app",
|
|
70
|
+
host=getattr(settings, "HOST", "0.0.0.0"),
|
|
71
|
+
port=int(getattr(settings, "PORT", 8000)),
|
|
72
|
+
factory=True,
|
|
73
|
+
reload=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
start_server()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Exception handlers for {{ project.name }} REST API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from fastapi import Request
|
|
7
|
+
from fastapi.exceptions import RequestValidationError
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
from starlette.exceptions import HTTPException
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
15
|
+
"""Handle HTTP exceptions raised by route handlers."""
|
|
16
|
+
logger.warning("HTTP %s: %s — %s %s", exc.status_code, exc.detail, request.method, request.url)
|
|
17
|
+
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def validation_exception_handler(
|
|
21
|
+
request: Request,
|
|
22
|
+
exc: RequestValidationError,
|
|
23
|
+
) -> JSONResponse:
|
|
24
|
+
"""Handle Pydantic validation errors on incoming requests."""
|
|
25
|
+
logger.warning("Validation error on %s %s: %s", request.method, request.url, exc.errors())
|
|
26
|
+
return JSONResponse(
|
|
27
|
+
status_code=422,
|
|
28
|
+
content={"detail": exc.errors()},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
33
|
+
"""Catch-all handler for unexpected exceptions."""
|
|
34
|
+
logger.exception("Unhandled exception on %s %s", request.method, request.url)
|
|
35
|
+
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Health-check endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("")
|
|
11
|
+
async def health() -> dict[str, str]:
|
|
12
|
+
"""Return service liveness status."""
|
|
13
|
+
return {"status": "ok"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""REST controller — versioned API router for {{ project.name }}."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/v1")
|
|
12
|
+
{% if routes %}
|
|
13
|
+
{% for path, method in routes %}
|
|
14
|
+
|
|
15
|
+
@router.{{ method }}("{{ path }}")
|
|
16
|
+
async def {{ method }}_{{ path | replace("/", "_") | replace("-", "_") | trim("_") }}() -> JSONResponse:
|
|
17
|
+
"""Handle {{ method.upper() }} {{ path }}."""
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
{% endfor %}
|
|
20
|
+
{% else %}
|
|
21
|
+
|
|
22
|
+
@router.get("/health")
|
|
23
|
+
async def health() -> JSONResponse:
|
|
24
|
+
"""Health check endpoint."""
|
|
25
|
+
return JSONResponse(content={"status": "app is online"})
|
|
26
|
+
{% endif %}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Tests for application factory — {{ project.name }}."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
|
|
6
|
+
from {{ project.python_package }}.application.app import create_app
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_create_app_returns_fastapi_instance() -> None:
|
|
10
|
+
app = create_app()
|
|
11
|
+
assert isinstance(app, FastAPI)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_create_app_has_router_included() -> None:
|
|
15
|
+
app = create_app()
|
|
16
|
+
routes = [r.path for r in app.routes] # type: ignore[attr-defined]
|
|
17
|
+
assert any("/api" in p or "/health" in p for p in routes)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_create_app_registers_exception_handlers() -> None:
|
|
21
|
+
app = create_app()
|
|
22
|
+
assert app.exception_handlers # type: ignore[attr-defined]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Tests for exception handlers — {{ project.name }}."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import HTTPException
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from {{ project.python_package }}.application.app import create_app
|
|
8
|
+
|
|
9
|
+
_client = TestClient(create_app(), raise_server_exceptions=False)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_http_exception_returns_json_with_status_code() -> None:
|
|
13
|
+
app = create_app()
|
|
14
|
+
|
|
15
|
+
@app.get("/_test_http_error")
|
|
16
|
+
def _raise_http() -> None:
|
|
17
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
18
|
+
|
|
19
|
+
client = TestClient(app, raise_server_exceptions=False)
|
|
20
|
+
response = client.get("/_test_http_error")
|
|
21
|
+
assert response.status_code == 404
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_unhandled_exception_returns_500() -> None:
|
|
25
|
+
app = create_app()
|
|
26
|
+
|
|
27
|
+
@app.get("/_test_unhandled")
|
|
28
|
+
def _raise_unhandled() -> None:
|
|
29
|
+
raise RuntimeError("boom")
|
|
30
|
+
|
|
31
|
+
client = TestClient(app, raise_server_exceptions=False)
|
|
32
|
+
response = client.get("/_test_unhandled")
|
|
33
|
+
assert response.status_code == 500
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_validation_error_returns_422() -> None:
|
|
37
|
+
app = create_app()
|
|
38
|
+
client = TestClient(app, raise_server_exceptions=False)
|
|
39
|
+
# POST to /api/v1 with bad payload should trigger RequestValidationError → 422
|
|
40
|
+
response = client.post(
|
|
41
|
+
"/api/v1/",
|
|
42
|
+
json={"bad_field": True},
|
|
43
|
+
)
|
|
44
|
+
assert response.status_code in (422, 404, 405)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tests for {{ project.name }} REST API health endpoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from starlette.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from {{ project.python_package }}.application.app import create_app
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture()
|
|
11
|
+
def client() -> TestClient:
|
|
12
|
+
"""TestClient fixture wired to the FastAPI application.
|
|
13
|
+
|
|
14
|
+
Add DI overrides here before constructing TestClient, e.g.:
|
|
15
|
+
app = create_app()
|
|
16
|
+
app.state.container.my_service.override(MockService())
|
|
17
|
+
"""
|
|
18
|
+
return TestClient(create_app())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_health_returns_200(client: TestClient) -> None:
|
|
22
|
+
"""GET /v1/health must return HTTP 200."""
|
|
23
|
+
response = client.get("/v1/health")
|
|
24
|
+
assert response.status_code == 200
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_health_returns_online_status(client: TestClient) -> None:
|
|
28
|
+
"""GET /v1/health body must contain a status key."""
|
|
29
|
+
response = client.get("/v1/health")
|
|
30
|
+
assert response.json().get("status") is not None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Add your custom endpoint tests below this line
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Tests for server entry point — {{ project.name }}."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_start_server_is_callable() -> None:
|
|
8
|
+
from {{ project.python_package }}.application.app import start_server
|
|
9
|
+
|
|
10
|
+
assert callable(start_server)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_server_module_imports_without_error() -> None:
|
|
14
|
+
mod = importlib.import_module("{{ project.python_package }}.server")
|
|
15
|
+
assert mod is not None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""{{ class_name }} helper package."""
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Tests for {{ class_name }}."""
|
|
2
|
+
|
|
3
|
+
from {{ project.python_package }}.infrastructure.helpers.{{ module_name }}.{{ module_name }} import {{ class_name }}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_{{ module_name }}_instantiation() -> None:
|
|
7
|
+
helper = {{ class_name }}()
|
|
8
|
+
assert helper is not None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
trigger:
|
|
2
|
+
branches:
|
|
3
|
+
include:
|
|
4
|
+
- main
|
|
5
|
+
- master
|
|
6
|
+
|
|
7
|
+
pool:
|
|
8
|
+
vmImage: "ubuntu-latest"
|
|
9
|
+
|
|
10
|
+
steps:
|
|
11
|
+
- task: UsePythonVersion@0
|
|
12
|
+
inputs:
|
|
13
|
+
versionSpec: "3.13"
|
|
14
|
+
displayName: "Set up Python 3.13"
|
|
15
|
+
|
|
16
|
+
- script: |
|
|
17
|
+
pip install uv
|
|
18
|
+
uv sync --all-extras
|
|
19
|
+
displayName: "Install dependencies"
|
|
20
|
+
|
|
21
|
+
- script: uv run ruff check src/ tests/
|
|
22
|
+
displayName: "Lint (ruff)"
|
|
23
|
+
|
|
24
|
+
- script: uv run mypy src/
|
|
25
|
+
displayName: "Type check (mypy)"
|
|
26
|
+
|
|
27
|
+
- script: uv run pytest --cov=src/ --cov-fail-under=80
|
|
28
|
+
displayName: "Test (pytest + coverage)"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["main", "master"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: ["main", "master"]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
ci:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Set up Python 3.13
|
|
17
|
+
uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.13"
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
uses: astral-sh/setup-uv@v3
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: uv sync --all-extras
|
|
26
|
+
|
|
27
|
+
- name: Lint (ruff)
|
|
28
|
+
run: uv run ruff check src/ tests/
|
|
29
|
+
|
|
30
|
+
- name: Type check (mypy)
|
|
31
|
+
run: uv run mypy src/
|
|
32
|
+
|
|
33
|
+
- name: Test (pytest + coverage)
|
|
34
|
+
run: uv run pytest --cov=src/ --cov-fail-under=80
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# {{ name }}
|
|
2
|
+
|
|
3
|
+
A Clean Architecture Python project.
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv sync
|
|
9
|
+
uv run pytest
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Architecture
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
src/{{ python_package }}/
|
|
16
|
+
├── application/ # Application bootstrap and DI wiring
|
|
17
|
+
├── domain/
|
|
18
|
+
│ ├── model/ # Entities, value objects (Pydantic v2)
|
|
19
|
+
│ └── usecase/ # Business logic, async use cases
|
|
20
|
+
└── infrastructure/
|
|
21
|
+
├── driven_adapters/ # Outbound adapters (DB, HTTP, secrets)
|
|
22
|
+
├── entry_points/ # Inbound adapters (REST, CLI, events)
|
|
23
|
+
└── helpers/ # Cross-cutting utilities
|
|
24
|
+
|
|
25
|
+
tests/ # Mirrors src/ structure
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Package
|
|
29
|
+
|
|
30
|
+
`{{ package }}`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DI configuration package."""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Application settings loaded from environment / .env file."""
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Settings(BaseSettings):
|
|
6
|
+
ENV: str = "development"
|
|
7
|
+
LOG_LEVEL: str = "INFO"
|
|
8
|
+
|
|
9
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
settings = Settings()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Root application DI container."""
|
|
2
|
+
from dependency_injector import containers, providers
|
|
3
|
+
|
|
4
|
+
from {{ python_package }}.application.config.resource_container import ResourceContainer
|
|
5
|
+
from {{ python_package }}.application.config.driven_adapters_container import DAContainer
|
|
6
|
+
from {{ python_package }}.application.config.usecases_container import UseCaseContainer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Container(containers.DeclarativeContainer):
|
|
10
|
+
"""Top-level container wiring all sub-containers."""
|
|
11
|
+
|
|
12
|
+
resource_container = providers.Container(ResourceContainer)
|
|
13
|
+
da_container = providers.Container(DAContainer)
|
|
14
|
+
usecase_container = providers.Container(
|
|
15
|
+
UseCaseContainer,
|
|
16
|
+
da_container=da_container,
|
|
17
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Driven-adapters DI container."""
|
|
2
|
+
from dependency_injector import containers, providers
|
|
3
|
+
|
|
4
|
+
# Import driven-adapter classes here to wire them in the DAContainer below.
|
|
5
|
+
#{{ python_package }}.infrastructure.driven_adapters.my_repo.MyRepo
|
|
6
|
+
|
|
7
|
+
class DAContainer(containers.DeclarativeContainer):
|
|
8
|
+
"""Wire infrastructure driven-adapter singletons here."""
|
|
9
|
+
|
|
10
|
+
# Example:
|
|
11
|
+
# my_repo = providers.Singleton(
|
|
12
|
+
# MyRepo,
|
|
13
|
+
# arg1=arg1,
|
|
14
|
+
# )
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Resource DI container — wires infrastructure resources (DB pools, HTTP clients, etc.)."""
|
|
2
|
+
from dependency_injector import containers, providers
|
|
3
|
+
|
|
4
|
+
from {{ python_package }}.application.config.config import settings
|
|
5
|
+
|
|
6
|
+
# Import resource-level classes here to wire them in the ResourceContainer below.
|
|
7
|
+
#{{ python_package }}.infrastructure.driven_adapters.my_repo.MyResource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ResourceContainer(containers.DeclarativeContainer):
|
|
11
|
+
"""Wire resource-level singletons (connections, clients) here."""
|
|
12
|
+
|
|
13
|
+
# Example:
|
|
14
|
+
# db_pool = providers.Resource(
|
|
15
|
+
# MyResource,
|
|
16
|
+
# arg1=settings.ARG1,
|
|
17
|
+
# )
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Use-case DI container."""
|
|
2
|
+
from dependency_injector import containers, providers
|
|
3
|
+
|
|
4
|
+
# Import use-case classes here to wire them in the UseCaseContainer below.
|
|
5
|
+
#{{ python_package }}.application.usecases.my_use_case.MyUseCase
|
|
6
|
+
|
|
7
|
+
class UseCaseContainer(containers.DeclarativeContainer):
|
|
8
|
+
"""Wire use-case singletons here, injecting driven adapters."""
|
|
9
|
+
|
|
10
|
+
da_container = providers.DependenciesContainer()
|
|
11
|
+
|
|
12
|
+
# Example:
|
|
13
|
+
# my_use_case = providers.Singleton(
|
|
14
|
+
# MyUseCase,
|
|
15
|
+
# arg1=da_container.my_repo,
|
|
16
|
+
# )
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
FROM python:3.13-slim AS base
|
|
3
|
+
|
|
4
|
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
5
|
+
PYTHONUNBUFFERED=1
|
|
6
|
+
|
|
7
|
+
# Install uv
|
|
8
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
9
|
+
|
|
10
|
+
WORKDIR /app
|
|
11
|
+
|
|
12
|
+
# Install dependencies (cached layer)
|
|
13
|
+
COPY pyproject.toml .
|
|
14
|
+
RUN uv sync --no-dev --no-install-project
|
|
15
|
+
|
|
16
|
+
# Copy source
|
|
17
|
+
COPY src/ src/
|
|
18
|
+
|
|
19
|
+
# Install project
|
|
20
|
+
RUN uv sync --no-dev
|
|
21
|
+
|
|
22
|
+
CMD ["python", "-m", "{{ python_package }}"]
|