diff --git a/.github/workflows/sidecar-packaging.yml b/.github/workflows/sidecar-packaging.yml new file mode 100644 index 0000000..ce47833 --- /dev/null +++ b/.github/workflows/sidecar-packaging.yml @@ -0,0 +1,64 @@ +name: Sidecar Packaging + +on: + workflow_dispatch: + +jobs: + build-sidecar: + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: macos-latest + os_short: mac + - os: windows-latest + os_short: win + - os: ubuntu-latest + os_short: linux + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install . + python -m pip install pyinstaller + + - name: Build sidecar (onedir) + run: | + python -m PyInstaller \ + --noconfirm \ + --clean \ + --onedir \ + --name harmonization-sidecar \ + --paths src \ + --distpath dist/sidecar/${{ matrix.os_short }} \ + scripts/sidecar_entry.py + + - name: Package artifact (macOS/Linux) + if: runner.os != 'Windows' + run: | + cd dist/sidecar/${{ matrix.os_short }} + tar -czf ../../harmonization-sidecar-${{ matrix.os_short }}.tar.gz . + + - name: Package artifact (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Compress-Archive -Path dist/sidecar/${{ matrix.os_short }}/* -DestinationPath dist/harmonization-sidecar-${{ matrix.os_short }}.zip + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: harmonization-sidecar-${{ matrix.os_short }} + path: | + dist/harmonization-sidecar-${{ matrix.os_short }}.tar.gz + dist/harmonization-sidecar-${{ matrix.os_short }}.zip diff --git a/.gitignore b/.gitignore index 9f0a56e..3b45981 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ __pycache__ src/harmonization_framework.egg-info node_modules replay.log +dist +*.spec diff --git a/README.md b/README.md index 428267e..579f0ac 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,51 @@ pip install . We recommend using the harmonization framework in an interactive Python environment like a Jupyter notebook. A demonstration is provided in `demo/integration.ipynb`. +### Sidecar (local API service) + +The package exposes a small sidecar entrypoint for running the FastAPI backend +as a local service (intended to be launched by an Electron app). + +Required environment variables: +- `API_PORT` (required): port to bind. +- `API_HOST` (optional): defaults to `127.0.0.1`. + +Example: + +```bash +API_PORT=54321 API_HOST=127.0.0.1 harmonization-sidecar +``` + +When running, the health check is available at: + +``` +GET http://127.0.0.1:54321/health/ +``` + +Graceful shutdown is supported via: + +``` +POST http://127.0.0.1:54321/shutdown/ +``` + +Logs are written to stdout/stderr as JSON lines. Optionally, set `API_LOG_PATH` +to also write logs to a file. + +### Sidecar packaging (CI) + +The repository includes a GitHub Actions workflow that builds the sidecar +executable for macOS, Windows, and Linux. The workflow outputs artifacts: + +- `harmonization-sidecar-mac` (tar.gz) +- `harmonization-sidecar-win` (zip) +- `harmonization-sidecar-linux` (tar.gz) + +Artifacts are built under: + +``` +dist/sidecar// +``` + ## Serialization Format Harmonization rules and primitives serialize to JSON-friendly dictionaries with a consistent schema: diff --git a/pyproject.toml b/pyproject.toml index b3e7212..a1cf980 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,3 +15,6 @@ classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent", ] + +[project.scripts] +harmonization-sidecar = "harmonization_framework.api.sidecar:main" diff --git a/scripts/build_sidecar.sh b/scripts/build_sidecar.sh new file mode 100755 index 0000000..17e40f1 --- /dev/null +++ b/scripts/build_sidecar.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +OS_SHORT=${1:-} +if [[ -z "$OS_SHORT" ]]; then + echo "Usage: $0 " + exit 1 +fi + +python -m pip install --upgrade pip +python -m pip install -r requirements.txt +python -m pip install . +python -m pip install pyinstaller + +python -m PyInstaller \ + --noconfirm \ + --clean \ + --onedir \ + --name harmonization-sidecar \ + --paths src \ + --distpath dist/sidecar/${OS_SHORT} \ + scripts/sidecar_entry.py + +echo "Built sidecar in dist/sidecar/${OS_SHORT}" diff --git a/scripts/sidecar_entry.py b/scripts/sidecar_entry.py new file mode 100644 index 0000000..5981078 --- /dev/null +++ b/scripts/sidecar_entry.py @@ -0,0 +1,4 @@ +from harmonization_framework.api.sidecar import main + +if __name__ == "__main__": + main() diff --git a/src/harmonization_framework/api/app.py b/src/harmonization_framework/api/app.py index ba0f05f..42c01e7 100644 --- a/src/harmonization_framework/api/app.py +++ b/src/harmonization_framework/api/app.py @@ -2,8 +2,10 @@ from harmonization_framework.api.routes.health import router as health_router from harmonization_framework.api.routes.rpc import router as rpc_router +from harmonization_framework.api.routes.shutdown import router as shutdown_router app = FastAPI(title="Harmonization Framework API") app.include_router(health_router, prefix="/health") app.include_router(rpc_router, prefix="/api") +app.include_router(shutdown_router, prefix="/shutdown") diff --git a/src/harmonization_framework/api/routes/shutdown.py b/src/harmonization_framework/api/routes/shutdown.py new file mode 100644 index 0000000..ba3fdd3 --- /dev/null +++ b/src/harmonization_framework/api/routes/shutdown.py @@ -0,0 +1,38 @@ +import os +import signal +import threading +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter() + + +class ShutdownResponse(BaseModel): + status: str + message: str + + +def _send_shutdown_signal() -> None: + """ + Send a platform-appropriate termination signal to the current process. + + Uvicorn will interpret this as a request to shut down gracefully. + """ + # Windows does not support SIGTERM in the same way as POSIX. + # CTRL_BREAK_EVENT is the closest equivalent for requesting a graceful stop. + if os.name == "nt" and hasattr(signal, "CTRL_BREAK_EVENT"): + os.kill(os.getpid(), signal.CTRL_BREAK_EVENT) + else: + # POSIX platforms (macOS/Linux) handle SIGTERM for graceful shutdown. + os.kill(os.getpid(), signal.SIGTERM) + + +@router.post("/") +def shutdown() -> ShutdownResponse: + """ + Request a graceful shutdown of the sidecar process. + + The response is returned immediately; the process exits shortly after. + """ + threading.Timer(0.1, _send_shutdown_signal).start() + return ShutdownResponse(status="ok", message="Shutdown initiated") diff --git a/src/harmonization_framework/api/sidecar.py b/src/harmonization_framework/api/sidecar.py new file mode 100644 index 0000000..7ffc664 --- /dev/null +++ b/src/harmonization_framework/api/sidecar.py @@ -0,0 +1,121 @@ +""" +Sidecar entrypoint for running the FastAPI app as a local service. + +This module is intentionally small and explicit so it can be used as the +packaged executable entrypoint (e.g., via PyInstaller). +""" + +import os +import sys +import json +import logging +from datetime import datetime, timezone +from typing import Optional, List + +import uvicorn + +from harmonization_framework.api.app import app + +DEFAULT_HOST = "127.0.0.1" +ENV_PORT = "API_PORT" +ENV_HOST = "API_HOST" +ENV_LOG_PATH = "API_LOG_PATH" + + +def _parse_port(value: str) -> int: + try: + port = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{ENV_PORT} must be an integer, got {value!r}") from exc + if not (1 <= port <= 65535): + raise ValueError(f"{ENV_PORT} must be in range 1-65535, got {port}") + return port + + +def _resolve_host(value: Optional[str]) -> str: + host = (value or DEFAULT_HOST).strip() + # Restrict to loopback by default for safety. + if host not in {"127.0.0.1", "localhost"}: + raise ValueError(f"{ENV_HOST} must be loopback (127.0.0.1 or localhost), got {host!r}") + return "127.0.0.1" if host == "localhost" else host + + +class _JsonLogFormatter(logging.Formatter): + """Format log records as compact JSON lines for stdout/stderr capture.""" + def format(self, record: logging.LogRecord) -> str: + payload = { + "ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + return json.dumps(payload, separators=(",", ":")) + + +def _configure_logging(log_path: Optional[str]) -> List[logging.Handler]: + """ + Configure structured logging to stdout and optionally to a log file. + """ + formatter = _JsonLogFormatter() + handlers: List[logging.Handler] = [] + + stream_handler = logging.StreamHandler(stream=sys.stdout) + stream_handler.setFormatter(formatter) + handlers.append(stream_handler) + + if log_path: + try: + file_handler = logging.FileHandler(log_path) + except OSError as exc: + raise ValueError(f"{ENV_LOG_PATH} is not writable: {log_path}") from exc + file_handler.setFormatter(formatter) + handlers.append(file_handler) + + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + root_logger.handlers = handlers + + # Ensure uvicorn loggers use the same handlers/format. + for logger_name in ("uvicorn.error", "uvicorn.access"): + logger = logging.getLogger(logger_name) + logger.handlers = handlers + logger.setLevel(logging.INFO) + logger.propagate = False + + return handlers + + +def main() -> None: + """ + Start the FastAPI sidecar using environment configuration. + + Required: + API_PORT: port number to bind (chosen by the launcher). + Optional: + API_HOST: host to bind, defaults to 127.0.0.1. + """ + log_path = os.getenv(ENV_LOG_PATH) + try: + _configure_logging(log_path) + except ValueError as exc: + logging.error(str(exc)) + sys.exit(2) + + port_raw = os.getenv(ENV_PORT) + if not port_raw: + logging.error("Missing required env var: %s", ENV_PORT) + sys.exit(2) + + try: + host = _resolve_host(os.getenv(ENV_HOST)) + port = _parse_port(port_raw) + except ValueError as exc: + logging.error(str(exc)) + sys.exit(2) + + # Use uvicorn as the ASGI server for the FastAPI app. + uvicorn.run(app, host=host, port=port, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/tests/test_shutdown_endpoint.py b/tests/test_shutdown_endpoint.py new file mode 100644 index 0000000..278e288 --- /dev/null +++ b/tests/test_shutdown_endpoint.py @@ -0,0 +1,25 @@ +from harmonization_framework.api.routes import shutdown as shutdown_module + + +def test_shutdown_endpoint_returns_ok(monkeypatch): + scheduled = {"delay": None, "func": None, "started": False} + + class FakeTimer: + def __init__(self, delay, func): + scheduled["delay"] = delay + scheduled["func"] = func + + def start(self): + scheduled["started"] = True + + # Prevent real timers/signals during the test. + monkeypatch.setattr(shutdown_module.threading, "Timer", FakeTimer) + + response = shutdown_module.shutdown() + assert response.status == "ok" + assert "Shutdown initiated" in response.message + + # The shutdown is scheduled asynchronously; ensure it was set up correctly. + assert scheduled["started"] is True + assert scheduled["delay"] == 0.1 + assert scheduled["func"] == shutdown_module._send_shutdown_signal diff --git a/tests/test_sidecar_config.py b/tests/test_sidecar_config.py new file mode 100644 index 0000000..b38c78d --- /dev/null +++ b/tests/test_sidecar_config.py @@ -0,0 +1,86 @@ +import pytest + +from harmonization_framework.api import sidecar +from harmonization_framework.api.sidecar import _parse_port, _resolve_host, _configure_logging + + +def test_parse_port_accepts_valid_range(): + assert _parse_port("1") == 1 + assert _parse_port("65535") == 65535 + + +def test_parse_port_rejects_invalid_values(): + with pytest.raises(ValueError, match="integer"): + _parse_port("nope") + with pytest.raises(ValueError, match="range"): + _parse_port("0") + with pytest.raises(ValueError, match="range"): + _parse_port("70000") + + +def test_resolve_host_defaults_to_loopback(): + assert _resolve_host(None) == "127.0.0.1" + assert _resolve_host("") == "127.0.0.1" + assert _resolve_host("localhost") == "127.0.0.1" + + +def test_resolve_host_rejects_non_loopback(): + with pytest.raises(ValueError, match="loopback"): + _resolve_host("0.0.0.0") + with pytest.raises(ValueError, match="loopback"): + _resolve_host("192.168.1.1") + + +def test_main_exits_when_port_missing(monkeypatch): + monkeypatch.delenv("API_PORT", raising=False) + monkeypatch.setattr(sidecar.uvicorn, "run", lambda *args, **kwargs: None) + + with pytest.raises(SystemExit) as excinfo: + sidecar.main() + + assert excinfo.value.code == 2 + + +def test_main_exits_on_invalid_port(monkeypatch): + monkeypatch.setenv("API_PORT", "not-a-port") + monkeypatch.setattr(sidecar.uvicorn, "run", lambda *args, **kwargs: None) + + with pytest.raises(SystemExit) as excinfo: + sidecar.main() + + assert excinfo.value.code == 2 + + +def test_main_exits_on_invalid_host(monkeypatch): + monkeypatch.setenv("API_PORT", "54321") + monkeypatch.setenv("API_HOST", "0.0.0.0") + monkeypatch.setattr(sidecar.uvicorn, "run", lambda *args, **kwargs: None) + + with pytest.raises(SystemExit) as excinfo: + sidecar.main() + + assert excinfo.value.code == 2 + + +def test_main_runs_with_valid_env(monkeypatch): + calls = {} + + def fake_run(app, host, port, log_level): + calls["host"] = host + calls["port"] = port + calls["log_level"] = log_level + + monkeypatch.setenv("API_PORT", "54321") + monkeypatch.setenv("API_HOST", "127.0.0.1") + monkeypatch.setattr(sidecar.uvicorn, "run", fake_run) + + sidecar.main() + + assert calls == {"host": "127.0.0.1", "port": 54321, "log_level": "info"} + + +def test_configure_logging_with_file(tmp_path): + log_path = tmp_path / "sidecar.log" + handlers = _configure_logging(str(log_path)) + assert len(handlers) == 2 + assert log_path.exists()