diff --git a/.env.example b/.env.example index 9bb6bfd..20b2efb 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,7 @@ LOGGING_LEVEL=INFO LOGGING_FORMATTER=pretty + +APP_HOST=127.0.0.1 +APP_PORT=8000 +DOCS_HOST=127.0.0.1 +DOCS_PORT=8008 diff --git a/justfile b/justfile index 3cc5ad2..1dfa7e3 100644 --- a/justfile +++ b/justfile @@ -4,6 +4,9 @@ set dotenv-load := true VENV_DIR := ".venv" BUILD_DIR := "build" CACHE_DIR := ".cache" + +APP_HOST := env_var_or_default("APP_HOST", "127.0.0.1") +APP_PORT := env_var_or_default("APP_PORT", "8000") DOCS_HOST := env_var_or_default("DOCS_HOST", "127.0.0.1") DOCS_PORT := env_var_or_default("DOCS_PORT", "8008") @@ -60,3 +63,12 @@ changelog-build: changelog-fragment: @uv run --group=changelog towncrier create + +app-dev-serve: + @uv run --group=dev granian \ + --host="{{ APP_HOST }}" \ + --port="{{ APP_PORT }}" \ + --interface="asgi" \ + --factory \ + --reload \ + app.interface.http.asgi:create_asgi_application diff --git a/pyproject.toml b/pyproject.toml index 06cc2ec..1950beb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,15 @@ requires-python = ">=3.14" classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.14" ] dynamic = [ "version" ] dependencies = [ + "granian>=2.6", + "litestar>=2.19", + "msgspec>=0.20", "pydantic-settings>=2.12", ] [dependency-groups] dev = [ + "granian[reload]>=2.6", { include-group = "changelog" }, { include-group = "docs" }, { include-group = "lint" }, @@ -118,6 +122,7 @@ lint.isort.lines-after-imports = 2 lint.pydocstyle.convention = "numpy" [tool.pytest.ini_options] +anyio_mode = "auto" cache_dir = ".cache/pytest" pythonpath = [ "src" ] testpaths = [ "tests" ] @@ -132,6 +137,7 @@ log_cli = true log_cli_level = "INFO" filterwarnings = [ "error", + "ignore:Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater.:UserWarning", ] [tool.coverage.run] diff --git a/src/app/__init__.py b/src/app/__init__.py index 1e4e3d4..9708609 100644 --- a/src/app/__init__.py +++ b/src/app/__init__.py @@ -1,4 +1,7 @@ -from .__meta__ import __version__ +from .__meta__ import __api_version__, __version__ -__all__ = ("__version__",) +__all__ = ( + "__api_version__", + "__version__", +) diff --git a/src/app/__meta__.py b/src/app/__meta__.py index 0beccff..3a8c66c 100644 --- a/src/app/__meta__.py +++ b/src/app/__meta__.py @@ -1 +1,2 @@ __version__ = "0.0.0a0" +__api_version__ = "0.0.0a0" diff --git a/src/app/interface/__init__.py b/src/app/interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/interface/http/__init__.py b/src/app/interface/http/__init__.py new file mode 100644 index 0000000..4a9e627 --- /dev/null +++ b/src/app/interface/http/__init__.py @@ -0,0 +1,4 @@ +from . import asgi + + +__all__ = ("asgi",) diff --git a/src/app/interface/http/asgi.py b/src/app/interface/http/asgi.py new file mode 100644 index 0000000..41b53e1 --- /dev/null +++ b/src/app/interface/http/asgi.py @@ -0,0 +1,27 @@ +from litestar import Litestar +from litestar.openapi import OpenAPIConfig + +from app import __api_version__ +from app.interface.http.controller.system import health +from app.platform.config.loaders import load_logging_config +from app.platform.logging import configure_logging + + +def create_asgi_application() -> Litestar: + """Create and configure the ASGI application. + + Returns + ------- + Litestar + ASGI application. + """ + app = Litestar( + route_handlers=[ + health, + ], + openapi_config=OpenAPIConfig(title="Land Sight API", version=__api_version__), + ) + logging_config = load_logging_config() + configure_logging(config=logging_config) + + return app diff --git a/src/app/interface/http/controller/__init__.py b/src/app/interface/http/controller/__init__.py new file mode 100644 index 0000000..747bb64 --- /dev/null +++ b/src/app/interface/http/controller/__init__.py @@ -0,0 +1,4 @@ +from . import system + + +__all__ = ("system",) diff --git a/src/app/interface/http/controller/system.py b/src/app/interface/http/controller/system.py new file mode 100644 index 0000000..1d56f6d --- /dev/null +++ b/src/app/interface/http/controller/system.py @@ -0,0 +1,9 @@ +from litestar import get + +from app.interface.http.schema.system import HealthResponse + + +@get("/health", sync_to_thread=False) +def health() -> HealthResponse: + """Check service availability.""" + return HealthResponse(status="ok") diff --git a/src/app/interface/http/schema/__init__.py b/src/app/interface/http/schema/__init__.py new file mode 100644 index 0000000..747bb64 --- /dev/null +++ b/src/app/interface/http/schema/__init__.py @@ -0,0 +1,4 @@ +from . import system + + +__all__ = ("system",) diff --git a/src/app/interface/http/schema/system.py b/src/app/interface/http/schema/system.py new file mode 100644 index 0000000..46677d3 --- /dev/null +++ b/src/app/interface/http/schema/system.py @@ -0,0 +1,9 @@ +import typing + +from msgspec import Struct + + +class HealthResponse(Struct): + """Health response schema.""" + + status: typing.Literal["ok"] = "ok" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..4e7e922 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,27 @@ +import typing + +import pytest +from litestar.testing import AsyncTestClient + +from app.interface.http.asgi import create_asgi_application + + +if typing.TYPE_CHECKING: + from collections.abc import AsyncIterator + + from litestar import Litestar + + +@pytest.fixture(scope="session") +def app() -> Litestar: + """Provide Litestar application instance for tests.""" + app = create_asgi_application() + app.debug = True + return app + + +@pytest.fixture(scope="function") +async def test_client(app: Litestar) -> AsyncIterator[AsyncTestClient[Litestar]]: + """Provide async test client bound to the Litestar app.""" + async with AsyncTestClient(app=app) as client: + yield client diff --git a/tests/integration/interface/http/controller/test_system.py b/tests/integration/interface/http/controller/test_system.py new file mode 100644 index 0000000..43060fa --- /dev/null +++ b/tests/integration/interface/http/controller/test_system.py @@ -0,0 +1,14 @@ +import typing + +from litestar import Litestar, status_codes + + +if typing.TYPE_CHECKING: + from litestar.testing import AsyncTestClient + + +async def test_health_check_endpoint(test_client: AsyncTestClient[Litestar]) -> None: + """Health check endpoint returns HTTP 200 and ok status.""" + response = await test_client.get("/health") + assert response.status_code == status_codes.HTTP_200_OK + assert response.json() == {"status": "ok"}