Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/sidecar-packaging.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ __pycache__
src/harmonization_framework.egg-info
node_modules
replay.log
dist
*.spec
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<os_short>/
```

## Serialization Format

Harmonization rules and primitives serialize to JSON-friendly dictionaries with a consistent schema:
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]

[project.scripts]
harmonization-sidecar = "harmonization_framework.api.sidecar:main"
24 changes: 24 additions & 0 deletions scripts/build_sidecar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail

OS_SHORT=${1:-}
if [[ -z "$OS_SHORT" ]]; then
echo "Usage: $0 <mac|win|linux>"
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}"
4 changes: 4 additions & 0 deletions scripts/sidecar_entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from harmonization_framework.api.sidecar import main

if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions src/harmonization_framework/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
38 changes: 38 additions & 0 deletions src/harmonization_framework/api/routes/shutdown.py
Original file line number Diff line number Diff line change
@@ -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")
121 changes: 121 additions & 0 deletions src/harmonization_framework/api/sidecar.py
Original file line number Diff line number Diff line change
@@ -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()
25 changes: 25 additions & 0 deletions tests/test_shutdown_endpoint.py
Original file line number Diff line number Diff line change
@@ -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
Loading